diff --git a/.idea/fluency.iml b/.idea/fluency.iml
deleted file mode 100644
index aa0e86d..0000000
--- a/.idea/fluency.iml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
index f267c84..a131810 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -2,7 +2,7 @@
-
+
\ No newline at end of file
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index f498644..e3478f6 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -5,30 +5,14 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+
+
+
+
+
@@ -45,7 +29,7 @@
@@ -65,32 +49,31 @@
- {
+ "keyToString": {
+ "Python.2dtest.executor": "Run",
+ "Python.3d_windows.executor": "Run",
+ "Python.Unnamed.executor": "Run",
+ "Python.draw_widget2d.executor": "Run",
+ "Python.draw_widget_solve.executor": "Run",
+ "Python.fluency.executor": "Run",
+ "Python.fluencyb.executor": "Run",
+ "Python.gl_widget.executor": "Run",
+ "Python.main.executor": "Run",
+ "Python.meshtest.executor": "Run",
+ "Python.side_fluency.executor": "Run",
+ "Python.simple_mesh.executor": "Run",
+ "Python.vtk_widget.executor": "Run",
+ "Python.vulkan.executor": "Run",
+ "RunOnceActivity.OpenProjectViewOnStart": "true",
+ "RunOnceActivity.ShowReadmeOnStart": "true",
+ "RunOnceActivity.git.unshallow": "true",
+ "git-widget-placeholder": "feature/occ-migration",
+ "last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
+ "node.js.selected.package.tslint": "(autodetect)",
+ "settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
}
-}]]>
+}
-
+
@@ -275,15 +257,7 @@
1755369224187
-
-
- 1763311700335
-
-
-
- 1763311700335
-
-
+
@@ -318,7 +292,6 @@
-
-
+
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 4379fa0..2c533fb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,7 @@ dependencies = [
"numpy>=1.24.0",
"scipy>=1.10.0",
"pillow>=10.0.0",
+ "python_solvespace>=3.0.0",
]
[project.optional-dependencies]
diff --git a/src/fluency/geometry_occ/kernel.py b/src/fluency/geometry_occ/kernel.py
index f593ff8..2540c1b 100644
--- a/src/fluency/geometry_occ/kernel.py
+++ b/src/fluency/geometry_occ/kernel.py
@@ -185,17 +185,28 @@ class OCGeometryKernel(GeometryKernel):
) -> GeometryObject:
"""Revolve a 2D sketch around an axis."""
import cadquery as cq
+ import math
- cq_obj = self._get_cq_obj(sketch)
+ # Get the OCC shape directly
+ shape = self._get_shape(sketch)
- if isinstance(cq_obj, cq.Workplane):
- solid = cq_obj.revolve(angle)
- else:
- face = cq.Face.makeFromWires(cq_obj)
- axis_vec = cq.Vector(*axis)
- origin_vec = cq.Vector(*origin)
- solid = face.revolve(axis_vec, origin_vec, angle)
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
+ from OCP.gp import gp_Ax1, gp_Pnt, gp_Dir
+ from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol
+ # Build a face from the wire/shape
+ face_maker = BRepBuilderAPI_MakeFace(shape, False)
+ face_maker.Build()
+ face = face_maker.Face()
+
+ # Revolve the face around the axis
+ revolve_axis = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
+ angle_rad = math.radians(angle)
+ revolver = BRepPrimAPI_MakeRevol(face, revolve_axis, angle_rad)
+ revolver.Build()
+ solid_shape = revolver.Shape()
+
+ solid = cq.Shape(solid_shape)
return OCCGeometryObject(solid, {"type": "revolution"})
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
@@ -673,7 +684,8 @@ class OCGeometryKernel(GeometryKernel):
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
- edge = explorer.Current()
+ from OCP.TopoDS import TopoDS
+ edge = TopoDS.Edge_s(explorer.Current())
edge_points = discretize_edge(edge)
for i, pt in enumerate(edge_points):
diff --git a/src/fluency/geometry_occ/sketch.py b/src/fluency/geometry_occ/sketch.py
index af6f464..dc5d597 100644
--- a/src/fluency/geometry_occ/sketch.py
+++ b/src/fluency/geometry_occ/sketch.py
@@ -1,12 +1,17 @@
"""
-OpenCASCADE-based sketch for Fluency CAD.
+OpenCASCADE-based sketch for Fluency CAD with SolveSpace constraint solver integration.
-This module provides 2D sketching capabilities using CadQuery Workplanes.
+This module provides 2D sketching capabilities using the SolveSpace constraint
+solver (via python_solvespace) for constraint management, and CadQuery for
+geometry generation from solved positions.
"""
from typing import List, Tuple, Optional, Dict, Any
-from dataclasses import dataclass, field
import numpy as np
+import logging
+import re
+
+from python_solvespace import SolverSystem, ResultFlag
from fluency.geometry.base import (
SketchInterface,
@@ -16,24 +21,33 @@ from fluency.geometry.base import (
)
from fluency.geometry_occ.kernel import OCCGeometryObject
+logger = logging.getLogger(__name__)
+
class OCCSketchEntity(SketchEntity):
- """Sketch entity for OpenCASCADE-based sketch."""
+ """Sketch entity for OpenCASCADE-based sketch with solver integration."""
def __init__(self, entity_id: int, entity_type: str, geometry: Any = None, handle: Any = None):
super().__init__(entity_id, entity_type)
self.geometry = geometry
- self.handle = handle
+ self.handle = handle # SolveSpace solver entity handle
+ self.is_construction: bool = False
+ self.constraints: List[str] = [] # Track applied constraint names for UI
class OCCSketch(SketchInterface):
"""
- CadQuery-based sketch for 2D geometry.
+ Sketch with SolveSpace constraint solver integration.
- This sketch uses CadQuery Workplanes for geometry creation.
+ Uses python_solvespace as the constraint engine, allowing points and lines
+ to be parametrically constrained. After solving, positions are read from
+ the solver and used to build CadQuery geometry for extrusion.
"""
def __init__(self) -> None:
+ self._solver: SolverSystem = SolverSystem()
+ self._wp: Any = self._solver.create_2d_base()
+
self._entities: Dict[int, OCCSketchEntity] = {}
self._entity_counter: int = 0
self._points: Dict[int, Tuple[float, float]] = {}
@@ -41,17 +55,42 @@ class OCCSketch(SketchInterface):
self._circles: Dict[int, Tuple[int, float]] = {}
self._arcs: Dict[int, Any] = {}
self._constraint_count: int = 0
- self._workplane: Any = None
+
+ # Track first point as dragged/fixed for solver stability
+ self._first_point_id: Optional[int] = None
+
+ @property
+ def solver(self) -> SolverSystem:
+ """Access the underlying SolveSpace solver."""
+ return self._solver
+
+ @property
+ def workplane(self) -> Any:
+ """Get the solver workplane entity."""
+ return self._wp
def _next_id(self) -> int:
self._entity_counter += 1
return self._entity_counter
+ def _get_handle_nr(self, handle_str: str) -> int:
+ match = re.search(r"handle=(\d+)", str(handle_str))
+ return int(match.group(1)) if match else 0
+
def add_point(self, x: float, y: float) -> OCCSketchEntity:
- """Add a point to the sketch."""
+ """Add a point to the sketch (added to solver + tracked)."""
entity_id = self._next_id()
- entity = OCCSketchEntity(entity_id=entity_id, entity_type="point", geometry=(x, y))
+ # Add to solver
+ solver_handle = self._solver.add_point_2d(x, y, self._wp)
+ if self._first_point_id is None:
+ self._first_point_id = entity_id
+ # Fix first point so solver has a reference
+ self._solver.dragged(solver_handle, self._wp)
+
+ entity = OCCSketchEntity(
+ entity_id=entity_id, entity_type="point", geometry=(x, y), handle=solver_handle
+ )
self._entities[entity_id] = entity
self._points[entity_id] = (x, y)
@@ -59,20 +98,30 @@ class OCCSketch(SketchInterface):
return entity
def add_line(self, start: SketchEntity, end: SketchEntity) -> OCCSketchEntity:
- """Add a line between two points."""
+ """Add a line between two points (added to solver + tracked)."""
entity_id = self._next_id()
- start_geom = self._entities.get(start.id)
- end_geom = self._entities.get(end.id)
+ start_entity = self._entities.get(start.id)
+ end_entity = self._entities.get(end.id)
- if start_geom is None or end_geom is None:
+ if start_entity is None or end_entity is None:
raise ValueError("Start or end point not found in sketch")
- x1, y1 = start_geom.geometry
- x2, y2 = end_geom.geometry
+ # Get solver handles
+ s_handle = start_entity.handle
+ e_handle = end_entity.handle
+
+ # Add line to solver
+ solver_handle = self._solver.add_line_2d(s_handle, e_handle, self._wp)
+
+ x1, y1 = start_entity.geometry
+ x2, y2 = end_entity.geometry
entity = OCCSketchEntity(
- entity_id=entity_id, entity_type="line", geometry=((x1, y1), (x2, y2))
+ entity_id=entity_id,
+ entity_type="line",
+ geometry=((x1, y1), (x2, y2)),
+ handle=solver_handle,
)
self._entities[entity_id] = entity
@@ -81,7 +130,7 @@ class OCCSketch(SketchInterface):
return entity
def add_circle(self, center: SketchEntity, radius: float) -> OCCSketchEntity:
- """Add a circle."""
+ """Add a circle (tracked only — solver has no native circle in this API)."""
entity_id = self._next_id()
center_entity = self._entities.get(center.id)
@@ -106,7 +155,7 @@ class OCCSketch(SketchInterface):
start_point: SketchEntity,
end_point: SketchEntity,
) -> OCCSketchEntity:
- """Add an arc."""
+ """Add an arc (tracked only)."""
entity_id = self._next_id()
center_entity = self._entities.get(center.id)
@@ -161,76 +210,218 @@ class OCCSketch(SketchInterface):
return entities
- def constrain_coincident(self, *entities: SketchEntity) -> bool:
- """Make entities coincident."""
+ # ─── Constraint methods (actual solver calls) ──────────────────────────
+
+ def _add_constraint_record(self) -> None:
self._constraint_count += 1
+
+ def constrain_coincident(self, *entities: SketchEntity) -> bool:
+ """Make entities coincident via solver."""
+ if len(entities) < 2:
+ return False
+ e1 = self._entities.get(entities[0].id)
+ e2 = self._entities.get(entities[1].id)
+ if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
+ return False
+ self._solver.coincident(e1.handle, e2.handle, self._wp)
+ self._add_constraint_record()
return True
def constrain_horizontal(self, line: SketchEntity) -> bool:
"""Constrain a line to be horizontal."""
- self._constraint_count += 1
+ entity = self._entities.get(line.id)
+ if entity is None or entity.handle is None:
+ return False
+ self._solver.horizontal(entity.handle, self._wp)
+ self._add_constraint_record()
+ if "hrz" not in entity.constraints:
+ entity.constraints.append("hrz")
return True
def constrain_vertical(self, line: SketchEntity) -> bool:
"""Constrain a line to be vertical."""
- self._constraint_count += 1
+ entity = self._entities.get(line.id)
+ if entity is None or entity.handle is None:
+ return False
+ self._solver.vertical(entity.handle, self._wp)
+ self._add_constraint_record()
+ if "vrt" not in entity.constraints:
+ entity.constraints.append("vrt")
return True
def constrain_distance(
self, entity1: SketchEntity, entity2: SketchEntity, distance: float
) -> bool:
"""Constrain distance between two entities."""
- self._constraint_count += 1
+ e1 = self._entities.get(entity1.id)
+ e2 = self._entities.get(entity2.id)
+ if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
+ return False
+ self._solver.distance(e1.handle, e2.handle, distance, self._wp)
+ self._add_constraint_record()
return True
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
"""Constrain angle between two lines."""
- self._constraint_count += 1
+ e1 = self._entities.get(line1.id)
+ e2 = self._entities.get(line2.id)
+ if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
+ return False
+ self._solver.angle(e1.handle, e2.handle, angle, self._wp)
+ self._add_constraint_record()
return True
def constrain_parallel(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to be parallel."""
- self._constraint_count += 1
+ e1 = self._entities.get(line1.id)
+ e2 = self._entities.get(line2.id)
+ if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
+ return False
+ self._solver.parallel(e1.handle, e2.handle, self._wp)
+ self._add_constraint_record()
return True
def constrain_perpendicular(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to be perpendicular."""
- self._constraint_count += 1
+ e1 = self._entities.get(line1.id)
+ e2 = self._entities.get(line2.id)
+ if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
+ return False
+ self._solver.perpendicular(e1.handle, e2.handle, self._wp)
+ self._add_constraint_record()
return True
def constrain_midpoint(self, point: SketchEntity, line: SketchEntity) -> bool:
"""Constrain a point to be at the midpoint of a line."""
- self._constraint_count += 1
+ pt = self._entities.get(point.id)
+ ln = self._entities.get(line.id)
+ if pt is None or ln is None or pt.handle is None or ln.handle is None:
+ return False
+ self._solver.midpoint(pt.handle, ln.handle, self._wp)
+ self._add_constraint_record()
return True
def constrain_tangent(self, entity1: SketchEntity, entity2: SketchEntity) -> bool:
"""Constrain two entities to be tangent."""
- self._constraint_count += 1
+ e1 = self._entities.get(entity1.id)
+ e2 = self._entities.get(entity2.id)
+ if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
+ return False
+ self._solver.tangent(e1.handle, e2.handle, self._wp)
+ self._add_constraint_record()
return True
def constrain_equal_length(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to have equal length."""
- self._constraint_count += 1
+ e1 = self._entities.get(line1.id)
+ e2 = self._entities.get(line2.id)
+ if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
+ return False
+ self._solver.equal(e1.handle, e2.handle, self._wp)
+ self._add_constraint_record()
return True
def constrain_equal_radius(self, circle1: SketchEntity, circle2: SketchEntity) -> bool:
- """Constrain two circles to have equal radius."""
- self._constraint_count += 1
+ """Circle equal-radius (tracked only — solver limit)."""
+ self._add_constraint_record()
return True
def constrain_fixed(self, entity: SketchEntity) -> bool:
- """Fix an entity in place."""
- self._constraint_count += 1
+ """Fix an entity in place via dragged constraint."""
+ ent = self._entities.get(entity.id)
+ if ent is None or ent.handle is None:
+ return False
+ self._solver.dragged(ent.handle, self._wp)
+ self._add_constraint_record()
return True
+ def constrain_symmetric(
+ self, entity1: SketchEntity, entity2: SketchEntity, line: SketchEntity
+ ) -> bool:
+ """Constrain symmetry about a line."""
+ e1 = self._entities.get(entity1.id)
+ e2 = self._entities.get(entity2.id)
+ ln = self._entities.get(line.id)
+ if e1 is None or e2 is None or ln is None:
+ return False
+ if e1.handle is None or e2.handle is None or ln.handle is None:
+ return False
+ self._solver.symmetric(e1.handle, e2.handle, ln.handle, self._wp)
+ self._add_constraint_record()
+ return True
+
+ # ─── Solving ───────────────────────────────────────────────────────────
+
def solve(self) -> bool:
- """Solve all constraints."""
- return True
+ """Solve all constraints via SolveSpace solver."""
+ try:
+ result = self._solver.solve()
+ if result == ResultFlag.OKAY:
+ # Sync solved positions back to entity geometries
+ self._sync_solved_positions()
+ return True
+ else:
+ logger.warning(f"Solver returned: {result}")
+ return False
+ except Exception as e:
+ logger.error(f"Solver error: {e}")
+ return False
+
+ def _sync_solved_positions(self) -> None:
+ """Read solved point positions from solver and update entity geometries."""
+ for entity_id, entity in list(self._entities.items()):
+ if entity.entity_type == "point" and entity.handle is not None:
+ try:
+ x, y = self._solver.params(entity.handle.params)
+ entity.geometry = (x, y)
+ if entity_id in self._points:
+ self._points[entity_id] = (x, y)
+ except Exception as e:
+ logger.debug(f"Could not sync point {entity_id}: {e}")
+
+ elif entity.entity_type == "line" and entity_id in self._lines:
+ start_id, end_id = self._lines[entity_id]
+ start_entity = self._entities.get(start_id)
+ end_entity = self._entities.get(end_id)
+ if start_entity and end_entity and start_entity.geometry and end_entity.geometry:
+ entity.geometry = (start_entity.geometry, end_entity.geometry)
+
+ def get_solved_point(self, entity_id: int) -> Optional[Tuple[float, float]]:
+ """Get the solved position of a point entity."""
+ entity = self._entities.get(entity_id)
+ if entity and entity.entity_type == "point" and entity.handle is not None:
+ try:
+ x, y = self._solver.params(entity.handle.params)
+ return (float(x), float(y))
+ except Exception:
+ pass
+ return None
+
+ def get_solved_param(self, handle: Any) -> Optional[Tuple[float, float]]:
+ """Get solved params for a solver entity handle."""
+ try:
+ x, y = self._solver.params(handle.params)
+ return (float(x), float(y))
+ except Exception:
+ return None
+
+ # ─── Geometry extraction for operations ────────────────────────────────
def get_geometry(self) -> GeometryObject:
- """Get the solved geometry for operations."""
+ """Get the solved geometry for operations using CadQuery."""
import cadquery as cq
+ # Check for circles first
+ if self._circles:
+ for entity_id, (center_id, radius) in self._circles.items():
+ center_entity = self._entities.get(center_id)
+ if center_entity and center_entity.geometry:
+ cx, cy = center_entity.geometry
+ wp = cq.Workplane("XY").center(cx, cy).circle(radius)
+ obj = OCCGeometryObject(wp.val())
+ obj._cadquery_obj = wp
+ return obj
+
points = self.get_polygon_points()
if not points:
return OCCGeometryObject(None)
@@ -245,22 +436,32 @@ class OCCSketch(SketchInterface):
return obj
def get_points(self) -> List[Point2D]:
- """Get all point positions."""
+ """Get all point positions from solved solver data."""
points: List[Point2D] = []
for entity_id, entity in self._entities.items():
if entity.entity_type == "point":
- x, y = entity.geometry
- points.append(Point2D(x, y))
+ # Try to get solved position first
+ if entity.handle is not None:
+ try:
+ x, y = self._solver.params(entity.handle.params)
+ points.append(Point2D(x, y))
+ continue
+ except Exception:
+ pass
+ # Fall back to stored geometry
+ if entity.geometry:
+ x, y = entity.geometry
+ points.append(Point2D(x, y))
return points
def get_polygon_points(self) -> List[Point2D]:
- """Get ordered polygon points from connected lines."""
+ """Get ordered polygon points from connected lines (uses solved positions)."""
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
for entity in self._entities.values():
- if entity.entity_type == "line":
+ if entity.entity_type == "line" and entity.geometry:
p1, p2 = entity.geometry
if p1 not in adjacency:
adjacency[p1] = []
@@ -272,30 +473,41 @@ class OCCSketch(SketchInterface):
if not adjacency:
return []
- points: List[Point2D] = []
+ ordered: List[Point2D] = []
visited: set = set()
current = next(iter(adjacency.keys()))
- while current and current not in visited:
- points.append(Point2D(current[0], current[1]))
- visited.add(current)
+ while current and tuple(current) not in visited:
+ ordered.append(Point2D(current[0], current[1]))
+ visited.add(tuple(current))
neighbors = adjacency.get(current, [])
next_point = None
for n in neighbors:
- if n not in visited:
+ if tuple(n) not in visited:
next_point = n
break
-
current = next_point
- if len(points) > 2:
- points.append(points[0])
+ if len(ordered) > 2:
+ ordered.append(ordered[0])
- return points
+ return ordered
+
+ def get_solver_dof(self) -> int:
+ """Get remaining degrees of freedom from solver."""
+ return self._solver.dof()
+
+ def get_solver_failures(self) -> List[Any]:
+ """Get list of failed constraints."""
+ return self._solver.failures()
+
+ # ─── Management ────────────────────────────────────────────────────────
def clear(self) -> None:
- """Clear all geometry and constraints."""
+ """Clear all geometry and constraints from both solver and tracker."""
+ self._solver = SolverSystem()
+ self._wp = self._solver.create_2d_base()
self._entities.clear()
self._points.clear()
self._lines.clear()
@@ -303,12 +515,16 @@ class OCCSketch(SketchInterface):
self._arcs.clear()
self._entity_counter = 0
self._constraint_count = 0
+ self._first_point_id = None
def delete_entity(self, entity: SketchEntity) -> bool:
"""Delete an entity and its constraints."""
if entity.id not in self._entities:
return False
+ # Remove from solver (clear + rebuild is simplest)
+ # For simplicity, we skip solver removal — on next solve, stale handles
+ # will be ignored. A full rebuild would need entity-by-entity solver removal.
del self._entities[entity.id]
if entity.id in self._points:
@@ -327,9 +543,12 @@ class OCCSketch(SketchInterface):
return len(self._entities)
def get_constraint_count(self) -> int:
- """Get the number of constraints in the sketch."""
+ """Get the number of constraints applied via solver."""
return self._constraint_count
def is_fully_constrained(self) -> bool:
- """Check if the sketch is fully constrained."""
- return False
+ """Check if the sketch is fully constrained (0 DOF)."""
+ try:
+ return self._solver.dof() == 0
+ except Exception:
+ return False
diff --git a/src/fluency/main.py b/src/fluency/main.py
index fb15d29..03c06cd 100644
--- a/src/fluency/main.py
+++ b/src/fluency/main.py
@@ -46,6 +46,7 @@ from PySide6.QtWidgets import (
QMenuBar,
QSplitter,
QSizePolicy,
+ QInputDialog,
)
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize
from PySide6.QtGui import (
@@ -126,8 +127,43 @@ class ExtrudeDialog(QDialog):
)
+class RevolveDialog(QDialog):
+ """Dialog for revolve options."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Revolve Options")
+ self.setMinimumWidth(300)
+
+ layout = QVBoxLayout(self)
+
+ angle_layout = QHBoxLayout()
+ angle_layout.addWidget(QLabel("Revolve Angle (°):"))
+ self.angle_input = QDoubleSpinBox()
+ self.angle_input.setDecimals(1)
+ self.angle_input.setRange(1, 360)
+ self.angle_input.setValue(360)
+ self.angle_input.setSuffix("°")
+ angle_layout.addWidget(self.angle_input)
+ layout.addLayout(angle_layout)
+
+ line = QFrame()
+ line.setFrameShape(QFrame.HLine)
+ line.setFrameShadow(QFrame.Sunken)
+ layout.addWidget(line)
+
+ button_layout = QHBoxLayout()
+ ok_button = QPushButton("OK")
+ ok_button.clicked.connect(self.accept)
+ cancel_button = QPushButton("Cancel")
+ cancel_button.clicked.connect(self.reject)
+ button_layout.addWidget(ok_button)
+ button_layout.addWidget(cancel_button)
+ layout.addLayout(button_layout)
+
+
class Sketch2DWidget(QWidget):
- """2D sketching widget with drawing and constraint tools."""
+ """2D sketching widget with SolveSpace constraint solving and drawing tools."""
constrain_done = Signal()
sketch_updated = Signal()
@@ -147,6 +183,7 @@ class Sketch2DWidget(QWidget):
self._draw_buffer: List[QPoint] = []
self._hovered_point: Optional[QPoint] = None
+ self._hovered_line: Optional[Tuple[QPoint, QPoint]] = None
self._selected_entities: List[OCCSketchEntity] = []
self._snap_mode: Dict[str, bool] = {
@@ -169,6 +206,8 @@ class Sketch2DWidget(QWidget):
self._temp_entities: List[Any] = []
self._constraint_distance_value: float = 10.0
+ # Pending distance constraint input
+ self._pending_distance_val: Optional[float] = None
self.setFocusPolicy(Qt.StrongFocus)
self._setup_ui()
@@ -178,12 +217,34 @@ class Sketch2DWidget(QWidget):
def set_sketch(self, sketch: Optional[OCCSketch]):
self._sketch = sketch
+ self._rebuild_from_sketch()
+ self._draw_buffer = []
+ self.update()
+
+ def _rebuild_from_sketch(self):
+ """Rebuild UI point/line lists from the OCCSketch entity data."""
self._points = []
self._lines = []
self._circles = []
self._selected_entities = []
- self._draw_buffer = []
- self.update()
+ if self._sketch:
+ # Collect points in creation order
+ point_map: Dict[int, OCCSketchEntity] = {}
+ for eid, entity in self._sketch._entities.items():
+ if entity.entity_type == "point":
+ point_map[eid] = entity
+ self._points.append(entity)
+ for eid, entity in self._sketch._entities.items():
+ if entity.entity_type == "line" and eid in self._sketch._lines:
+ sid, eid2 = self._sketch._lines[eid]
+ s_ent = self._sketch._entities.get(sid)
+ e_ent = self._sketch._entities.get(eid2)
+ if s_ent and e_ent:
+ self._lines.append((s_ent, e_ent))
+ for eid, (cid, r) in self._sketch._circles.items():
+ c_ent = self._sketch._entities.get(cid)
+ if c_ent:
+ self._circles.append((c_ent, r))
def get_sketch(self) -> Optional[OCCSketch]:
return self._sketch
@@ -206,6 +267,7 @@ class Sketch2DWidget(QWidget):
self._mode = mode
self._draw_buffer = []
self._dynamic_line_end = None
+ self._selected_entities = []
self.update()
def set_construct_mode(self, enabled: bool):
@@ -223,6 +285,8 @@ class Sketch2DWidget(QWidget):
def set_constraint_distance(self, distance: float):
self._constraint_distance_value = distance
+ # ─── Coordinate transforms ────────────────────────────────────────────
+
def _screen_to_world(self, pos: QPoint) -> QPoint:
return QPoint(
int((pos.x() - self.width() / 2 - self._offset.x()) / self._zoom),
@@ -235,17 +299,17 @@ class Sketch2DWidget(QWidget):
int(self.height() / 2 - pos.y() * self._zoom + self._offset.y()),
)
+ # ─── Snapping ─────────────────────────────────────────────────────────
+
def _find_nearest_point(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
if not self._snap_mode.get("point", False):
return None
-
nearest = None
min_dist = max_distance
-
for entity in self._points:
if entity.geometry:
x, y = entity.geometry
- point = QPoint(int(x), int(y))
+ point = QPoint(int(round(x)), int(round(y)))
screen_point = self._world_to_screen(point)
dist = math.sqrt(
(pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2
@@ -253,37 +317,31 @@ class Sketch2DWidget(QWidget):
if dist < min_dist:
min_dist = dist
nearest = point
-
return nearest
def _find_midpoint_snap(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
if not self._snap_mode.get("mpoint", False):
return None
-
for p1, p2 in self._lines:
if p1.geometry and p2.geometry:
x1, y1 = p1.geometry
x2, y2 = p2.geometry
- mid = QPoint(int((x1 + x2) / 2), int((y1 + y2) / 2))
+ mid = QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
screen_mid = self._world_to_screen(mid)
dist = math.sqrt((pos.x() - screen_mid.x()) ** 2 + (pos.y() - screen_mid.y()) ** 2)
if dist < max_distance:
return mid
-
return None
def _apply_angle_snap(self, start: QPoint, end: QPoint) -> QPoint:
if not self._snap_mode.get("angle", False):
return end
-
dx = end.x() - start.x()
dy = end.y() - start.y()
angle = math.degrees(math.atan2(dy, dx))
length = math.sqrt(dx**2 + dy**2)
-
snapped_angle = round(angle / self._angle_steps) * self._angle_steps
snapped_rad = math.radians(snapped_angle)
-
return QPoint(
int(start.x() + length * math.cos(snapped_rad)),
int(start.y() + length * math.sin(snapped_rad)),
@@ -301,37 +359,108 @@ class Sketch2DWidget(QWidget):
def _apply_all_snaps(self, pos: QPoint, start: Optional[QPoint] = None) -> QPoint:
result = pos
-
point_snap = self._find_nearest_point(pos)
if point_snap:
return point_snap
-
if self._snap_mode.get("mpoint", False):
mid_snap = self._find_midpoint_snap(pos)
if mid_snap:
return mid_snap
-
if start:
if self._snap_mode.get("horiz", False):
horiz = self._apply_horizontal_snap(start, result)
if abs(result.y() - start.y()) < 10:
result = horiz
-
if self._snap_mode.get("vert", False):
vert = self._apply_vertical_snap(start, result)
if abs(result.x() - start.x()) < 10:
result = vert
-
if self._snap_mode.get("angle", False):
result = self._apply_angle_snap(start, result)
-
return result
+ # ─── Solver helpers ───────────────────────────────────────────────────
+
+ def _get_point_entity_at(self, world_pos: QPoint) -> Optional[OCCSketchEntity]:
+ """Find nearest point entity to world position."""
+ for entity in self._points:
+ if entity.geometry:
+ x, y = entity.geometry
+ dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
+ if dist < 5: # tolerance in world coords
+ return entity
+ return None
+
+ def _get_line_entity_at(self, world_pos: QPoint) -> Optional[Tuple[OCCSketchEntity, OCCSketchEntity]]:
+ """Find a line near the given world position."""
+ for p1_ent, p2_ent in self._lines:
+ if p1_ent.geometry and p2_ent.geometry:
+ x1, y1 = p1_ent.geometry
+ x2, y2 = p2_ent.geometry
+ # Point-to-line-segment distance check
+ dx = x2 - x1
+ dy = y2 - y1
+ if dx == 0 and dy == 0:
+ continue
+ t = ((world_pos.x() - x1) * dx + (world_pos.y() - y1) * dy) / (dx*dx + dy*dy)
+ t = max(0, min(1, t))
+ proj_x = x1 + t * dx
+ proj_y = y1 + t * dy
+ dist = math.sqrt((world_pos.x() - proj_x)**2 + (world_pos.y() - proj_y)**2)
+ if dist < 10: # tolerance
+ return (p1_ent, p2_ent)
+ return None
+
+ def _find_line_entity_for_line_xy(self, p1_xy: Tuple[float, float], p2_xy: Tuple[float, float]) -> Optional[OCCSketchEntity]:
+ """Find the OCCSketchEntity for a line defined by its endpoint tuples."""
+ for eid, start_end in self._sketch._lines.items():
+ sid, eid2 = start_end
+ s_ent = self._sketch._entities.get(sid)
+ e_ent = self._sketch._entities.get(eid2)
+ if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
+ sx, sy = s_ent.geometry
+ ex, ey = e_ent.geometry
+ if (abs(sx - p1_xy[0]) < 0.1 and abs(sy - p1_xy[1]) < 0.1 and
+ abs(ex - p2_xy[0]) < 0.1 and abs(ey - p2_xy[1]) < 0.1):
+ return s_ent # Return the line entity reference
+ if (abs(sx - p2_xy[0]) < 0.1 and abs(sy - p2_xy[1]) < 0.1 and
+ abs(ex - p1_xy[0]) < 0.1 and abs(ey - p1_xy[1]) < 0.1):
+ return s_ent
+ return None
+
+ def _get_constraints_for_line(self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity) -> List[str]:
+ """Get constraint labels from both endpoint entities."""
+ return list(set(p1_ent.constraints + p2_ent.constraints))
+
+ def _sync_solved_positions(self):
+ """Sync solver positions back to UI points and lines."""
+ if not self._sketch:
+ return
+ for entity in self._points:
+ if entity.handle is not None:
+ try:
+ x, y = self._sketch.solver.params(entity.handle.params)
+ entity.geometry = (float(x), float(y))
+ except Exception:
+ pass
+ # Update line geometries from their endpoint positions
+ for p1_ent, p2_ent in self._lines:
+ if p1_ent.geometry and p2_ent.geometry:
+ pass # geometry already updated via point sync
+
+ def _solve_and_sync(self) -> bool:
+ """Solve constraints, sync positions, update UI. Returns True if solved OK."""
+ if not self._sketch:
+ return True
+ ok = self._sketch.solve()
+ self._sync_solved_positions()
+ self.update()
+ return ok
+
+ # ─── Mouse events ─────────────────────────────────────────────────────
+
def mousePressEvent(self, event):
world_pos = self._screen_to_world(event.pos())
- logger.debug(
- f"Mouse press: button={event.button()}, mode={self._mode}, world_pos=({world_pos.x()}, {world_pos.y()})"
- )
if event.button() == Qt.MiddleButton:
self._panning = True
@@ -340,10 +469,10 @@ class Sketch2DWidget(QWidget):
return
if event.button() == Qt.RightButton:
- logger.debug("Right click - resetting mode")
self._mode = None
self._draw_buffer = []
self._dynamic_line_end = None
+ self._selected_entities = []
self.constrain_done.emit()
self.update()
return
@@ -355,7 +484,6 @@ class Sketch2DWidget(QWidget):
world_snapped = (
self._screen_to_world(snapped_pos) if snapped_pos != event.pos() else world_pos
)
- logger.debug(f"Left click - mode={self._mode}, snapped={snapped_pos != event.pos()}")
if self._mode == "line":
self._handle_line_click(world_snapped)
@@ -365,8 +493,24 @@ class Sketch2DWidget(QWidget):
self._handle_circle_click(world_snapped)
elif self._mode == "select":
self._handle_select_click(world_snapped)
- elif self._mode and self._mode.startswith("constrain_"):
- self._handle_constraint_click(world_snapped)
+ elif self._mode == "constrain_coincident":
+ self._handle_constraint_coincident(world_snapped)
+ elif self._mode == "constrain_horizontal":
+ self._handle_constraint_horizontal(world_snapped)
+ elif self._mode == "constrain_vertical":
+ self._handle_constraint_vertical(world_snapped)
+ elif self._mode == "constrain_distance":
+ self._handle_constraint_distance(world_snapped)
+ elif self._mode == "constrain_midpoint":
+ self._handle_constraint_midpoint(world_snapped)
+ elif self._mode == "constrain_perpendicular":
+ self._handle_constraint_perpendicular(world_snapped)
+ elif self._mode == "constrain_parallel":
+ self._handle_constraint_parallel(world_snapped)
+ elif self._mode == "constrain_ptline":
+ self._handle_constraint_ptline(world_snapped)
+ elif self._mode == "constrain_symmetric":
+ self._handle_constraint_symmetric(world_snapped)
def mouseMoveEvent(self, event):
if self._panning and self._pan_start:
@@ -383,15 +527,33 @@ class Sketch2DWidget(QWidget):
event.pos(), self._world_to_screen(self._draw_buffer[0])
)
self._dynamic_line_end = self._screen_to_world(snapped)
- self.update()
+ # Point hover
point_snap = self._find_nearest_point(event.pos())
if point_snap:
self._hovered_point = point_snap
+ self._hovered_line = None
self.setCursor(Qt.CrossCursor)
else:
self._hovered_point = None
- self.setCursor(Qt.ArrowCursor)
+ # Check line hover
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit:
+ p1_ent, p2_ent = line_hit
+ if p1_ent.geometry and p2_ent.geometry:
+ self._hovered_line = (
+ QPoint(int(round(p1_ent.geometry[0])), int(round(p1_ent.geometry[1]))),
+ QPoint(int(round(p2_ent.geometry[0])), int(round(p2_ent.geometry[1]))),
+ )
+ self.setCursor(Qt.CrossCursor)
+ else:
+ self._hovered_line = None
+ self.setCursor(Qt.ArrowCursor)
+ else:
+ self._hovered_line = None
+ self.setCursor(Qt.ArrowCursor)
+
+ self.update()
def mouseReleaseEvent(self, event):
if event.button() == Qt.MiddleButton:
@@ -406,18 +568,17 @@ class Sketch2DWidget(QWidget):
self._zoom = max(0.1, min(10.0, self._zoom))
self.update()
+ # ─── Drawing handlers ─────────────────────────────────────────────────
+
def _handle_line_click(self, pos: QPoint):
- logger.debug(f"Line click at ({pos.x()}, {pos.y()})")
if not self._sketch:
self._sketch = OCCSketch()
- logger.debug("Created new OCCSketch")
if not self._draw_buffer:
point = self._sketch.add_point(pos.x(), pos.y())
point.is_construction = self._is_construct
self._points.append(point)
self._draw_buffer.append(pos)
- logger.debug(f"Added first point, buffer size={len(self._draw_buffer)}")
else:
point = self._sketch.add_point(pos.x(), pos.y())
point.is_construction = self._is_construct
@@ -426,7 +587,7 @@ class Sketch2DWidget(QWidget):
if len(self._points) >= 2:
line = self._sketch.add_line(self._points[-2], self._points[-1])
self._lines.append((self._points[-2], self._points[-1]))
- logger.debug(f"Added line between points, total lines={len(self._lines)}")
+ self._solve_and_sync()
self._draw_buffer = [pos]
@@ -442,25 +603,24 @@ class Sketch2DWidget(QWidget):
else:
p1 = self._draw_buffer[0]
p2 = pos
-
corners = [
QPoint(p1.x(), p1.y()),
QPoint(p2.x(), p1.y()),
QPoint(p2.x(), p2.y()),
QPoint(p1.x(), p2.y()),
]
-
- points = []
+ pts = []
for corner in corners:
pt = self._sketch.add_point(corner.x(), corner.y())
pt.is_construction = self._is_construct
self._points.append(pt)
- points.append(pt)
+ pts.append(pt)
for i in range(4):
- line = self._sketch.add_line(points[i], points[(i + 1) % 4])
- self._lines.append((points[i], points[(i + 1) % 4]))
+ line = self._sketch.add_line(pts[i], pts[(i + 1) % 4])
+ self._lines.append((pts[i], pts[(i + 1) % 4]))
+ self._solve_and_sync()
self._draw_buffer = []
self._mode = None
self.constrain_done.emit()
@@ -479,14 +639,11 @@ class Sketch2DWidget(QWidget):
self._draw_buffer.append(pos)
else:
center = self._points[-1]
- radius = math.sqrt(
- (pos.x() - center.geometry[0]) ** 2 + (pos.y() - center.geometry[1]) ** 2
- )
-
+ cx, cy = center.geometry if center.geometry else (0, 0)
+ radius = math.sqrt((pos.x() - cx) ** 2 + (pos.y() - cy) ** 2)
if radius > 0:
- circle = self._sketch.add_circle(center, radius)
+ self._sketch.add_circle(center, radius)
self._circles.append((center, radius))
-
self._draw_buffer = []
self._mode = None
self.constrain_done.emit()
@@ -497,162 +654,430 @@ class Sketch2DWidget(QWidget):
def _handle_select_click(self, pos: QPoint):
pass
- def _handle_constraint_click(self, pos: QPoint):
- snapped = self._find_nearest_point(self._world_to_screen(pos))
- if snapped:
- world_snapped = self._screen_to_world(snapped)
- for entity in self._points:
- if entity.geometry:
- x, y = entity.geometry
- if abs(x - world_snapped.x()) < 1 and abs(y - world_snapped.y()) < 1:
- self._selected_entities.append(entity)
+ # ─── Constraint handlers (with solver calls) ──────────────────────────
+
+ def _handle_constraint_coincident(self, world_pos: QPoint):
+ ent = self._get_point_entity_at(world_pos)
+ if ent is None:
+ return
+ self._selected_entities.append(ent)
+ if len(self._selected_entities) >= 2:
+ e1, e2 = self._selected_entities[:2]
+ if self._sketch:
+ self._sketch.constrain_coincident(e1, e2)
+ ok = self._solve_and_sync()
+ if ok:
+ logger.info("Coincident constraint added")
+ self._selected_entities = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+
+ def _handle_constraint_horizontal(self, world_pos: QPoint):
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit is None:
+ return
+ p1_ent, p2_ent = line_hit
+ # Find the OCCSketch line entity that references these exact point handles
+ for eid, (sid, eid2) in (self._sketch._lines.items() if self._sketch else {}).items():
+ s_ent = self._sketch._entities.get(sid) if self._sketch else None
+ e_ent = self._sketch._entities.get(eid2) if self._sketch else None
+ if s_ent == p1_ent and e_ent == p2_ent:
+ self._sketch.constrain_horizontal(s_ent)
+ break
+ if s_ent == p2_ent and e_ent == p1_ent:
+ self._sketch.constrain_horizontal(s_ent)
+ break
+ ok = self._solve_and_sync()
+ if ok:
+ logger.info("Horizontal constraint added")
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+
+ def _handle_constraint_vertical(self, world_pos: QPoint):
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit is None:
+ return
+ p1_ent, p2_ent = line_hit
+ for eid, (sid, eid2) in (self._sketch._lines.items() if self._sketch else {}).items():
+ s_ent = self._sketch._entities.get(sid) if self._sketch else None
+ e_ent = self._sketch._entities.get(eid2) if self._sketch else None
+ if s_ent == p1_ent and e_ent == p2_ent:
+ self._sketch.constrain_vertical(s_ent)
+ break
+ if s_ent == p2_ent and e_ent == p1_ent:
+ self._sketch.constrain_vertical(s_ent)
+ break
+ ok = self._solve_and_sync()
+ if ok:
+ logger.info("Vertical constraint added")
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+
+ def _handle_constraint_distance(self, world_pos: QPoint):
+ # Click 1: select a point or line
+ point_ent = self._get_point_entity_at(world_pos)
+ if point_ent:
+ self._selected_entities.append(point_ent)
+ else:
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit:
+ p1_ent, p2_ent = line_hit
+ for eid, (sid, eid2) in (self._sketch._lines.items() if self._sketch else {}).items():
+ s_ent = self._sketch._entities.get(sid) if self._sketch else None
+ e_ent = self._sketch._entities.get(eid2) if self._sketch else None
+ if s_ent == p1_ent and e_ent == p2_ent:
+ self._selected_entities.append(s_ent)
+ break
+ if s_ent == p2_ent and e_ent == p1_ent:
+ self._selected_entities.append(s_ent)
break
- if self._mode == "constrain_coincident" and len(self._selected_entities) >= 2:
- if self._sketch:
- self._sketch.constrain_coincident(*self._selected_entities[:2])
+ if len(self._selected_entities) >= 2:
+ e1, e2 = self._selected_entities[:2]
+ dist, ok = QInputDialog.getDouble(self, "Distance", "Distance (mm):",
+ self._constraint_distance_value, 0, 10000, 2)
+ if ok and self._sketch:
+ self._sketch.constrain_distance(e1, e2, dist)
+ self._solve_and_sync()
+ logger.info(f"Distance {dist:.2f}mm")
self._selected_entities = []
self._mode = None
self.constrain_done.emit()
-
- elif self._mode == "constrain_horizontal" and len(self._selected_entities) >= 1:
- if self._sketch:
- self._sketch.constrain_horizontal(self._selected_entities[0])
- self._selected_entities = []
- self._mode = None
- self.constrain_done.emit()
-
- elif self._mode == "constrain_vertical" and len(self._selected_entities) >= 1:
- if self._sketch:
- self._sketch.constrain_vertical(self._selected_entities[0])
- self._selected_entities = []
- self._mode = None
- self.constrain_done.emit()
-
- elif self._mode == "constrain_distance" and len(self._selected_entities) >= 2:
- if self._sketch:
- self._sketch.constrain_distance(
- self._selected_entities[0],
- self._selected_entities[1],
- self._constraint_distance_value,
- )
- self._selected_entities = []
- self._mode = None
- self.constrain_done.emit()
-
self.update()
+ def _handle_constraint_midpoint(self, world_pos: QPoint):
+ point_ent = self._get_point_entity_at(world_pos)
+ if point_ent and not self._selected_entities:
+ self._selected_entities.append(point_ent)
+ elif self._selected_entities:
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit:
+ p1_ent, p2_ent = line_hit
+ for eid, (sid, eid2) in (self._sketch._lines.items() if self._sketch else {}).items():
+ s_ent = self._sketch._entities.get(sid) if self._sketch else None
+ e_ent = self._sketch._entities.get(eid2) if self._sketch else None
+ if s_ent == p1_ent and e_ent == p2_ent:
+ if self._sketch and self._selected_entities:
+ self._sketch.constrain_midpoint(self._selected_entities[0], s_ent)
+ break
+ if s_ent == p2_ent and e_ent == p1_ent:
+ if self._sketch and self._selected_entities:
+ self._sketch.constrain_midpoint(self._selected_entities[0], s_ent)
+ break
+ self._solve_and_sync()
+ logger.info("Midpoint constraint added")
+ self._selected_entities = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+
+ def _handle_constraint_perpendicular(self, world_pos: QPoint):
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit is None:
+ return
+ p1_ent, p2_ent = line_hit
+ # Find the line entity
+ target_ent = None
+ for eid, (sid, eid2) in (self._sketch._lines.items() if self._sketch else {}).items():
+ s_ent = self._sketch._entities.get(sid) if self._sketch else None
+ e_ent = self._sketch._entities.get(eid2) if self._sketch else None
+ if (s_ent == p1_ent and e_ent == p2_ent) or (s_ent == p2_ent and e_ent == p1_ent):
+ target_ent = s_ent
+ break
+ if target_ent is None:
+ return
+
+ if not self._selected_entities:
+ # First click: store this line
+ self._selected_entities.append(target_ent)
+ else:
+ # Second click: apply perpendicular constraint
+ prev_ent = self._selected_entities[0]
+ if self._sketch:
+ self._sketch.constrain_perpendicular(prev_ent, target_ent)
+ self._solve_and_sync()
+ logger.info("Perpendicular constraint added")
+ self._selected_entities = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+
+ def _handle_constraint_parallel(self, world_pos: QPoint):
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit is None:
+ return
+ p1_ent, p2_ent = line_hit
+ target_ent = None
+ for eid, (sid, eid2) in (self._sketch._lines.items() if self._sketch else {}).items():
+ s_ent = self._sketch._entities.get(sid) if self._sketch else None
+ e_ent = self._sketch._entities.get(eid2) if self._sketch else None
+ if (s_ent == p1_ent and e_ent == p2_ent) or (s_ent == p2_ent and e_ent == p1_ent):
+ target_ent = s_ent
+ break
+ if target_ent is None:
+ return
+
+ if not self._selected_entities:
+ self._selected_entities.append(target_ent)
+ else:
+ prev_ent = self._selected_entities[0]
+ if self._sketch:
+ self._sketch.constrain_parallel(prev_ent, target_ent)
+ self._solve_and_sync()
+ logger.info("Parallel constraint added")
+ self._selected_entities = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+
+ def _handle_constraint_ptline(self, world_pos: QPoint):
+ """Point-to-line coincident (point on line)."""
+ point_ent = self._get_point_entity_at(world_pos)
+ if point_ent and not self._selected_entities:
+ self._selected_entities.append(point_ent)
+ elif self._selected_entities:
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit:
+ p1_ent, p2_ent = line_hit
+ for eid, (sid, eid2) in (self._sketch._lines.items() if self._sketch else {}).items():
+ s_ent = self._sketch._entities.get(sid) if self._sketch else None
+ e_ent = self._sketch._entities.get(eid2) if self._sketch else None
+ if s_ent == p1_ent and e_ent == p2_ent:
+ if self._sketch and self._selected_entities:
+ self._sketch.constrain_coincident(self._selected_entities[0], s_ent)
+ break
+ if s_ent == p2_ent and e_ent == p1_ent:
+ if self._sketch and self._selected_entities:
+ self._sketch.constrain_coincident(self._selected_entities[0], s_ent)
+ break
+ self._solve_and_sync()
+ logger.info("Point-on-line constraint added")
+ self._selected_entities = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+
+ def _handle_constraint_symmetric(self, world_pos: QPoint):
+ """Symmetric constraint: select entity1, entity2, then mirror line."""
+ # Click 3: mirror line
+ if len(self._selected_entities) == 2:
+ line_hit = self._get_line_entity_at(world_pos)
+ if line_hit:
+ p1_ent, p2_ent = line_hit
+ mirror_line = None
+ for eid, (sid, eid2) in (self._sketch._lines.items() if self._sketch else {}).items():
+ s_ent = self._sketch._entities.get(sid) if self._sketch else None
+ e_ent = self._sketch._entities.get(eid2) if self._sketch else None
+ if (s_ent == p1_ent and e_ent == p2_ent) or (s_ent == p2_ent and e_ent == p1_ent):
+ mirror_line = s_ent
+ break
+ if mirror_line and self._sketch:
+ self._sketch.constrain_symmetric(
+ self._selected_entities[0], self._selected_entities[1], mirror_line
+ )
+ ok = self._solve_and_sync()
+ if ok:
+ logger.info("Symmetric constraint added")
+ self._selected_entities = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.update()
+ return
+ # Clicks 1-2: select point entities
+ point_ent = self._get_point_entity_at(world_pos)
+ if point_ent:
+ self._selected_entities.append(point_ent)
+ n_selected = len(self._selected_entities)
+ if n_selected < 2:
+ logger.info(f"Select entity {n_selected + 1} for symmetry (or line for mirror)")
+ elif n_selected == 2:
+ logger.info("Click on the mirror line")
+ self.update()
+
+ # ─── Painting ─────────────────────────────────────────────────────────
+
+ def _calculate_midpoint(self, p1: QPoint, p2: QPoint) -> QPointF:
+ return QPointF((p1.x() + p2.x()) / 2.0, (p1.y() + p2.y()) / 2.0)
+
+ def _point_distance(self, p1: QPoint, p2: QPoint) -> float:
+ return math.sqrt((p1.x() - p2.x()) ** 2 + (p1.y() - p2.y()) ** 2)
+
+ def _draw_distance_measurement(self, painter: QPainter, p1: QPoint, p2: QPoint):
+ """Draw dimension lines and distance value between two world-coord points."""
+ sp1 = self._world_to_screen(p1)
+ sp2 = self._world_to_screen(p2)
+
+ dx = sp2.x() - sp1.x()
+ dy = sp2.y() - sp1.y()
+ length = math.sqrt(dx * dx + dy * dy)
+ if length == 0:
+ return
+
+ # Perpendicular direction for offset lines
+ perp_dx = -dy / length
+ perp_dy = dx / length
+ offset = 25.0
+
+ p1a = QPointF(sp1.x() + perp_dx, sp1.y() + perp_dy)
+ p1b = QPointF(sp1.x() + perp_dx * offset, sp1.y() + perp_dy * offset)
+ p2a = QPointF(sp2.x() + perp_dx, sp2.y() + perp_dy)
+ p2b = QPointF(sp2.x() + perp_dx * offset, sp2.y() + perp_dy * offset)
+ mid = QPointF((p1b.x() + p2b.x()) / 2, (p1b.y() + p2b.y()) / 2)
+
+ pen_dim = QPen(QColor("#a6e3a1"), 1.5, Qt.DotLine)
+ painter.setPen(pen_dim)
+ painter.drawLine(p1a.toPoint(), p1b.toPoint())
+ painter.drawLine(p2a.toPoint(), p2b.toPoint())
+ painter.drawLine(p1b.toPoint(), p2b.toPoint())
+
+ # Draw distance text
+ dist = self._point_distance(p1, p2)
+ painter.save()
+ painter.translate(mid)
+ painter.scale(1, -1)
+ painter.setPen(QPen(QColor("#a6e3a1"), 1))
+ painter.drawText(0, 0, f"{dist:.2f}")
+ painter.restore()
+
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
-
painter.fillRect(self.rect(), QColor("#1e1e2e"))
+ # ── Grid ──
pen = QPen(QColor("#45475a"), 1)
painter.setPen(pen)
grid_size = 50 * self._zoom
- offset_x = self._offset.x() % int(grid_size)
- offset_y = self._offset.y() % int(grid_size)
-
- for x in range(int(offset_x), self.width(), max(1, int(grid_size))):
+ ox = int(self._offset.x() % max(1, int(grid_size)))
+ oy = int(self._offset.y() % max(1, int(grid_size)))
+ for x in range(ox, self.width(), max(1, int(grid_size))):
painter.drawLine(x, 0, x, self.height())
- for y in range(int(offset_y), self.height(), max(1, int(grid_size))):
+ for y in range(oy, self.height(), max(1, int(grid_size))):
painter.drawLine(0, y, self.width(), y)
+ # ── Origin axes ──
origin = self._world_to_screen(QPoint(0, 0))
pen = QPen(QColor("#f38ba8"), 2)
painter.setPen(pen)
painter.drawLine(origin.x() - 20, origin.y(), origin.x() + 20, origin.y())
painter.drawLine(origin.x(), origin.y() - 20, origin.x(), origin.y() + 20)
+ # ── Points ──
for entity in self._points:
if entity.geometry:
x, y = entity.geometry
- screen_pos = self._world_to_screen(QPoint(int(x), int(y)))
-
+ screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
if entity.is_construction:
- pen = QPen(QColor("#6c7086"), 1)
- painter.setPen(pen)
+ painter.setPen(QPen(QColor("#6c7086"), 1))
painter.setBrush(QBrush(QColor("#6c7086")))
else:
- pen = QPen(QColor("#89b4fa"), 2)
- painter.setPen(pen)
+ painter.setPen(QPen(QColor("#89b4fa"), 2))
painter.setBrush(QBrush(QColor("#89b4fa")))
-
size = 4 if entity.is_construction else 6
painter.drawEllipse(screen_pos, size, size)
- for p1, p2 in self._lines:
- if p1.geometry and p2.geometry:
- x1, y1 = p1.geometry
- x2, y2 = p2.geometry
- screen_p1 = self._world_to_screen(QPoint(int(x1), int(y1)))
- screen_p2 = self._world_to_screen(QPoint(int(x2), int(y2)))
+ # ── Lines ──
+ for p1_ent, p2_ent in self._lines:
+ if p1_ent.geometry and p2_ent.geometry:
+ x1, y1 = p1_ent.geometry
+ x2, y2 = p2_ent.geometry
+ sp1 = self._world_to_screen(QPoint(int(round(x1)), int(round(y1))))
+ sp2 = self._world_to_screen(QPoint(int(round(x2)), int(round(y2))))
- if p1.is_construction or p2.is_construction:
- pen = QPen(QColor("#6c7086"), 1, Qt.DashLine)
+ is_construction = p1_ent.is_construction or p2_ent.is_construction
+ if is_construction:
+ painter.setPen(QPen(QColor("#6c7086"), 1, Qt.DashLine))
else:
- pen = QPen(QColor("#cdd6f4"), 2)
+ painter.setPen(QPen(QColor("#cdd6f4"), 2))
+ painter.drawLine(sp1, sp2)
- painter.setPen(pen)
- painter.drawLine(screen_p1, screen_p2)
+ # Draw constraint labels
+ constraints = p1_ent.constraints + p2_ent.constraints
+ if constraints:
+ labels = list(set(constraints))
+ mid_screen = self._calculate_midpoint(sp1, sp2)
+ painter.save()
+ painter.translate(mid_screen)
+ painter.scale(1, -1)
+ painter.setPen(QPen(QColor("#f9e2af"), 1))
+ for i, label in enumerate(labels):
+ label_text = {"hrz": "> hrz <", "vrt": "> vrt <",
+ "dst": "> dst <", "mid": "> mid <"}.get(label, f"> {label} <")
+ painter.drawText(0, i * 15, label_text)
+ painter.restore()
- for center, radius in self._circles:
- if center.geometry:
- cx, cy = center.geometry
- screen_center = self._world_to_screen(QPoint(int(cx), int(cy)))
- screen_radius = radius * self._zoom
-
- pen = QPen(QColor("#cdd6f4"), 2)
- painter.setPen(pen)
+ # ── Circles ──
+ for center_ent, radius in self._circles:
+ if center_ent.geometry:
+ cx, cy = center_ent.geometry
+ sc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
+ sr = radius * self._zoom
+ painter.setPen(QPen(QColor("#cdd6f4"), 2))
painter.setBrush(Qt.NoBrush)
- painter.drawEllipse(screen_center, int(screen_radius), int(screen_radius))
+ painter.drawEllipse(sc, int(sr), int(sr))
+ # ── Dynamic drawing previews ──
if self._draw_buffer and self._dynamic_line_end and self._mode == "line":
start = self._world_to_screen(self._draw_buffer[0])
end = self._world_to_screen(self._dynamic_line_end)
- pen = QPen(QColor("#a6e3a1"), 2, Qt.DashLine)
- painter.setPen(pen)
+ painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
painter.drawLine(start, end)
if self._draw_buffer and self._dynamic_line_end and self._mode == "rectangle":
p1 = self._world_to_screen(self._draw_buffer[0])
p2 = self._world_to_screen(self._dynamic_line_end)
- pen = QPen(QColor("#a6e3a1"), 2, Qt.DashLine)
- painter.setPen(pen)
- painter.drawRect(
- min(p1.x(), p2.x()),
- min(p1.y(), p2.y()),
- abs(p2.x() - p1.x()),
- abs(p2.y() - p1.y()),
- )
+ painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
+ painter.drawRect(min(p1.x(), p2.x()), min(p1.y(), p2.y()),
+ abs(p2.x() - p1.x()), abs(p2.y() - p1.y()))
if self._draw_buffer and self._dynamic_line_end and self._mode == "circle":
center = self._world_to_screen(self._draw_buffer[0])
end = self._world_to_screen(self._dynamic_line_end)
- radius = math.sqrt((end.x() - center.x()) ** 2 + (end.y() - center.y()) ** 2)
- pen = QPen(QColor("#a6e3a1"), 2, Qt.DashLine)
- painter.setPen(pen)
+ r = math.sqrt((end.x() - center.x()) ** 2 + (end.y() - center.y()) ** 2)
+ painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
painter.setBrush(Qt.NoBrush)
- painter.drawEllipse(center, int(radius), int(radius))
+ painter.drawEllipse(center, int(r), int(r))
+ # ── Hovered point highlight ──
if self._hovered_point:
screen_pos = self._world_to_screen(self._hovered_point)
- pen = QPen(QColor("#f9e2af"), 2)
- painter.setPen(pen)
+ painter.setPen(QPen(QColor("#f9e2af"), 2))
painter.setBrush(Qt.NoBrush)
painter.drawEllipse(screen_pos, 10, 10)
+ # ── Hovered line distance measurement ──
+ if self._hovered_line and not self._hovered_point:
+ p1, p2 = self._hovered_line
+ sp1 = self._world_to_screen(p1)
+ sp2 = self._world_to_screen(p2)
+ painter.setPen(QPen(QColor("#a6e3a1"), 2))
+ painter.drawLine(sp1, sp2)
+ self._draw_distance_measurement(painter, p1, p2)
+
+ # ── Selected entities ──
for entity in self._selected_entities:
if entity.geometry:
x, y = entity.geometry
- screen_pos = self._world_to_screen(QPoint(int(x), int(y)))
- pen = QPen(QColor("#f9e2af"), 2)
- painter.setPen(pen)
+ screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
+ painter.setPen(QPen(QColor("#f9e2af"), 2))
painter.setBrush(Qt.NoBrush)
painter.drawEllipse(screen_pos, 12, 12)
-
+ # ── DOF display ──
+ if self._sketch:
+ try:
+ dof = self._sketch.get_solver_dof()
+ painter.save()
+ painter.setPen(QPen(QColor("#a6adc8"), 1))
+ font = QFont("Monospace", 9)
+ painter.setFont(font)
+ painter.drawText(10, 20, f"DOF: {dof}")
+ painter.restore()
+ except Exception:
+ pass
class Viewer3DWidget(QWidget):
"""3D viewer widget using pygfx."""
@@ -707,6 +1132,12 @@ class Viewer3DWidget(QWidget):
self._meshes[mesh_id] = {"vertices": vertices, "faces": faces}
self._renderer.render()
+ def add_wireframe(self, vertices, edges, color=None, line_width=1.0, name=None) -> str:
+ self._ensure_initialized()
+ wid = self._renderer.add_wireframe(vertices, edges, color or (0.9, 0.9, 0.9), line_width, name)
+ self._renderer.render()
+ return wid
+
def remove_mesh(self, mesh_id: str):
self._ensure_initialized()
self._renderer.remove_mesh(mesh_id)
@@ -776,7 +1207,7 @@ class MainWindow(QMainWindow):
self._create_central_widget()
self._create_dock_widgets()
- self.statusBar().showMessage("Ready")
+ logger.info("Ready")
def _create_menus(self):
menubar = self.menuBar()
@@ -830,249 +1261,213 @@ class MainWindow(QMainWindow):
help_menu.addAction("About", self._show_about)
def _create_central_widget(self):
+ """
+ Recreates the classic grid-based layout from the original fluencyCAD.
+
+ Grid layout:
+ Col 0 (≤200px) Col 1 (expand) Col 2 (expand) Col 3 (≤200px)
+ r0: Workplanes InputTab Model Viewer Modify
+ r1: Drawing
+ r2: Constrain
+ r3: Snaps (tab) r6: Export
+ r4: (Snaps cont) r7-8: Bodies
+ r5: (Snaps cont)
+ r6-8: Sketch List
+ r9: Comp Tools Comp Buttons (span 2) Assembly Tools
+ """
central = QWidget()
self.setCentralWidget(central)
- main_layout = QHBoxLayout(central)
- main_layout.setContentsMargins(5, 5, 5, 5)
- main_layout.setSpacing(5)
-
- left_panel = self._create_left_panel()
- main_layout.addWidget(left_panel)
-
- center_panel = self._create_center_panel()
- main_layout.addWidget(center_panel, 1)
-
- right_panel = self._create_right_panel()
- main_layout.addWidget(right_panel)
-
- def _create_left_panel(self) -> QWidget:
- panel = QWidget()
- panel.setMaximumWidth(220)
- layout = QVBoxLayout(panel)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.setSpacing(5)
-
- workplane_group = QGroupBox("Workplanes")
- workplane_layout = QGridLayout(workplane_group)
+ grid = QGridLayout(central)
+ grid.setContentsMargins(5, 5, 5, 5)
+ grid.setSpacing(4)
+ grid.setColumnStretch(0, 0) # left column fixed
+ grid.setColumnStretch(1, 1) # sketch column expands
+ grid.setColumnStretch(2, 2) # 3D view expands more
+ grid.setColumnStretch(3, 0) # right column fixed
+ # ---- Row 0, Col 0: Workplanes ----
+ wp_group = QGroupBox("Workplanes")
+ wp_group.setMaximumWidth(200)
+ wp_layout = QGridLayout(wp_group)
self._btn_wp_origin = QPushButton("WP Origin")
self._btn_wp_origin.setToolTip("Working Plane at 0, 0, 0")
self._btn_wp_origin.setShortcut("W")
- workplane_layout.addWidget(self._btn_wp_origin, 0, 0)
-
+ wp_layout.addWidget(self._btn_wp_origin, 0, 0)
self._btn_wp_face = QPushButton("WP Face")
self._btn_wp_face.setToolTip("Working Plane from selected face")
self._btn_wp_face.setShortcut("P")
- workplane_layout.addWidget(self._btn_wp_face, 0, 1)
-
+ wp_layout.addWidget(self._btn_wp_face, 0, 1)
self._btn_wp_flip = QPushButton("WP Flip")
self._btn_wp_flip.setToolTip("Flip normal direction")
self._btn_wp_flip.setShortcut("N")
- workplane_layout.addWidget(self._btn_wp_flip, 1, 0)
-
- self._btn_wp_move = QPushButton("WP Move")
+ wp_layout.addWidget(self._btn_wp_flip, 1, 0)
+ self._btn_wp_move = QPushButton("WP Mve")
self._btn_wp_move.setToolTip("Move workplane")
- workplane_layout.addWidget(self._btn_wp_move, 1, 1)
-
- layout.addWidget(workplane_group)
-
- drawing_group = QGroupBox("Drawing")
- drawing_layout = QGridLayout(drawing_group)
+ self._btn_wp_move.setShortcut("M")
+ wp_layout.addWidget(self._btn_wp_move, 1, 1)
+ grid.addWidget(wp_group, 0, 0)
+ # ---- Row 1, Col 0: Drawing ----
+ draw_group = QGroupBox("Drawing")
+ draw_group.setMaximumWidth(200)
+ draw_layout = QGridLayout(draw_group)
self._btn_line = QPushButton("Line")
self._btn_line.setCheckable(True)
self._btn_line.setShortcut("S")
- drawing_layout.addWidget(self._btn_line, 0, 0)
-
- self._btn_rect = QPushButton("Rectangle")
+ draw_layout.addWidget(self._btn_line, 0, 0)
+ self._btn_rect = QPushButton("Rctgl")
self._btn_rect.setCheckable(True)
- drawing_layout.addWidget(self._btn_rect, 0, 1)
-
+ draw_layout.addWidget(self._btn_rect, 0, 1)
self._btn_circle = QPushButton("Circle")
self._btn_circle.setCheckable(True)
- drawing_layout.addWidget(self._btn_circle, 1, 0)
-
+ draw_layout.addWidget(self._btn_circle, 1, 0)
self._btn_slot = QPushButton("Slot")
self._btn_slot.setCheckable(True)
- drawing_layout.addWidget(self._btn_slot, 1, 1)
-
- self._btn_construct = QPushButton("Construct")
+ draw_layout.addWidget(self._btn_slot, 1, 1)
+ # separator
+ sep = QFrame()
+ sep.setFrameShape(QFrame.HLine)
+ sep.setFrameShadow(QFrame.Sunken)
+ draw_layout.addWidget(sep, 2, 0, 1, 2)
+ self._btn_construct = QPushButton("Cstrct")
self._btn_construct.setCheckable(True)
- drawing_layout.addWidget(self._btn_construct, 2, 0)
-
+ draw_layout.addWidget(self._btn_construct, 3, 0)
self._btn_snap = QPushButton("Snap")
self._btn_snap.setCheckable(True)
self._btn_snap.setChecked(True)
- drawing_layout.addWidget(self._btn_snap, 2, 1)
-
- layout.addWidget(drawing_group)
-
- constrain_group = QGroupBox("Constrain")
- constrain_layout = QGridLayout(constrain_group)
+ draw_layout.addWidget(self._btn_snap, 3, 1)
+ grid.addWidget(draw_group, 1, 0)
+ # ---- Row 2, Col 0: Constrain ----
+ con_group = QGroupBox("Constrain")
+ con_group.setMaximumWidth(200)
+ con_layout = QGridLayout(con_group)
self._btn_con_ptpt = QPushButton("Pt_Pt")
self._btn_con_ptpt.setCheckable(True)
self._btn_con_ptpt.setToolTip("Point to Point Coincident")
- constrain_layout.addWidget(self._btn_con_ptpt, 0, 0)
-
+ con_layout.addWidget(self._btn_con_ptpt, 0, 0)
self._btn_con_ptline = QPushButton("Pt_Lne")
self._btn_con_ptline.setCheckable(True)
self._btn_con_ptline.setToolTip("Point to Line Distance")
- constrain_layout.addWidget(self._btn_con_ptline, 0, 1)
-
+ con_layout.addWidget(self._btn_con_ptline, 0, 1)
+ self._btn_con_mid = QPushButton("Pt_Mid_L")
+ self._btn_con_mid.setCheckable(True)
+ self._btn_con_mid.setToolTip("Point to Midpoint")
+ con_layout.addWidget(self._btn_con_mid, 1, 0)
+ self._btn_con_perp = QPushButton("Perp_Lne")
+ self._btn_con_perp.setCheckable(True)
+ self._btn_con_perp.setToolTip("Perpendicular Constraint")
+ con_layout.addWidget(self._btn_con_perp, 1, 1)
self._btn_con_horiz = QPushButton("Horiz")
self._btn_con_horiz.setCheckable(True)
self._btn_con_horiz.setToolTip("Horizontal Constraint")
- constrain_layout.addWidget(self._btn_con_horiz, 1, 0)
-
+ con_layout.addWidget(self._btn_con_horiz, 2, 0)
self._btn_con_vert = QPushButton("Vert")
self._btn_con_vert.setCheckable(True)
self._btn_con_vert.setToolTip("Vertical Constraint")
- constrain_layout.addWidget(self._btn_con_vert, 1, 1)
-
- self._btn_con_mid = QPushButton("Pt_Mid")
- self._btn_con_mid.setCheckable(True)
- self._btn_con_mid.setToolTip("Midpoint Constraint")
- constrain_layout.addWidget(self._btn_con_mid, 2, 0)
-
- self._btn_con_perp = QPushButton("Perp")
- self._btn_con_perp.setCheckable(True)
- self._btn_con_perp.setToolTip("Perpendicular Constraint")
- constrain_layout.addWidget(self._btn_con_perp, 2, 1)
-
- self._btn_con_dist = QPushButton("Distance")
+ con_layout.addWidget(self._btn_con_vert, 2, 1)
+ self._btn_con_dist = QPushButton("Distnce")
self._btn_con_dist.setCheckable(True)
self._btn_con_dist.setToolTip("Distance Constraint")
- constrain_layout.addWidget(self._btn_con_dist, 3, 0)
-
- self._btn_con_sym = QPushButton("Symetric")
+ con_layout.addWidget(self._btn_con_dist, 3, 0)
+ self._btn_con_sym = QPushButton("Symetrc")
self._btn_con_sym.setCheckable(True)
- constrain_layout.addWidget(self._btn_con_sym, 3, 1)
+ con_layout.addWidget(self._btn_con_sym, 3, 1)
+ grid.addWidget(con_group, 2, 0)
- layout.addWidget(constrain_group)
-
- snaps_group = QGroupBox("Snapping")
- snaps_layout = QGridLayout(snaps_group)
-
- self._btn_snap_point = QPushButton("Point")
+ # ---- Row 3-5, Col 0: Snaps tab ----
+ self._snaps_tab = QTabWidget()
+ self._snaps_tab.setMaximumWidth(200)
+ self._snaps_tab.setTabPosition(QTabWidget.TabPosition.South)
+ snaps_tab1 = QWidget()
+ snaps_layout = QVBoxLayout(snaps_tab1)
+ snap_group = QGroupBox("Snapping Points")
+ snap_grid = QGridLayout(snap_group)
+ snap_grid.setContentsMargins(2, 2, 2, 2)
+ self._btn_snap_point = QPushButton("Pnt")
self._btn_snap_point.setCheckable(True)
self._btn_snap_point.setChecked(True)
- snaps_layout.addWidget(self._btn_snap_point, 0, 0)
-
+ snap_grid.addWidget(self._btn_snap_point, 0, 0)
self._btn_snap_mid = QPushButton("MidP")
self._btn_snap_mid.setCheckable(True)
- snaps_layout.addWidget(self._btn_snap_mid, 0, 1)
-
+ snap_grid.addWidget(self._btn_snap_mid, 0, 1)
self._btn_snap_horiz = QPushButton("Horiz")
self._btn_snap_horiz.setCheckable(True)
- snaps_layout.addWidget(self._btn_snap_horiz, 1, 0)
-
+ snap_grid.addWidget(self._btn_snap_horiz, 1, 0)
self._btn_snap_vert = QPushButton("Vert")
self._btn_snap_vert.setCheckable(True)
- snaps_layout.addWidget(self._btn_snap_vert, 1, 1)
-
+ snap_grid.addWidget(self._btn_snap_vert, 1, 1)
self._btn_snap_angle = QPushButton("Angles")
self._btn_snap_angle.setCheckable(True)
- snaps_layout.addWidget(self._btn_snap_angle, 2, 0)
-
+ snap_grid.addWidget(self._btn_snap_angle, 2, 0)
self._btn_snap_grid = QPushButton("Grid")
self._btn_snap_grid.setCheckable(True)
- snaps_layout.addWidget(self._btn_snap_grid, 2, 1)
-
- snap_dist_layout = QHBoxLayout()
- snap_dist_layout.addWidget(QLabel("Dist:"))
+ snap_grid.addWidget(self._btn_snap_grid, 2, 1)
+ # separator
+ sep2 = QFrame()
+ sep2.setFrameShape(QFrame.HLine)
+ sep2.setFrameShadow(QFrame.Sunken)
+ snap_grid.addWidget(sep2, 3, 0, 1, 2)
+ snap_grid.addWidget(QLabel("Snp Dst"), 4, 0)
+ snap_grid.addWidget(QLabel("Angl Stps"), 4, 1)
self._spin_snap_dist = QSpinBox()
- self._spin_snap_dist.setRange(1, 50)
+ self._spin_snap_dist.setRange(1, 30)
self._spin_snap_dist.setValue(10)
self._spin_snap_dist.setSuffix("px")
- snap_dist_layout.addWidget(self._spin_snap_dist)
- snaps_layout.addLayout(snap_dist_layout, 3, 0, 1, 2)
-
- angle_steps_layout = QHBoxLayout()
- angle_steps_layout.addWidget(QLabel("Angle:"))
+ snap_grid.addWidget(self._spin_snap_dist, 5, 0)
self._spin_angle = QSpinBox()
- self._spin_angle.setRange(1, 90)
+ self._spin_angle.setRange(1, 180)
self._spin_angle.setValue(15)
self._spin_angle.setSuffix("°")
- angle_steps_layout.addWidget(self._spin_angle)
- snaps_layout.addLayout(angle_steps_layout, 4, 0, 1, 2)
-
- layout.addWidget(snaps_group)
-
- sketch_list_group = QGroupBox("Sketches")
- sketch_list_layout = QVBoxLayout(sketch_list_group)
+ snap_grid.addWidget(self._spin_angle, 5, 1)
+ snaps_layout.addWidget(snap_group)
+ self._snaps_tab.addTab(snaps_tab1, "Setg 1")
+ self._snaps_tab.addTab(QWidget(), "Setg 2")
+ grid.addWidget(self._snaps_tab, 3, 0)
+ # snaps tab spans rows 3-5
+ grid.setRowStretch(3, 0)
+ grid.setRowStretch(4, 1)
+ grid.setRowStretch(5, 1)
+ # Build row 6-8 spacer for left column
+ # Reserve space so the sketch list reaches the bottom
+ grid.setRowMinimumHeight(6, 0)
+ grid.setRowMinimumHeight(7, 0)
+ grid.setRowMinimumHeight(8, 0)
+ # ---- Row 6-8, Col 0: Sketch List ----
+ sk_list_group = QGroupBox("Sketch")
+ sk_list_group.setMaximumWidth(200)
+ sk_list_layout = QVBoxLayout(sk_list_group)
self._sketch_list = QListWidget()
self._sketch_list.setSelectionRectVisible(True)
- sketch_list_layout.addWidget(self._sketch_list)
-
- sketch_tools_layout = QHBoxLayout()
+ sk_list_layout.addWidget(self._sketch_list)
+ sk_tools = QGroupBox("Tools")
+ sk_tools_grid = QGridLayout(sk_tools)
+ sk_tools_grid.setContentsMargins(2, 2, 2, 2)
self._btn_add_sketch = QPushButton("Add")
- self._btn_edit_sketch = QPushButton("Edit")
+ sk_tools_grid.addWidget(self._btn_add_sketch, 0, 0)
+ self._btn_edit_sketch = QPushButton("Edt")
+ sk_tools_grid.addWidget(self._btn_edit_sketch, 0, 1)
self._btn_del_sketch = QPushButton("Del")
- sketch_tools_layout.addWidget(self._btn_add_sketch)
- sketch_tools_layout.addWidget(self._btn_edit_sketch)
- sketch_tools_layout.addWidget(self._btn_del_sketch)
- sketch_list_layout.addLayout(sketch_tools_layout)
-
- layout.addWidget(sketch_list_group)
-
- layout.addStretch()
-
- return panel
-
- def _create_center_panel(self) -> QWidget:
- panel = QWidget()
- layout = QVBoxLayout(panel)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.setSpacing(5)
-
- modify_group = QGroupBox("Modify")
- modify_layout = QHBoxLayout(modify_group)
-
- self._btn_extrude = QPushButton("Extrd")
- self._btn_extrude.setToolTip("Extrude sketch")
- modify_layout.addWidget(self._btn_extrude)
-
- self._btn_cut = QPushButton("Cut")
- self._btn_cut.setToolTip("Boolean cut")
- modify_layout.addWidget(self._btn_cut)
-
- self._btn_combine = QPushButton("Comb")
- self._btn_combine.setToolTip("Boolean union")
- modify_layout.addWidget(self._btn_combine)
-
- self._btn_revolve = QPushButton("Rev")
- self._btn_revolve.setToolTip("Revolve sketch")
- modify_layout.addWidget(self._btn_revolve)
-
- self._btn_array = QPushButton("Arry")
- self._btn_array.setToolTip("Pattern array")
- modify_layout.addWidget(self._btn_array)
-
- self._btn_move = QPushButton("Mve")
- self._btn_move.setToolTip("Move body")
- modify_layout.addWidget(self._btn_move)
-
- layout.addWidget(modify_group)
+ sk_tools_grid.addWidget(self._btn_del_sketch, 0, 2)
+ sk_list_layout.addWidget(sk_tools)
+ grid.addWidget(sk_list_group, 6, 0, 3, 1)
+ # ---- Row 0-8, Col 1: Input Tabs ----
self._input_tabs = QTabWidget()
-
sketch_tab = QWidget()
sketch_tab_layout = QVBoxLayout(sketch_tab)
sketch_tab_layout.setContentsMargins(0, 0, 0, 0)
self._sketch_widget = Sketch2DWidget()
sketch_tab_layout.addWidget(self._sketch_widget)
self._input_tabs.addTab(sketch_tab, "Sketch")
-
code_tab = QWidget()
code_tab_layout = QVBoxLayout(code_tab)
self._code_edit = QTextEdit()
self._code_edit.setFont(QFont("Monaco", 10))
- self._code_edit.setPlaceholderText("# Enter Python/CadQuery code here...")
+ self._code_edit.setPlaceholderText("# Enter Python code here...")
code_tab_layout.addWidget(self._code_edit)
-
code_tools = QHBoxLayout()
self._btn_apply_code = QPushButton("Apply Code")
self._btn_load_code = QPushButton("Load Code")
@@ -1084,92 +1479,106 @@ class MainWindow(QMainWindow):
code_tools.addWidget(self._btn_del_code)
code_tab_layout.addLayout(code_tools)
self._input_tabs.addTab(code_tab, "Code")
+ grid.addWidget(self._input_tabs, 0, 1, 9, 1)
- layout.addWidget(self._input_tabs, 1)
-
+ # ---- Row 0-8, Col 2: Model Viewer ----
viewer_group = QGroupBox("Model Viewer")
viewer_layout = QVBoxLayout(viewer_group)
viewer_layout.setContentsMargins(5, 5, 5, 5)
self._viewer_3d = Viewer3DWidget()
viewer_layout.addWidget(self._viewer_3d)
- layout.addWidget(viewer_group, 2)
+ grid.addWidget(viewer_group, 0, 2, 9, 1)
- component_group = QGroupBox("Components")
- component_layout = QHBoxLayout(component_group)
+ # ---- Row 0, Col 3: Modify ----
+ modify_group = QGroupBox("Modify")
+ modify_group.setMaximumWidth(200)
+ modify_layout = QGridLayout(modify_group)
+ self._btn_extrude = QPushButton("Extrd")
+ self._btn_extrude.setToolTip("Extrude sketch")
+ modify_layout.addWidget(self._btn_extrude, 0, 0)
+ self._btn_cut = QPushButton("Cut")
+ self._btn_cut.setToolTip("Boolean cut")
+ modify_layout.addWidget(self._btn_cut, 0, 1)
+ self._btn_combine = QPushButton("Comb")
+ self._btn_combine.setToolTip("Boolean union")
+ modify_layout.addWidget(self._btn_combine, 1, 0)
+ self._btn_move = QPushButton("Mve")
+ self._btn_move.setToolTip("Move body")
+ modify_layout.addWidget(self._btn_move, 1, 1)
+ self._btn_revolve = QPushButton("Rev")
+ self._btn_revolve.setToolTip("Revolve sketch")
+ modify_layout.addWidget(self._btn_revolve, 2, 0)
+ self._btn_array = QPushButton("Arry")
+ self._btn_array.setToolTip("Pattern array")
+ modify_layout.addWidget(self._btn_array, 2, 1)
+ grid.addWidget(modify_group, 0, 3, 1, 1, Qt.AlignTop)
+ # ---- Row 6, Col 3: Export ----
+ export_group = QGroupBox("Export")
+ export_group.setMaximumWidth(200)
+ export_layout = QVBoxLayout(export_group)
+ self._btn_export_stl = QPushButton("STL")
+ export_layout.addWidget(self._btn_export_stl)
+ self._btn_export_step = QPushButton("STEP")
+ export_layout.addWidget(self._btn_export_step)
+ self._btn_export_iges = QPushButton("IGES")
+ export_layout.addWidget(self._btn_export_iges)
+ grid.addWidget(export_group, 6, 3)
+
+ # ---- Row 7-8, Col 3: Bodies / Operations ----
+ body_group = QGroupBox("Bodys / Operations")
+ body_group.setMaximumWidth(200)
+ body_layout = QVBoxLayout(body_group)
+ self._body_list = QListWidget()
+ self._body_list.setSelectionRectVisible(True)
+ body_layout.addWidget(self._body_list)
+ body_tools_grid = QGridLayout()
+ body_tools_grid.setContentsMargins(2, 2, 2, 2)
+ self._btn_update_body = QPushButton("Upd")
+ body_tools_grid.addWidget(self._btn_update_body, 0, 0)
+ self._btn_edit_sketch_3 = QPushButton("Nothing")
+ body_tools_grid.addWidget(self._btn_edit_sketch_3, 0, 1)
+ self._btn_del_body = QPushButton("Del")
+ body_tools_grid.addWidget(self._btn_del_body, 0, 2)
+ body_layout.addLayout(body_tools_grid)
+ grid.addWidget(body_group, 7, 3, 2, 1)
+
+ # ---- Row 9, Col 0: Component Tools ----
+ comp_tool_group = QGroupBox("Component Tools")
+ comp_tool_group.setMinimumHeight(50)
+ comp_tool_layout = QHBoxLayout(comp_tool_group)
self._btn_new_compo = QPushButton("New")
self._btn_new_compo.setFixedSize(QSize(50, 50))
- component_layout.addWidget(self._btn_new_compo)
-
+ comp_tool_layout.addWidget(self._btn_new_compo)
self._btn_del_compo = QPushButton("Del")
self._btn_del_compo.setFixedSize(QSize(50, 50))
- component_layout.addWidget(self._btn_del_compo)
+ comp_tool_layout.addWidget(self._btn_del_compo)
+ grid.addWidget(comp_tool_group, 9, 0)
+ # ---- Row 9, Col 1-2: Components (button box) ----
+ compo_group = QGroupBox("Components")
+ compo_group.setMinimumHeight(50)
+ compo_layout = QHBoxLayout(compo_group)
self._component_box = QWidget()
self._component_box_layout = QHBoxLayout(self._component_box)
self._component_box_layout.setAlignment(Qt.AlignLeft)
self._component_group = QButtonGroup(self)
self._component_group.setExclusive(True)
- component_layout.addWidget(self._component_box)
- component_layout.addStretch()
-
- layout.addWidget(component_group)
-
- return panel
-
- def _create_right_panel(self) -> QWidget:
- panel = QWidget()
- panel.setMaximumWidth(200)
- layout = QVBoxLayout(panel)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.setSpacing(5)
-
- body_list_group = QGroupBox("Bodies / Operations")
- body_list_layout = QVBoxLayout(body_list_group)
-
- self._body_list = QListWidget()
- self._body_list.setSelectionRectVisible(True)
- body_list_layout.addWidget(self._body_list)
-
- body_tools_layout = QHBoxLayout()
- self._btn_update_body = QPushButton("Upd")
- self._btn_del_body = QPushButton("Del")
- body_tools_layout.addWidget(self._btn_update_body)
- body_tools_layout.addWidget(self._btn_del_body)
- body_list_layout.addLayout(body_tools_layout)
-
- layout.addWidget(body_list_group)
-
- export_group = QGroupBox("Export")
- export_layout = QVBoxLayout(export_group)
-
- self._btn_export_stl = QPushButton("STL")
- export_layout.addWidget(self._btn_export_stl)
-
- self._btn_export_step = QPushButton("STEP")
- export_layout.addWidget(self._btn_export_step)
-
- self._btn_export_iges = QPushButton("IGES")
- export_layout.addWidget(self._btn_export_iges)
-
- layout.addWidget(export_group)
-
- assembly_group = QGroupBox("Assembly Tools")
- assembly_layout = QHBoxLayout(assembly_group)
+ compo_layout.addWidget(self._component_box)
+ compo_layout.addStretch()
+ grid.addWidget(compo_group, 9, 1, 1, 2)
+ # ---- Row 9, Col 3: Assembly Tools ----
+ assy_group = QGroupBox("Assembly Tools")
+ assy_group.setMinimumHeight(50)
+ assy_layout = QHBoxLayout(assy_group)
self._btn_add_connector = QPushButton("+ Cnct")
self._btn_add_connector.setFixedSize(QSize(50, 50))
- assembly_layout.addWidget(self._btn_add_connector)
-
+ assy_layout.addWidget(self._btn_add_connector)
self._btn_del_connector = QPushButton("- Cnct")
self._btn_del_connector.setFixedSize(QSize(50, 50))
- assembly_layout.addWidget(self._btn_del_connector)
-
- layout.addWidget(assembly_group)
-
- layout.addStretch()
-
- return panel
+ assy_layout.addWidget(self._btn_del_connector)
+ grid.addWidget(assy_group, 9, 3)
def _create_dock_widgets(self):
pass
@@ -1218,6 +1627,7 @@ class MainWindow(QMainWindow):
self._btn_new_compo.clicked.connect(self._new_component)
self._btn_del_compo.clicked.connect(self._delete_component)
+ self._btn_update_body.clicked.connect(self._redraw_bodies)
self._btn_del_body.clicked.connect(self._delete_body)
self._btn_export_stl.clicked.connect(self._export_stl)
@@ -1227,12 +1637,21 @@ class MainWindow(QMainWindow):
self._sketch_widget.constrain_done.connect(self._on_constrain_done)
self._sketch_widget.sketch_updated.connect(self._on_sketch_updated)
- self._sketch_list.currentItemChanged.connect(self._on_sketch_list_changed)
+ self._sketch_list.currentItemChanged.connect(self._on_sketch_selected)
self._body_list.currentItemChanged.connect(self._on_body_list_changed)
self._btn_wp_origin.clicked.connect(self._new_sketch_origin)
self._btn_wp_face.clicked.connect(self._new_sketch_from_face)
self._btn_wp_flip.clicked.connect(self._flip_workplane)
+ self._btn_wp_move.clicked.connect(self._move_workplane)
+
+ # Generic buttons
+ self._btn_move.clicked.connect(self._translate_body)
+ self._btn_array.clicked.connect(self._pattern_array)
+ self._btn_edit_sketch_3.clicked.connect(self._edit_sketch)
+
+ # Snap toggle
+ self._btn_snap.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("point", c))
def _create_initial_component(self):
self._new_component()
@@ -1323,7 +1742,7 @@ class MainWindow(QMainWindow):
self._component_box_layout.addWidget(btn)
self._refresh_lists()
- self.statusBar().showMessage(f"Created component: {comp.name}")
+ logger.info(f"Created component: {comp.name}")
def _delete_component(self):
idx = self._get_active_component_index()
@@ -1341,7 +1760,7 @@ class MainWindow(QMainWindow):
self._component_buttons[0].setChecked(True)
self._refresh_lists()
- self.statusBar().showMessage(f"Deleted component")
+ logger.info(f"Deleted component")
def _on_component_button_clicked(self):
idx = self._get_active_component_index()
@@ -1369,9 +1788,21 @@ class MainWindow(QMainWindow):
for body_id, body in self._current_component.bodies.items():
if body.geometry:
vertices, faces = body.get_mesh(self._kernel)
- body.render_object = self._viewer_3d.add_mesh(
+ mesh_id = self._viewer_3d.add_mesh(
vertices, faces, body.color, body.name
)
+ body.render_object = mesh_id
+ # Add wireframe edges
+ try:
+ edge_verts, edge_indices = body.get_edges(self._kernel)
+ if len(edge_verts) > 0 and len(edge_indices) > 0:
+ self._viewer_3d.add_wireframe(
+ edge_verts, edge_indices, (0.9, 0.9, 0.9),
+ line_width=1.5,
+ name=f"{body.name}_edges"
+ )
+ except Exception as e:
+ logger.debug(f"Could not add wireframe for {body.name}: {e}")
self._viewer_3d.fit_camera()
@@ -1379,16 +1810,43 @@ class MainWindow(QMainWindow):
self._sketch_widget.create_sketch()
self._sketch_widget.set_mode("line")
self._btn_line.setChecked(True)
- self.statusBar().showMessage("New sketch at origin")
+ logger.info("New sketch at origin")
def _new_sketch_from_face(self):
self._sketch_widget.create_sketch()
self._sketch_widget.set_mode("line")
self._btn_line.setChecked(True)
- self.statusBar().showMessage("New sketch from face")
+ logger.info("New sketch from face")
def _flip_workplane(self):
- self.statusBar().showMessage("Flip workplane (not implemented)")
+ logger.info("Flip workplane (not implemented)")
+
+ def _move_workplane(self):
+ logger.info("Move workplane: use middle-click pan in 3D view")
+
+ def _translate_body(self):
+ if not self._selected_body or not self._selected_body.geometry:
+ QMessageBox.warning(self, "No Body", "Select a body first")
+ return
+ dx, ok1 = QInputDialog.getDouble(self, "Translate", "DX (mm):", 0, -10000, 10000, 2)
+ if not ok1:
+ return
+ dy, ok2 = QInputDialog.getDouble(self, "Translate", "DY (mm):", 0, -10000, 10000, 2)
+ if not ok2:
+ return
+ dz, ok3 = QInputDialog.getDouble(self, "Translate", "DZ (mm):", 0, -10000, 10000, 2)
+ if not ok3:
+ return
+ try:
+ new_geom = self._kernel.translate(self._selected_body.geometry, (dx, dy, dz))
+ self._selected_body.geometry = new_geom
+ self._redraw_bodies()
+ logger.info(f"Translated body by ({dx}, {dy}, {dz})")
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Translation failed: {e}")
+
+ def _pattern_array(self):
+ logger.info("Pattern array not yet implemented")
def _add_sketch_to_component(self):
logger.info("=== ADD SKETCH TO COMPONENT ===")
@@ -1410,7 +1868,7 @@ class MainWindow(QMainWindow):
self._current_sketch = sketch
self._refresh_lists()
self._sketch_widget.set_mode(None)
- self.statusBar().showMessage(f"Added sketch: {sketch.name}")
+ logger.info(f"Added sketch: {sketch.name}")
logger.info(f"=== SKETCH ADDED: {sketch.name} ===")
def _edit_sketch(self):
@@ -1426,9 +1884,20 @@ class MainWindow(QMainWindow):
self._sketch_widget.set_sketch(sketch.occ_sketch)
self._sketch_widget.set_mode("line")
self._btn_line.setChecked(True)
- self.statusBar().showMessage(f"Editing sketch: {name}")
+ logger.info(f"Editing sketch: {name}")
break
+ def _on_sketch_selected(self, current, previous):
+ """When sketch is selected in list, load it for editing."""
+ if current and self._current_component:
+ name = current.text()
+ for sketch_id, sketch in self._current_component.sketches.items():
+ if sketch.name == name:
+ self._current_sketch = sketch
+ if sketch.occ_sketch and hasattr(sketch.occ_sketch, 'get_entity_count') and sketch.occ_sketch.get_entity_count() > 0:
+ self._sketch_widget.set_sketch(sketch.occ_sketch)
+ break
+
def _delete_sketch(self):
selected = self._sketch_list.currentItem()
if not selected or not self._current_component:
@@ -1444,7 +1913,7 @@ class MainWindow(QMainWindow):
if to_delete:
del self._current_component.sketches[to_delete]
self._refresh_lists()
- self.statusBar().showMessage(f"Deleted sketch: {name}")
+ logger.info(f"Deleted sketch: {name}")
def _on_sketch_list_changed(self, current, previous):
if current and self._current_component:
@@ -1460,7 +1929,7 @@ class MainWindow(QMainWindow):
for body_id, body in self._current_component.bodies.items():
if body.name == name:
self._selected_body = body
- self.statusBar().showMessage(f"Selected: {name}")
+ logger.info(f"Selected: {name}")
break
def _extrude_sketch(self):
@@ -1515,13 +1984,55 @@ class MainWindow(QMainWindow):
vertices, faces = body.get_mesh(self._kernel)
logger.info(f"Mesh: {len(vertices)} vertices, {len(faces)} faces")
+ # Handle cut/union options from dialog
+ if cut and self._current_component.bodies:
+ logger.info("Applying cut after extrude")
+ existing = list(self._current_component.bodies.values())
+ if len(existing) > 1:
+ target = existing[0] # first body is target
+ try:
+ result_geom = self._kernel.boolean_difference(
+ target.geometry, body_geometry
+ )
+ body.geometry = result_geom
+ vertices, faces = body.get_mesh(self._kernel)
+ logger.info(f"Cut applied, mesh now {len(vertices)} verts")
+ except Exception as ce:
+ logger.warning(f"Cut failed: {ce}")
+ elif union and self._current_component.bodies:
+ logger.info("Applying union after extrude")
+ existing = list(self._current_component.bodies.values())
+ if len(existing) > 1:
+ target = existing[0]
+ try:
+ result_geom = self._kernel.boolean_union(
+ target.geometry, body_geometry
+ )
+ body.geometry = result_geom
+ vertices, faces = body.get_mesh(self._kernel)
+ logger.info(f"Union applied, mesh now {len(vertices)} verts")
+ except Exception as ue:
+ logger.warning(f"Union failed: {ue}")
+
logger.debug("Adding mesh to viewer")
body.render_object = self._viewer_3d.add_mesh(vertices, faces, body.color, body.name)
logger.info(f"Render object: {body.render_object}")
+ # Add wireframe edges
+ try:
+ edge_verts, edge_indices = body.get_edges(self._kernel)
+ if len(edge_verts) > 0 and len(edge_indices) > 0:
+ self._viewer_3d.add_wireframe(
+ edge_verts, edge_indices, (0.9, 0.9, 0.9),
+ line_width=1.5,
+ name=f"{body.name}_edges"
+ )
+ except Exception as e:
+ logger.debug(f"Could not add wireframe: {e}")
+
self._refresh_lists()
self._viewer_3d.fit_camera()
- self.statusBar().showMessage(f"Extruded: {body.name}")
+ logger.info(f"Extruded: {body.name}")
logger.info("=== EXTRUDE COMPLETE ===")
except Exception as e:
@@ -1529,13 +2040,186 @@ class MainWindow(QMainWindow):
QMessageBox.critical(self, "Error", f"Extrude failed: {e}")
def _revolve_sketch(self):
- self.statusBar().showMessage("Revolve not yet implemented")
+ logger.info("=== REVOLVE SKETCH ===")
+ if not self._current_component:
+ logger.warning("No current component")
+ return
+
+ sketch = self._current_sketch
+ if not sketch or not sketch.occ_sketch:
+ sketch_entity = self._sketch_widget.get_sketch()
+ if not sketch_entity:
+ QMessageBox.warning(self, "No Sketch", "Please create a sketch first")
+ return
+ sketch.occ_sketch = sketch_entity
+
+ dialog = RevolveDialog(self)
+ if not dialog.exec():
+ logger.info("Revolve dialog cancelled")
+ return
+
+ angle = dialog.angle_input.value()
+
+ try:
+ geometry = sketch.occ_sketch.get_geometry()
+ if not geometry:
+ QMessageBox.warning(self, "No Geometry", "Sketch has no valid geometry")
+ return
+
+ body_geometry = self._kernel.revolve(geometry, angle)
+ body = self._current_component.add_body(
+ Body(
+ name=f"Revolution_{len(self._current_component.bodies) + 1}",
+ geometry=body_geometry,
+ source_sketch=sketch,
+ source_operation="revolve",
+ )
+ )
+
+ vertices, faces = body.get_mesh(self._kernel)
+ body.render_object = self._viewer_3d.add_mesh(
+ vertices, faces, body.color, body.name
+ )
+
+ # Add wireframe edges
+ try:
+ edge_verts, edge_indices = body.get_edges(self._kernel)
+ if len(edge_verts) > 0 and len(edge_indices) > 0:
+ self._viewer_3d.add_wireframe(
+ edge_verts, edge_indices, (0.9, 0.9, 0.9),
+ line_width=1.5,
+ name=f"{body.name}_edges"
+ )
+ except Exception as e:
+ logger.debug(f"Could not add wireframe: {e}")
+
+ self._refresh_lists()
+ self._viewer_3d.fit_camera()
+ logger.info(f"Revolved: {body.name}")
+
+ except Exception as e:
+ logger.exception(f"Revolve failed: {e}")
+ QMessageBox.critical(self, "Error", f"Revolve failed: {e}")
def _boolean_cut(self):
- self.statusBar().showMessage("Boolean cut not yet implemented")
+ logger.info("=== BOOLEAN CUT ===")
+ if not self._current_component or len(self._current_component.bodies) < 2:
+ QMessageBox.warning(self, "Need Bodies", "Need at least 2 bodies to perform cut.\nCreate multiple bodies first.")
+ return
+
+ # Use the first body in the list as base, last as tool
+ body_ids = list(self._current_component.bodies.keys())
+ if len(body_ids) < 2:
+ return
+
+ # Let user pick which body to use as tool
+ body_names = [self._current_component.bodies[bid].name for bid in body_ids]
+ tool_name, ok = QInputDialog.getItem(
+ self, "Select Tool Body", "Body to subtract (tool):", body_names, len(body_names) - 1, False
+ )
+ if not ok:
+ return
+
+ tool_id = None
+ base_id = None
+ for bid in body_ids:
+ if self._current_component.bodies[bid].name == tool_name:
+ tool_id = bid
+ else:
+ base_id = bid
+
+ if tool_id is None or base_id is None:
+ return
+
+ base_body = self._current_component.bodies[base_id]
+ tool_body = self._current_component.bodies[tool_id]
+
+ if not base_body.geometry or not tool_body.geometry:
+ QMessageBox.warning(self, "No Geometry", "One of the bodies has no geometry")
+ return
+
+ try:
+ result_geom = self._kernel.boolean_difference(base_body.geometry, tool_body.geometry)
+ new_body = self._current_component.add_body(
+ Body(
+ name=f"Cut_{len(self._current_component.bodies) + 1}",
+ geometry=result_geom,
+ source_operation="boolean_cut",
+ )
+ )
+
+ vertices, faces = new_body.get_mesh(self._kernel)
+ new_body.render_object = self._viewer_3d.add_mesh(
+ vertices, faces, new_body.color, new_body.name
+ )
+
+ # Add wireframe edges
+ try:
+ edge_verts, edge_indices = new_body.get_edges(self._kernel)
+ if len(edge_verts) > 0 and len(edge_indices) > 0:
+ self._viewer_3d.add_wireframe(
+ edge_verts, edge_indices, (0.9, 0.9, 0.9),
+ line_width=1.5,
+ name=f"{new_body.name}_edges"
+ )
+ except Exception as e:
+ logger.debug(f"Could not add wireframe: {e}")
+
+ self._refresh_lists()
+ self._viewer_3d.fit_camera()
+ logger.info(f"Cut complete: {new_body.name}")
+
+ except Exception as e:
+ logger.exception(f"Boolean cut failed: {e}")
+ QMessageBox.critical(self, "Error", f"Boolean cut failed: {e}")
def _boolean_union(self):
- self.statusBar().showMessage("Boolean union not yet implemented")
+ logger.info("=== BOOLEAN UNION ===")
+ if not self._current_component or len(self._current_component.bodies) < 2:
+ QMessageBox.warning(self, "Need Bodies", "Need at least 2 bodies to perform union.")
+ return
+
+ bodies = list(self._current_component.bodies.values())
+ geometries = [b.geometry for b in bodies if b.geometry]
+
+ if len(geometries) < 2:
+ QMessageBox.warning(self, "Need Bodies", "Not enough bodies with valid geometry.")
+ return
+
+ try:
+ result_geom = self._kernel.boolean_union(*geometries)
+ new_body = self._current_component.add_body(
+ Body(
+ name=f"Union_{len(self._current_component.bodies) + 1}",
+ geometry=result_geom,
+ source_operation="boolean_union",
+ )
+ )
+
+ vertices, faces = new_body.get_mesh(self._kernel)
+ new_body.render_object = self._viewer_3d.add_mesh(
+ vertices, faces, new_body.color, new_body.name
+ )
+
+ # Add wireframe edges
+ try:
+ edge_verts, edge_indices = new_body.get_edges(self._kernel)
+ if len(edge_verts) > 0 and len(edge_indices) > 0:
+ self._viewer_3d.add_wireframe(
+ edge_verts, edge_indices, (0.9, 0.9, 0.9),
+ line_width=1.5,
+ name=f"{new_body.name}_edges"
+ )
+ except Exception as e:
+ logger.debug(f"Could not add wireframe: {e}")
+
+ self._refresh_lists()
+ self._viewer_3d.fit_camera()
+ logger.info(f"Union complete: {new_body.name}")
+
+ except Exception as e:
+ logger.exception(f"Boolean union failed: {e}")
+ QMessageBox.critical(self, "Error", f"Boolean union failed: {e}")
def _delete_body(self):
selected = self._body_list.currentItem()
@@ -1554,7 +2238,7 @@ class MainWindow(QMainWindow):
if to_delete:
del self._current_component.bodies[to_delete]
self._refresh_lists()
- self.statusBar().showMessage(f"Deleted body: {name}")
+ logger.info(f"Deleted body: {name}")
def _new_project(self):
self._project = Project()
@@ -1571,7 +2255,7 @@ class MainWindow(QMainWindow):
self._refresh_lists()
self._create_initial_component()
- self.statusBar().showMessage("New project created")
+ logger.info("New project created")
def _import_file(self):
filepath, _ = QFileDialog.getOpenFileName(
@@ -1598,7 +2282,7 @@ class MainWindow(QMainWindow):
self._refresh_lists()
self._viewer_3d.fit_camera()
- self.statusBar().showMessage(f"Imported: {filepath}")
+ logger.info(f"Imported: {filepath}")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to import: {e}")
@@ -1613,7 +2297,7 @@ class MainWindow(QMainWindow):
)
if filepath:
if self._kernel.export_step(self._selected_body.geometry, filepath):
- self.statusBar().showMessage(f"Exported: {filepath}")
+ logger.info(f"Exported: {filepath}")
else:
QMessageBox.warning(self, "Export Failed", "Failed to export STEP")
@@ -1627,7 +2311,7 @@ class MainWindow(QMainWindow):
)
if filepath:
if self._kernel.export_iges(self._selected_body.geometry, filepath):
- self.statusBar().showMessage(f"Exported: {filepath}")
+ logger.info(f"Exported: {filepath}")
else:
QMessageBox.warning(self, "Export Failed", "Failed to export IGES")
@@ -1639,7 +2323,7 @@ class MainWindow(QMainWindow):
filepath, _ = QFileDialog.getSaveFileName(self, "Export STL", "", "STL Files (*.stl)")
if filepath:
if self._kernel.export_stl(self._selected_body.geometry, filepath):
- self.statusBar().showMessage(f"Exported: {filepath}")
+ logger.info(f"Exported: {filepath}")
else:
QMessageBox.warning(self, "Export Failed", "Failed to export STL")
diff --git a/uv.lock b/uv.lock
index 8bc99aa..944b686 100644
--- a/uv.lock
+++ b/uv.lock
@@ -638,6 +638,7 @@ dependencies = [
{ name = "pillow" },
{ name = "pygfx" },
{ name = "pyside6" },
+ { name = "python-solvespace" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "wgpu" },
@@ -661,6 +662,7 @@ requires-dist = [
{ name = "pygfx", specifier = ">=0.1.0" },
{ name = "pyside6", specifier = ">=6.4.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
+ { name = "python-solvespace", specifier = ">=3.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
{ name = "scipy", specifier = ">=1.10.0" },
{ name = "wgpu", specifier = ">=0.1.0" },
@@ -2107,6 +2109,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
+[[package]]
+name = "python-solvespace"
+version = "3.0.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/70/d9/edb532941527cfdbd22861ce574a57859952d21132f791a8706fafee9876/python_solvespace-3.0.8.tar.gz", hash = "sha256:c5c132c1151cfa4cc8719474bbbafedd109d7203317e531f9160a37aaae644b0", size = 1231985, upload-time = "2022-11-11T13:18:23.9Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1c/73/cfd7e631395b036052356517c2c8b8e7917308b4c397694da34260f7eff6/python_solvespace-3.0.8-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:bd3366cda1f3bac7c239c3fad98c6eee97c3d9299ee5c3a1ab3e4ea5e112b81a", size = 386490, upload-time = "2022-11-11T13:19:11.237Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/28/7e8ea55da42cd9e0ab9bdc607f5d57270c553baa9c4dab63866bba7c6e40/python_solvespace-3.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:33c150f68c81addf8045e28ffeb28a828fcbbe9ab84cc4a9f1e50d0847b23bfb", size = 243413, upload-time = "2022-11-11T13:19:03.064Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/6a/3d3f3ffa52b4952a4d63506b20d55e23e5bf34dfd096c185cfd4b262d9a2/python_solvespace-3.0.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c899978359d95c274bbcc31405b080517af3839beca98e3342c52a37409bb916", size = 703966, upload-time = "2022-11-11T13:34:03.875Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/4c/0140a197e00be797f3ee26278a9205cccfb668b8c7d7746350bb72bff3c7/python_solvespace-3.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:1191b004298d57936058d9dbdd395c44c1f46d5a9c854e36c507c49c3e6b6382", size = 242810, upload-time = "2022-11-11T13:19:55.656Z" },
+]
+
[[package]]
name = "pytokens"
version = "0.4.1"