diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 9adc0a5..e1ec969 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,13 +4,10 @@
-
+
-
-
-
-
-
+
+
@@ -63,6 +60,7 @@
"Python.occ_renderer.executor": "Run",
"Python.side_fluency.executor": "Run",
"Python.simple_mesh.executor": "Run",
+ "Python.sketch.executor": "Run",
"Python.vtk_widget.executor": "Run",
"Python.vulkan.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true",
@@ -288,7 +286,15 @@
1782768610475
-
+
+
+ 1782928990792
+
+
+
+ 1782928990792
+
+
@@ -328,6 +334,7 @@
-
+
+
\ No newline at end of file
diff --git a/src/fluency/geometry_occ/sketch.py b/src/fluency/geometry_occ/sketch.py
index 1348174..a646d84 100644
--- a/src/fluency/geometry_occ/sketch.py
+++ b/src/fluency/geometry_occ/sketch.py
@@ -74,6 +74,14 @@ class OCCSketch(SketchInterface):
# • clear them as a group when the source face is removed
self._external_entity_ids: set = set()
+ # Centerline entity ids (X and Y reference axes through origin).
+ # These are construction lines that span the sketch and are used
+ # as reference axes for constraining geometry. They are:
+ # • fixed in the solver (never move)
+ # • marked is_construction (excluded from profile detection)
+ # • non-deletable
+ self._centerline_ids: set = set()
+
# Track first point as dragged/fixed for solver stability
self._first_point_id: Optional[int] = None
@@ -259,8 +267,14 @@ class OCCSketch(SketchInterface):
radius: float,
start_point: SketchEntity,
end_point: SketchEntity,
+ sweep: Optional[float] = None,
) -> OCCSketchEntity:
- """Add an arc (tracked only)."""
+ """Add an arc (tracked only).
+
+ *sweep* is the signed angular span in radians (positive = CCW, negative = CW).
+ When *None* the rendering will infer the shortest path between start and end.
+ """
+ import math
entity_id = self._next_id()
center_entity = self._entities.get(center.id)
@@ -274,10 +288,26 @@ class OCCSketch(SketchInterface):
sx, sy = start_entity.geometry
ex, ey = end_entity.geometry
+ # Infer sweep from geometry when not provided.
+ if sweep is None:
+ sa = math.atan2(sy - cy, sx - cx)
+ ea = math.atan2(ey - cy, ex - cx)
+ sweep = ea - sa
+ while sweep > math.pi:
+ sweep -= 2 * math.pi
+ while sweep < -math.pi:
+ sweep += 2 * math.pi
+
entity = OCCSketchEntity(
entity_id=entity_id,
entity_type="arc",
- geometry={"center": (cx, cy), "radius": radius, "start": (sx, sy), "end": (ex, ey)},
+ geometry={
+ "center": (cx, cy),
+ "radius": radius,
+ "start": (sx, sy),
+ "end": (ex, ey),
+ "sweep": sweep,
+ },
)
self._entities[entity_id] = entity
@@ -286,6 +316,7 @@ class OCCSketch(SketchInterface):
"start": start_point.id,
"end": end_point.id,
"radius": radius,
+ "sweep": sweep,
}
return entity
@@ -432,6 +463,67 @@ class OCCSketch(SketchInterface):
"""Return the set of external (underlay) entity ids currently in the sketch."""
return set(self._external_entity_ids)
+ # ── Centerlines (X and Y reference axes) ────────────────────────────
+
+ _CENTERLINE_EXTENT: float = 10000.0 # large enough to span any sketch
+
+ def add_centerlines(self) -> None:
+ """Add X (horizontal) and Y (vertical) centerlines through the origin.
+
+ These construction lines serve as reference axes for constraining
+ sketch geometry. They are:
+ • Fixed in the solver (never move)
+ • Marked is_construction (excluded from profile / face detection)
+ • Non-deletable
+ • Available as constraint targets (snap, coincident, distance,
+ symmetric, horizontal, vertical, parallel, perpendicular)
+
+ The X centerline runs horizontal (left→right) through origin and
+ the Y centerline runs vertical (bottom→top) through origin. Both
+ extend to ``_CENTERLINE_EXTENT`` in both directions so they span
+ any realistic sketch. If centerlines have already been added this
+ is a no-op.
+ """
+ if self._centerline_ids:
+ return
+
+ HALF = self._CENTERLINE_EXTENT
+
+ # Origin point — auto-fixed as the first point in the solver
+ origin = self.add_point(0.0, 0.0)
+ origin.is_construction = True
+
+ # X centerline (horizontal)
+ xl = self.add_point(-HALF, 0.0)
+ xl.is_construction = True
+ xr = self.add_point(HALF, 0.0)
+ xr.is_construction = True
+ xline = self.add_line(xl, xr)
+ xline.is_construction = True
+ self.constrain_horizontal(xline)
+ self.constrain_fixed(xl)
+
+ # Y centerline (vertical)
+ yb = self.add_point(0.0, -HALF)
+ yb.is_construction = True
+ yt = self.add_point(0.0, HALF)
+ yt.is_construction = True
+ yline = self.add_line(yb, yt)
+ yline.is_construction = True
+ self.constrain_vertical(yline)
+ self.constrain_fixed(yb)
+
+ self._centerline_ids = {
+ origin.id, xl.id, xr.id, xline.id,
+ yb.id, yt.id, yline.id,
+ }
+
+ self.solve()
+
+ def get_centerline_ids(self) -> set:
+ """Return the set of centerline entity ids currently in the sketch."""
+ return set(self._centerline_ids)
+
def add_rectangle(
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
) -> List[OCCSketchEntity]:
@@ -838,7 +930,11 @@ class OCCSketch(SketchInterface):
if entity_id in self._external_entity_ids:
continue
center_entity = self._entities.get(center_id)
+ circle_ent = self._entities.get(entity_id)
if center_entity and center_entity.geometry and not center_entity.is_external:
+ # Skip construction circles — they're reference geometry.
+ if circle_ent is not None and circle_ent.is_construction:
+ continue
cx, cy = center_entity.geometry
face_dict = {
"outer": {"type": "circle", "center": (cx, cy), "radius": radius},
@@ -880,13 +976,13 @@ class OCCSketch(SketchInterface):
def get_polygon_points(self) -> List[Point2D]:
"""Get ordered polygon points from connected lines (uses solved positions).
- External (underlay) lines are skipped — they are reference geometry
- only, not part of the sketch profile.
+ External (underlay) and construction lines are skipped — they are
+ reference geometry only, not part of the sketch profile.
"""
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
for entity in self._entities.values():
- if entity.entity_type == "line" and entity.geometry and not entity.is_external:
+ if entity.entity_type == "line" and entity.geometry and not entity.is_external and not entity.is_construction:
p1, p2 = entity.geometry
if p1 not in adjacency:
adjacency[p1] = []
@@ -926,21 +1022,63 @@ class OCCSketch(SketchInterface):
def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
"""Current line segments as world-coordinate tuples (uses solved positions).
- External (underlay) lines are excluded: they're reference geometry
- projected from a 3D face, not user-drawn sketch profile, and must
- not contribute to detect_faces / get_geometry / extrusion.
+ Returns both straight line segments AND tessellated arc segments so
+ that arcs participate in closed-loop / face detection. Construction
+ and external entities are excluded — they're reference geometry and
+ must not affect the sketch profile.
+
+ Tessellation density: roughly 12 segments per π radians of arc sweep,
+ which gives smooth-looking closed loops for face detection.
"""
segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
+
+ # ── Straight line segments ──
for line_id, (sid, eid2) in self._lines.items():
- # Skip external/underlay lines — they live in the solver for
- # constraint referencing, but are not part of the sketch profile.
if line_id in self._external_entity_ids:
continue
+ line_ent = self._entities.get(line_id)
+ if line_ent is not None and line_ent.is_construction:
+ continue
s_ent = self._entities.get(sid)
e_ent = self._entities.get(eid2)
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
segs.append(((float(s_ent.geometry[0]), float(s_ent.geometry[1])),
(float(e_ent.geometry[0]), float(e_ent.geometry[1]))))
+
+ # ── Arc segments (tessellated) ──
+ for arc_id, arc_data in self._arcs.items():
+ arc_ent = self._entities.get(arc_id)
+ if arc_ent is not None and arc_ent.is_construction:
+ continue
+ center_id = arc_data.get("center")
+ start_id = arc_data.get("start")
+ end_id = arc_data.get("end")
+ radius = arc_data.get("radius", 0.0)
+ sweep = arc_data.get("sweep")
+ if sweep is None or radius <= 0:
+ continue
+ c_ent = self._entities.get(center_id)
+ s_ent = self._entities.get(start_id)
+ e_ent = self._entities.get(end_id)
+ if not (c_ent and s_ent and e_ent
+ and c_ent.geometry and s_ent.geometry and e_ent.geometry):
+ continue
+ cx, cy = c_ent.geometry
+ sx, sy = s_ent.geometry
+ start_angle = math.atan2(sy - cy, sx - cx)
+ # ~12 segments per π radians
+ n = max(4, int(abs(sweep) / (math.pi / 12)))
+ for i in range(n):
+ t1 = i / n
+ t2 = (i + 1) / n
+ a1 = start_angle + t1 * sweep
+ a2 = start_angle + t2 * sweep
+ p1 = (cx + radius * math.cos(a1),
+ cy + radius * math.sin(a1))
+ p2 = (cx + radius * math.cos(a2),
+ cy + radius * math.sin(a2))
+ segs.append((p1, p2))
+
return segs
def get_closed_loops(self) -> List[Dict[str, Any]]:
@@ -1240,6 +1378,7 @@ class OCCSketch(SketchInterface):
self._constraint_count = 0
self._constraint_log.clear()
self._external_entity_ids.clear()
+ self._centerline_ids.clear()
self._first_point_id = None
def _prune_log_for(self, removed_ids: set) -> None:
@@ -1259,10 +1398,17 @@ class OCCSketch(SketchInterface):
constraint that referenced it, rebuilds the whole solver system from
the surviving points/lines + pruned log, and re-solves. The line's
endpoint points are NOT removed — only the line segment.
+
+ Note: centerlines (reference axes through origin) cannot be deleted.
"""
if line.id not in self._lines or line.id not in self._entities:
return False
+ # Centerlines are permanent reference axes — refuse deletion.
+ if line.id in self._centerline_ids:
+ logger.debug("Refusing to delete centerline")
+ return False
+
del self._lines[line.id]
if line.id in self._entities:
del self._entities[line.id]
@@ -1298,10 +1444,17 @@ class OCCSketch(SketchInterface):
All constraints that reference the point OR the removed lines are
pruned from the log, the solver is rebuilt from survivors, labels are
re-derived, and the system is re-solved.
+
+ Note: centerlines (reference axes through origin) cannot be deleted.
"""
if point.id not in self._entities or point.id not in self._points:
return False
+ # Centerline points are permanent reference anchors — refuse deletion.
+ if point.id in self._centerline_ids:
+ logger.debug("Refusing to delete centerline point")
+ return False
+
removed_ids: set = {point.id}
# Remove lines that use this point as an endpoint.
removed_line_keys: List[int] = [
@@ -1327,6 +1480,18 @@ class OCCSketch(SketchInterface):
del self._circles[cid]
if cid in self._entities:
del self._entities[cid]
+ # Arcs referencing this point (as centre, start, or end) are invalid.
+ removed_arc_keys: List[int] = [
+ aid for aid, adata in list(self._arcs.items())
+ if adata.get("center") == point.id
+ or adata.get("start") == point.id
+ or adata.get("end") == point.id
+ ]
+ for aid in removed_arc_keys:
+ removed_ids.add(aid)
+ del self._arcs[aid]
+ if aid in self._entities:
+ del self._entities[aid]
self._prune_log_for(removed_ids)
self._rebuild_solver()
diff --git a/src/fluency/main.py b/src/fluency/main.py
index 8ee25b9..34af923 100644
--- a/src/fluency/main.py
+++ b/src/fluency/main.py
@@ -313,6 +313,10 @@ class Sketch2DWidget(QWidget):
self._points: List[OCCSketchEntity] = []
self._lines: List[Tuple[OCCSketchEntity, OCCSketchEntity]] = []
self._circles: List[Tuple[OCCSketchEntity, float]] = []
+ self._arcs: List[Tuple[OCCSketchEntity, float, OCCSketchEntity, OCCSketchEntity, float]] = [] # (center, radius, start_point, end_point, sweep)
+ # Accumulated sweep tracking during arc draw (smoothly follows cursor)
+ self._arc_accum_sweep: float = 0.0
+ self._arc_prev_angle: Optional[float] = None
self._draw_buffer: List[QPoint] = []
self._hovered_point: Optional[QPoint] = None
@@ -367,6 +371,12 @@ class Sketch2DWidget(QWidget):
self._move_orig_positions: Dict[int, Tuple[float, float]] = {}
self._move_active: bool = False
+ # Auto-constraint tracking on snap
+ self._snap_point_target: Optional[OCCSketchEntity] = None
+ self._snap_line_target: Optional[OCCSketchEntity] = None
+ self._snap_horizontal: bool = False
+ self._snap_vertical: bool = False
+
self.setFocusPolicy(Qt.StrongFocus)
self._setup_ui()
@@ -576,6 +586,7 @@ class Sketch2DWidget(QWidget):
self._points = []
self._lines = []
self._circles = []
+ self._arcs = []
self._selected_entities = []
if self._sketch:
# Collect points in creation order (user + external).
@@ -593,6 +604,18 @@ class Sketch2DWidget(QWidget):
c_ent = self._sketch._entities.get(cid)
if c_ent:
self._circles.append((c_ent, r))
+ # Rebuild arcs: arc data is stored as {"center": cid, "start": sid, "end": eid2, "radius": r}
+ for eid, arc_data in self._sketch._arcs.items():
+ center_id = arc_data.get("center")
+ start_id = arc_data.get("start")
+ end_id = arc_data.get("end")
+ radius = arc_data.get("radius", 0)
+ sweep = arc_data.get("sweep") # may be None (legacy arcs)
+ c_ent = self._sketch._entities.get(center_id) if center_id else None
+ s_ent = self._sketch._entities.get(start_id) if start_id else None
+ e_ent = self._sketch._entities.get(end_id) if end_id else None
+ if c_ent and s_ent and e_ent:
+ self._arcs.append((c_ent, radius, s_ent, e_ent, sweep))
@staticmethod
def _is_external(entity: Any) -> bool:
@@ -604,17 +627,46 @@ class Sketch2DWidget(QWidget):
"""
return bool(entity is not None and getattr(entity, "is_external", False))
+ def _is_centerline(self, entity: Any) -> bool:
+ """True if an entity is a centerline reference axis (X or Y).
+
+ Centerlines are the permanent X (horizontal) and Y (vertical)
+ construction lines through the sketch origin. They are rendered
+ with distinct colors (red for X, green for Y) as viewport-spanning
+ dashed lines, and are protected from deletion.
+ """
+ if entity is None or self._sketch is None:
+ return False
+ return entity.id in self._sketch._centerline_ids
+
def get_sketch(self) -> Optional[OCCSketch]:
return self._sketch
def create_sketch(self) -> OCCSketch:
self._sketch = OCCSketch()
- self._points = []
- self._lines = []
- self._circles = []
+ self._sketch.add_centerlines()
+ # Sync widget tracking lists from sketch so centerlines (and any
+ # future auto-created entities) are immediately pickable.
+ self._rebuild_from_sketch()
self.update()
return self._sketch
+ def _ensure_sketch_with_centerlines(self) -> None:
+ """Create a sketch with centerlines if none exists yet.
+
+ Drawing handlers (line, rect, circle, arc, slot) auto-create an
+ OCCSketch when the user starts drawing without first pressing
+ WP Origin — but the old code used ``OCCSketch()`` directly,
+ bypassing ``create_sketch()`` and leaving the sketch without
+ reference axes. This helper ensures centerlines are always
+ present, whether the sketch was created via WP Origin, WP Face,
+ or on-the-fly by a drawing tool.
+ """
+ if self._sketch is None:
+ self._sketch = OCCSketch()
+ self._sketch.add_centerlines()
+ self._rebuild_from_sketch()
+
def reset_buffers(self):
self._draw_buffer = []
self._dynamic_line_end = None
@@ -630,6 +682,12 @@ class Sketch2DWidget(QWidget):
self._move_orig_positions = {}
self._move_active = False
self._hovered_face = None
+ self._snap_point_target = None
+ self._snap_line_target = None
+ self._snap_horizontal = False
+ self._snap_vertical = False
+ self._arc_accum_sweep = 0.0
+ self._arc_prev_angle = None
def set_mode(self, mode: Optional[str]):
self._mode = mode
@@ -638,6 +696,8 @@ class Sketch2DWidget(QWidget):
self._selected_entities = []
self._hovered_constraint_idx = -1
self._hovered_face = None
+ self._arc_accum_sweep = 0.0
+ self._arc_prev_angle = None
# Cancel an ongoing move when switching modes
if self._move_active:
self._clear_move_state()
@@ -760,8 +820,15 @@ class Sketch2DWidget(QWidget):
def _apply_all_snaps(self, pos: QPoint, start: Optional[QPoint] = None) -> QPoint:
result = pos
+ # Reset auto-constraint tracking
+ self._snap_point_target = None
+ self._snap_line_target = None
+ self._snap_horizontal = False
+ self._snap_vertical = False
+
point_snap = self._find_nearest_point(pos)
if point_snap:
+ self._snap_point_target = self._find_nearest_point_entity(pos)
return self._world_to_screen(point_snap)
if self._snap_mode.get("mpoint", False):
mid_snap = self._find_midpoint_snap(pos)
@@ -772,10 +839,12 @@ class Sketch2DWidget(QWidget):
horiz = self._apply_horizontal_snap(start, result)
if abs(result.y() - start.y()) < 10:
result = horiz
+ self._snap_horizontal = True
if self._snap_mode.get("vert", False):
vert = self._apply_vertical_snap(start, result)
if abs(result.x() - start.x()) < 10:
result = vert
+ self._snap_vertical = True
if self._snap_mode.get("angle", False):
result = self._apply_angle_snap(start, result)
# Grid snap as a final fallback (only when nothing else applied)
@@ -790,7 +859,7 @@ class Sketch2DWidget(QWidget):
if not self._snap_mode.get("grid", False):
return None
world = self._screen_to_world(pos)
- grid_step = 50 # world units per grid cell (matches paintEvent grid)
+ grid_step = 10 # 10mm world units per grid cell (matches 10mm minor grid)
gx = round(world.x() / grid_step) * grid_step
gy = round(world.y() / grid_step) * grid_step
return QPoint(gx, gy)
@@ -810,9 +879,16 @@ class Sketch2DWidget(QWidget):
"""
pos = mouse_screen
+ # Reset auto-constraint tracking
+ self._snap_point_target = None
+ self._snap_line_target = None
+ self._snap_horizontal = False
+ self._snap_vertical = False
+
# Point snap (excluding moved points)
if self._snap_mode.get("point", False):
nearest = None
+ nearest_entity = None
min_dist = self._snap_distance
for entity in self._points:
if entity.id in exclude_ids or not entity.geometry:
@@ -823,7 +899,9 @@ class Sketch2DWidget(QWidget):
if d < min_dist:
min_dist = d
nearest = sp
+ nearest_entity = entity
if nearest is not None:
+ self._snap_point_target = nearest_entity
return nearest
# Midpoint snap (excluding lines whose both endpoints are being moved)
@@ -889,6 +967,11 @@ class Sketch2DWidget(QWidget):
(so the user can add a distance / horizontal / vertical / parallel /
perpendicular / midpoint constraint against them) and skipped when
the underlay is hidden.
+
+ Centerlines are treated as infinite reference axes — the hit test
+ uses perpendicular distance to the infinite line (no segment
+ clamping) with a zoom-adjusted tolerance so they are pickable at
+ any zoom level.
"""
for p1_ent, p2_ent in self._lines:
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
@@ -898,18 +981,35 @@ class Sketch2DWidget(QWidget):
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)
+
+ # Compute perpendicular distance to the infinite line.
+ line_len_sq = dx * dx + dy * dy
+ # Perpendicular distance: |(P - P1) × (P2 - P1)| / |P2 - P1|
+ perp_dist = abs(dx * (y1 - world_pos.y()) - dy * (x1 - world_pos.x())) / math.sqrt(line_len_sq)
+
+ is_cl = line_ent is not None and self._is_centerline(line_ent)
+ if is_cl:
+ # Centerlines are infinite reference axes: use the
+ # perpendicular distance directly (no segment clamping).
+ # Zoom-adjusted tolerance: at least 20 pixels in screen space
+ # so the axes are easily pickable regardless of zoom level.
+ tol = max(10.0, 20.0 / max(self._zoom, 0.01))
+ if perp_dist < tol:
+ return (p1_ent, p2_ent)
+ else:
+ # Regular lines: clamp the projection to the segment
+ # and check distance to that clamped point.
+ t = ((world_pos.x() - x1) * dx + (world_pos.y() - y1) * dy) / line_len_sq
+ t = max(0.0, min(1.0, t))
+ proj_x = x1 + t * dx
+ proj_y = y1 + t * dy
+ seg_dist = math.sqrt((world_pos.x() - proj_x)**2 + (world_pos.y() - proj_y)**2)
+ if seg_dist < 10: # tolerance
+ return (p1_ent, p2_ent)
return None
def _find_line_sketch_entity(
@@ -1245,6 +1345,8 @@ class Sketch2DWidget(QWidget):
self._draw_buffer = []
self._dynamic_line_end = None
self._selected_entities = []
+ self._arc_accum_sweep = 0.0
+ self._arc_prev_angle = None
self.constrain_done.emit()
self.update()
return
@@ -1255,7 +1357,8 @@ class Sketch2DWidget(QWidget):
# ① Tight point check (4 px) — a deliberate grab on a point.
tight_ent = self._find_nearest_point_entity(event.pos(), max_distance=4)
if (tight_ent is not None and tight_ent.geometry
- and not self._is_external(tight_ent)):
+ and not self._is_external(tight_ent)
+ and not self._is_centerline(tight_ent)):
x, y = tight_ent.geometry
moving = self._collect_connected_points(tight_ent)
orig: Dict[int, Tuple[float, float]] = {}
@@ -1289,7 +1392,8 @@ class Sketch2DWidget(QWidget):
# pick (the desired behavior) rather than a no-op.
target = self._find_move_target(event.pos())
if (target is not None
- and not self._is_external(target[0])):
+ and not self._is_external(target[0])
+ and not self._is_centerline(target[0])):
anchor_ent, anchor_world = target
moving = self._collect_connected_points(anchor_ent)
orig: Dict[int, Tuple[float, float]] = {}
@@ -1305,7 +1409,7 @@ class Sketch2DWidget(QWidget):
return
snapped_pos = self._apply_all_snaps(
- event.pos(), self._draw_buffer[0] if self._draw_buffer else None
+ event.pos(), self._world_to_screen(self._draw_buffer[0]) if self._draw_buffer else None
)
world_snapped = (
self._screen_to_world(snapped_pos) if snapped_pos != event.pos() else world_pos
@@ -1317,6 +1421,10 @@ class Sketch2DWidget(QWidget):
self._handle_rectangle_click(world_snapped)
elif self._mode == "circle":
self._handle_circle_click(world_snapped)
+ elif self._mode == "arc":
+ self._handle_arc_click(world_snapped)
+ elif self._mode == "slot":
+ self._handle_slot_click(world_snapped)
elif self._mode == "select":
self._handle_select_click(world_snapped)
elif self._mode == "constrain_coincident":
@@ -1365,12 +1473,49 @@ class Sketch2DWidget(QWidget):
world_pos = self._screen_to_world(event.pos())
- if self._draw_buffer and self._mode in ["line", "rectangle", "circle"]:
+ if self._draw_buffer and self._mode in ["line", "rectangle", "circle", "arc", "slot"]:
snapped = self._apply_all_snaps(
event.pos(), self._world_to_screen(self._draw_buffer[0])
)
self._dynamic_line_end = self._screen_to_world(snapped)
+ # Project arc end onto the circle so the preview snaps to
+ # the correct radius (matching the start point's distance).
+ if self._mode == "arc" and len(self._draw_buffer) == 2:
+ cw = self._draw_buffer[0]
+ sw = self._draw_buffer[1]
+ ew = self._dynamic_line_end
+ cx_f, cy_f = cw.x(), cw.y()
+ sx_f, sy_f = sw.x(), sw.y()
+ r = math.sqrt((sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2) if (
+ (sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2 > 0
+ ) else 1.0
+ dx = ew.x() - cx_f
+ dy = ew.y() - cy_f
+ d = math.sqrt(dx * dx + dy * dy)
+ if d > 0:
+ self._dynamic_line_end = QPoint(
+ int(round(cx_f + dx / d * r)),
+ int(round(cy_f + dy / d * r)),
+ )
+ else:
+ self._dynamic_line_end = sw
+
+ # Accumulated sweep tracking for arc (after click 2, before click 3)
+ if self._mode == "arc" and len(self._draw_buffer) == 2:
+ cw = self._draw_buffer[0]
+ ew = self._dynamic_line_end
+ current_angle = math.atan2(ew.y() - cw.y(), ew.x() - cw.x())
+ if self._arc_prev_angle is not None:
+ delta = current_angle - self._arc_prev_angle
+ # Normalise delta to [-PI, PI] (handle atan2 wrap-around)
+ while delta > math.pi:
+ delta -= 2 * math.pi
+ while delta < -math.pi:
+ delta += 2 * math.pi
+ self._arc_accum_sweep += delta
+ self._arc_prev_angle = current_angle
+
# Constraint-tag hover takes priority — if the cursor is over a tag,
# we highlight it for delete and skip point/line hover this move.
self._constraint_tags = self._compute_constraint_tags()
@@ -1472,6 +1617,12 @@ class Sketch2DWidget(QWidget):
logger.warning("Constraint violation while moving element; reverting")
self._sketch.set_positions(original)
self._solve_and_sync()
+ self._snap_point_target = None
+ else:
+ # Auto-constrain: point snap during move → coincident
+ if self._snap_point_target is not None and self._move_anchor is not None:
+ self._sketch.constrain_coincident(self._move_anchor, self._snap_point_target)
+ self._solve_and_sync()
# Snap modes are honoured during the move (see _apply_move_snaps
# in mouseMoveEvent), so the committed positions are already snapped.
self._clear_move_state()
@@ -1511,6 +1662,15 @@ class Sketch2DWidget(QWidget):
self._delete_hovered_point()
event.accept()
return
+
+ # C key toggles the hovered line between normal and construction mode.
+ if event.key() == Qt.Key_C:
+ if self._mode in ("select", None) and not self._draw_buffer and self._sketch is not None:
+ if self._hovered_line_entity is not None:
+ self._toggle_hovered_line_construction()
+ event.accept()
+ return
+
super().keyPressEvent(event)
def _delete_hovered_constraint(self):
@@ -1538,6 +1698,10 @@ class Sketch2DWidget(QWidget):
if getattr(line_ent, "is_external", False):
logger.debug("Refusing to delete external (underlay) line")
return
+ # Centerlines are permanent reference axes — refuse deletion.
+ if self._is_centerline(line_ent):
+ logger.debug("Refusing to delete centerline")
+ return
ok = self._sketch.delete_line(line_ent)
logger.info(f"Deleted line {line_ent.id}; recompute solved={ok}")
# Refresh widget tracking from the pruned sketch and sync solved positions.
@@ -1564,6 +1728,10 @@ class Sketch2DWidget(QWidget):
if getattr(point_ent, "is_external", False):
logger.debug("Refusing to delete external (underlay) point")
return
+ # Centerline points are permanent reference anchors — refuse deletion.
+ if self._is_centerline(point_ent):
+ logger.debug("Refusing to delete centerline point")
+ return
ok = self._sketch.delete_point(point_ent)
logger.info(f"Deleted point {point_ent.id}; recompute solved={ok}")
self._rebuild_from_sketch()
@@ -1576,17 +1744,54 @@ class Sketch2DWidget(QWidget):
self.sketch_updated.emit()
self.update()
+ def _toggle_hovered_line_construction(self) -> None:
+ """Toggle the hovered line between normal and construction mode.
+
+ The line entity's ``is_construction`` flag is flipped. The line
+ entity is then marked as the sole authority for its construction
+ state — its endpoint points are NOT toggled, so that other lines
+ sharing the same endpoint are unaffected. The paint code checks
+ the line entity's flag in addition to the endpoint flags.
+ """
+ line_ent = self._hovered_line_entity
+ if line_ent is None or self._sketch is None:
+ return
+ # External (underlay) lines are reference geometry from the source
+ # face — they can't be toggled individually.
+ if getattr(line_ent, "is_external", False):
+ logger.debug("Refusing to toggle external (underlay) line")
+ return
+ # Centerlines are permanent reference axes — refuse toggling.
+ if self._is_centerline(line_ent):
+ logger.debug("Refusing to toggle centerline")
+ return
+ # Flip the construction flag on the line entity itself.
+ new_val = not line_ent.is_construction
+ line_ent.is_construction = new_val
+ logger.info(
+ f"Toggled line {line_ent.id} construction -> {new_val}"
+ )
+ self._hovered_line = None
+ self._hovered_line_entity = None
+ self._solve_and_sync()
+ self.sketch_updated.emit()
+ self.update()
+
# ─── Drawing handlers ─────────────────────────────────────────────────
def _handle_line_click(self, pos: QPoint):
- if not self._sketch:
- self._sketch = OCCSketch()
+ self._ensure_sketch_with_centerlines()
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)
+
+ # Auto-constrain: point snap → coincident on start point
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(point, self._snap_point_target)
+ self._solve_and_sync()
else:
point = self._sketch.add_point(pos.x(), pos.y())
point.is_construction = self._is_construct
@@ -1595,6 +1800,23 @@ 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]))
+
+ # Auto-constrain: point snap → coincident on end point
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(self._points[-1], self._snap_point_target)
+
+ # Auto-constrain: detect horizontal / vertical from geometry
+ if self._snap_mode.get("horiz", False) or self._snap_mode.get("vert", False):
+ p1_geom = self._points[-2].geometry
+ p2_geom = self._points[-1].geometry
+ if p1_geom is not None and p2_geom is not None:
+ x1, y1 = p1_geom
+ x2, y2 = p2_geom
+ if self._snap_mode.get("horiz", False) and abs(y1 - y2) < 1e-6:
+ self._sketch.constrain_horizontal(line)
+ elif self._snap_mode.get("vert", False) and abs(x1 - x2) < 1e-6:
+ self._sketch.constrain_vertical(line)
+
self._solve_and_sync()
self._draw_buffer = [pos]
@@ -1603,8 +1825,7 @@ class Sketch2DWidget(QWidget):
self.update()
def _handle_rectangle_click(self, pos: QPoint):
- if not self._sketch:
- self._sketch = OCCSketch()
+ self._ensure_sketch_with_centerlines()
if not self._draw_buffer:
self._draw_buffer.append(pos)
@@ -1624,9 +1845,33 @@ class Sketch2DWidget(QWidget):
self._points.append(pt)
pts.append(pt)
+ line_entities = []
for i in range(4):
line = self._sketch.add_line(pts[i], pts[(i + 1) % 4])
self._lines.append((pts[i], pts[(i + 1) % 4]))
+ line_entities.append(line)
+
+ # Auto-constrain: point snap → coincident on nearest corner
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(pts[0], self._snap_point_target)
+
+ # Auto-constrain: detect horizontal / vertical from geometry
+ if self._snap_mode.get("horiz", False):
+ for idx in (0, 2):
+ p_start = pts[idx]
+ p_end = pts[(idx + 1) % 4]
+ if p_start.geometry and p_end.geometry:
+ dx = p_end.geometry[0] - p_start.geometry[0]
+ if abs(dx) > 1e-6:
+ self._sketch.constrain_horizontal(line_entities[idx])
+ if self._snap_mode.get("vert", False):
+ for idx in (1, 3):
+ p_start = pts[idx]
+ p_end = pts[(idx + 1) % 4]
+ if p_start.geometry and p_end.geometry:
+ dy = p_end.geometry[1] - p_start.geometry[1]
+ if abs(dy) > 1e-6:
+ self._sketch.constrain_vertical(line_entities[idx])
self._solve_and_sync()
self._draw_buffer = []
@@ -1637,14 +1882,18 @@ class Sketch2DWidget(QWidget):
self.update()
def _handle_circle_click(self, pos: QPoint):
- if not self._sketch:
- self._sketch = OCCSketch()
+ self._ensure_sketch_with_centerlines()
if not self._draw_buffer:
center = self._sketch.add_point(pos.x(), pos.y())
center.is_construction = self._is_construct
self._points.append(center)
self._draw_buffer.append(pos)
+
+ # Auto-constrain: point snap → coincident on center
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(center, self._snap_point_target)
+ self._solve_and_sync()
else:
center = self._points[-1]
cx, cy = center.geometry if center.geometry else (0, 0)
@@ -1659,6 +1908,253 @@ class Sketch2DWidget(QWidget):
self.sketch_updated.emit()
self.update()
+ def _handle_arc_click(self, pos: QPoint):
+ """Three-click arc: center → start point (radius) → end point (sweep)."""
+ self._ensure_sketch_with_centerlines()
+
+ if not self._draw_buffer:
+ # Click 1: place center point
+ center = self._sketch.add_point(pos.x(), pos.y())
+ center.is_construction = self._is_construct
+ self._points.append(center)
+ self._draw_buffer.append(pos)
+
+ # Auto-constrain: point snap → coincident on center
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(center, self._snap_point_target)
+ self._solve_and_sync()
+ elif len(self._draw_buffer) == 1:
+ # Click 2: place start point (defines radius + start angle)
+ start = self._sketch.add_point(pos.x(), pos.y())
+ start.is_construction = self._is_construct
+ self._points.append(start)
+ self._draw_buffer.append(pos)
+
+ # Initialise accumulated sweep tracking
+ cx = self._draw_buffer[0].x()
+ cy = self._draw_buffer[0].y()
+ self._arc_prev_angle = math.atan2(pos.y() - cy, pos.x() - cx)
+ self._arc_accum_sweep = 0.0
+
+ # Auto-constrain: point snap → coincident on start
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(start, self._snap_point_target)
+
+ self._solve_and_sync()
+ else:
+ # Click 3: place end point, then create arc
+ center = self._points[-2]
+ start_point = self._points[-1]
+
+ cx, cy = center.geometry if center.geometry else (0, 0)
+ sx, sy = start_point.geometry if start_point.geometry else (0, 0)
+ radius = math.sqrt((sx - cx) ** 2 + (sy - cy) ** 2)
+
+ # Project the click onto the circle so the end point
+ # stays exactly on the same radius as the start.
+ dx = pos.x() - cx
+ dy = pos.y() - cy
+ dist = math.sqrt(dx * dx + dy * dy)
+ if dist > 0:
+ ex = cx + dx / dist * radius
+ ey = cy + dy / dist * radius
+ else:
+ ex = cx + radius
+ ey = cy
+
+ end = self._sketch.add_point(ex, ey)
+ end.is_construction = self._is_construct
+ self._points.append(end)
+
+ # Use accumulated sweep when significant; fall back to computed.
+ sweep = self._arc_accum_sweep
+ if abs(sweep) < 0.01:
+ # Barely moved — compute shortest-path from geometry
+ # using the projected end position.
+ ea = math.atan2(ey - cy, ex - cx)
+ sa = math.atan2(sy - cy, sx - cx)
+ sweep = ea - sa
+ while sweep > math.pi:
+ sweep -= 2 * math.pi
+ while sweep < -math.pi:
+ sweep += 2 * math.pi
+
+ if radius > 0:
+ self._sketch.add_arc(center, radius, start_point, end, sweep=sweep)
+ self._arcs.append((center, radius, start_point, end, sweep))
+
+ # Auto-constrain: point snap → coincident on end point
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(end, self._snap_point_target)
+
+ self._solve_and_sync()
+
+ self._draw_buffer = []
+ self._mode = None
+ self.constrain_done.emit()
+
+ self.sketch_updated.emit()
+ self.update()
+
+ def _handle_slot_click(self, pos: QPoint):
+ """Three-click slot: center1 → center2 (centerline) → width radius.
+
+ Builds two semicircular arcs and two connecting lines forming a
+ closed slot profile suitable for extrusion.
+ """
+ self._ensure_sketch_with_centerlines()
+
+ if not self._draw_buffer:
+ # Click 1: place first arc center C1.
+ c1 = self._sketch.add_point(pos.x(), pos.y())
+ c1.is_construction = self._is_construct
+ self._points.append(c1)
+ self._draw_buffer.append(pos)
+
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(c1, self._snap_point_target)
+ self._solve_and_sync()
+
+ self.sketch_updated.emit()
+ self.update()
+ return
+
+ if len(self._draw_buffer) == 1:
+ # Click 2: place second arc center C2 (defines centerline).
+ c2 = self._sketch.add_point(pos.x(), pos.y())
+ c2.is_construction = self._is_construct
+ self._points.append(c2)
+ self._draw_buffer.append(pos)
+
+ if self._snap_point_target is not None:
+ self._sketch.constrain_coincident(c2, self._snap_point_target)
+
+ self._solve_and_sync()
+ self.sketch_updated.emit()
+ self.update()
+ return
+
+ # Click 3: compute radius from perpendicular distance to centerline
+ # and build the full slot profile.
+ c1_pos = self._draw_buffer[0]
+ c2_pos = self._draw_buffer[1]
+ c1_ent = self._points[-2]
+ c2_ent = self._points[-1]
+
+ dx_c = c2_pos.x() - c1_pos.x()
+ dy_c = c2_pos.y() - c1_pos.y()
+ L_sq = dx_c * dx_c + dy_c * dy_c
+
+ if L_sq < 1.0:
+ # Degenerate: centers on top of each other → draw a circle
+ # using the midpoint-to-cursor distance as radius.
+ import math as _m
+ r_fallback = _m.hypot(pos.x() - c1_pos.x(), pos.y() - c1_pos.y())
+ if r_fallback > 0:
+ self._sketch.add_circle(c1_ent, r_fallback)
+ self._circles.append((c1_ent, r_fallback))
+ self._draw_buffer = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.sketch_updated.emit()
+ self.update()
+ return
+
+ # Perpendicular distance from cursor to the centerline C1↔C2.
+ import math as _m
+ r = abs((pos.x() - c1_pos.x()) * dy_c
+ - (pos.y() - c1_pos.y()) * dx_c) / _m.sqrt(L_sq)
+ if r < 0.5:
+ r = 0.5 # minimum radius so the slot doesn't collapse
+
+ L = _m.sqrt(L_sq)
+ alpha = _m.atan2(dy_c, dx_c)
+ # Unit perpendicular (rotate 90° CCW)
+ perp_x = -dy_c / L
+ perp_y = dx_c / L
+
+ # Four corner points (world coords)
+ def corner(cx, cy, sign):
+ return QPoint(
+ int(round(cx + sign * r * perp_x)),
+ int(round(cy + sign * r * perp_y)),
+ )
+
+ # Top / bottom are relative to the perpendicular direction.
+ # Perp points "up" from the centreline; -perp points "down".
+ t1 = corner(c1_pos.x(), c1_pos.y(), 1) # C1 + perp
+ b1 = corner(c1_pos.x(), c1_pos.y(), -1) # C1 - perp
+ t2 = corner(c2_pos.x(), c2_pos.y(), 1) # C2 + perp
+ b2 = corner(c2_pos.x(), c2_pos.y(), -1) # C2 - perp
+
+ # Create point entities
+ pt1 = self._sketch.add_point(t1.x(), t1.y())
+ pt1.is_construction = self._is_construct
+ self._points.append(pt1)
+ pb1 = self._sketch.add_point(b1.x(), b1.y())
+ pb1.is_construction = self._is_construct
+ self._points.append(pb1)
+ pt2 = self._sketch.add_point(t2.x(), t2.y())
+ pt2.is_construction = self._is_construct
+ self._points.append(pt2)
+ pb2 = self._sketch.add_point(b2.x(), b2.y())
+ pb2.is_construction = self._is_construct
+ self._points.append(pb2)
+
+ # Auto-constrain: coincident on each corner point if it snaps onto
+ # an existing point entity (e.g. vertex of another shape).
+ for corner_pt in (pt1, pb1, pt2, pb2):
+ if corner_pt.geometry is not None:
+ cgx, cgy = corner_pt.geometry
+ snap_target = self._get_point_entity_at(
+ QPoint(int(round(cgx)), int(round(cgy)))
+ )
+ if snap_target is not None and snap_target.id != corner_pt.id:
+ self._sketch.constrain_coincident(corner_pt, snap_target)
+
+ # Arc 1 (at C1): exterior sweep from top to bottom (CCW)
+ arc1_ent = None
+ arc2_ent = None
+ if r > 0:
+ arc1_ent = self._sketch.add_arc(c1_ent, r, pt1, pb1, sweep=_m.pi)
+ self._arcs.append((c1_ent, r, pt1, pb1, _m.pi))
+
+ # Arc 2 (at C2): exterior sweep from bottom to top (CCW)
+ arc2_ent = self._sketch.add_arc(c2_ent, r, pb2, pt2, sweep=_m.pi)
+ self._arcs.append((c2_ent, r, pb2, pt2, _m.pi))
+
+ # Lines connecting the arc endpoints
+ line1 = self._sketch.add_line(pb1, pb2) # bottom line
+ self._lines.append((pb1, pb2))
+ line2 = self._sketch.add_line(pt2, pt1) # top line
+ self._lines.append((pt2, pt1))
+
+ # Auto-constrain: horizontal / vertical on the two slot lines
+ if self._snap_mode.get("horiz", False) or self._snap_mode.get("vert", False):
+ for line_ent, pa, pb in [(line1, pb1, pb2), (line2, pt2, pt1)]:
+ if pa.geometry and pb.geometry:
+ x1, y1 = pa.geometry
+ x2, y2 = pb.geometry
+ if self._snap_mode.get("horiz", False) and abs(y1 - y2) < 1e-6:
+ self._sketch.constrain_horizontal(line_ent)
+ elif self._snap_mode.get("vert", False) and abs(x1 - x2) < 1e-6:
+ self._sketch.constrain_vertical(line_ent)
+
+ # Auto-constrain: the two slot lines are always parallel
+ self._sketch.constrain_parallel(line1, line2)
+
+ # Auto-constrain: the two arcs have equal radius
+ if arc1_ent is not None and arc2_ent is not None:
+ self._sketch.constrain_equal_radius(arc1_ent, arc2_ent)
+
+ self._solve_and_sync()
+
+ self._draw_buffer = []
+ self._mode = None
+ self.constrain_done.emit()
+ self.sketch_updated.emit()
+ self.update()
+
def _handle_select_click(self, pos: QPoint):
"""Handle select mode - find and highlight points/lines for selection."""
# Check if clicking on an existing point
@@ -1950,23 +2446,77 @@ class Sketch2DWidget(QWidget):
painter.setRenderHint(QPainter.Antialiasing)
painter.fillRect(self.rect(), QColor("#1e1e2e"))
- # ── Grid ──
- pen = QPen(QColor("#45475a"), 1)
- painter.setPen(pen)
- grid_size = 50 * self._zoom
- 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(oy, self.height(), max(1, int(grid_size))):
- painter.drawLine(0, y, self.width(), y)
+ # ── Grid (fixed 10mm world-units spacing) ──
+ # Minor grid: 10 world-unit (10mm) spacing.
+ # Major grid: 100 world-unit (100mm) spacing drawn bolder.
+ # Grid lines are drawn in WORLD space so they keep their meaning
+ # regardless of zoom. When zoomed out too far to show 10mm lines
+ # clearly, only the 100mm major grid is drawn.
+ MIN_PX_SPACING = 6.0 # skip a grid level if screen spacing is below this
+ grid_10 = 10 # 10mm minor grid
+ grid_100 = 100 # 100mm major grid
- # ── 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)
+ # Compute screen-space spacing
+ px_10 = grid_10 * self._zoom
+ px_100 = grid_100 * self._zoom
+
+ # Viewport bounds in world coordinates
+ top_left = self._screen_to_world(QPoint(0, 0))
+ bottom_right = self._screen_to_world(QPoint(self.width(), self.height()))
+
+ pen_major = QPen(QColor("#45475a"), 1)
+ pen_minor = QPen(QColor("#3a3a4e"), 0.5)
+
+ # ── Draw 100mm major grid (always if visible) ──
+ if px_100 >= MIN_PX_SPACING:
+ start_x = math.floor(min(top_left.x(), bottom_right.x()) / grid_100) * grid_100
+ end_x = math.ceil(max(top_left.x(), bottom_right.x()) / grid_100) * grid_100
+ start_y = math.floor(min(top_left.y(), bottom_right.y()) / grid_100) * grid_100
+ end_y = math.ceil(max(top_left.y(), bottom_right.y()) / grid_100) * grid_100
+ painter.setPen(pen_major)
+ wx = start_x
+ while wx <= end_x:
+ sx = int(wx * self._zoom + self.width() / 2 + self._offset.x())
+ painter.drawLine(sx, 0, sx, self.height())
+ wx += grid_100
+ wy = start_y
+ while wy <= end_y:
+ sy = int(self.height() / 2 - wy * self._zoom + self._offset.y())
+ painter.drawLine(0, sy, self.width(), sy)
+ wy += grid_100
+
+ # ── Draw 10mm minor grid (only when wide enough spacing) ──
+ if px_10 >= MIN_PX_SPACING:
+ start_x = math.floor(min(top_left.x(), bottom_right.x()) / grid_10) * grid_10
+ end_x = math.ceil(max(top_left.x(), bottom_right.x()) / grid_10) * grid_10
+ start_y = math.floor(min(top_left.y(), bottom_right.y()) / grid_10) * grid_10
+ end_y = math.ceil(max(top_left.y(), bottom_right.y()) / grid_10) * grid_10
+ painter.setPen(pen_minor)
+ wx = start_x
+ while wx <= end_x:
+ sx = int(wx * self._zoom + self.width() / 2 + self._offset.x())
+ painter.drawLine(sx, 0, sx, self.height())
+ wx += grid_10
+ wy = start_y
+ while wy <= end_y:
+ sy = int(self.height() / 2 - wy * self._zoom + self._offset.y())
+ painter.drawLine(0, sy, self.width(), sy)
+ wy += grid_10
+
+ # ── Centerlines (X and Y reference axes through origin) ──
+ # X centerline = horizontal (red dashed), Y centerline = vertical (green dashed).
+ # Both span the full viewport so they are always visible as reference guides.
+ origin_sc = self._world_to_screen(QPoint(0, 0))
+ # X centerline (red) — horizontal through origin
+ painter.setPen(QPen(QColor("#f38ba8"), 1.5, Qt.DashLine))
+ painter.drawLine(0, origin_sc.y(), self.width(), origin_sc.y())
+ # Y centerline (green) — vertical through origin
+ painter.setPen(QPen(QColor("#a6e3a1"), 1.5, Qt.DashLine))
+ painter.drawLine(origin_sc.x(), 0, origin_sc.x(), self.height())
+ # Origin intersection point (small ring)
+ painter.setPen(QPen(QColor("#cdd6f4"), 1))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawEllipse(origin_sc, 3, 3)
# ── Source-face underlay fill (sketch-on-surface) ──
# The underlay is now drawn as real construction-line entities
@@ -1993,6 +2543,11 @@ class Sketch2DWidget(QWidget):
# the underlay is hidden. Skip them here to avoid double draw.
if self._is_external(entity):
continue
+ # Centerline points are rendered as part of the viewport-spanning
+ # axes above; skip them here to avoid double-drawing tiny dots
+ # at the endpoints far out of view.
+ if self._is_centerline(entity):
+ continue
if entity.geometry:
x, y = entity.geometry
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
@@ -2039,13 +2594,17 @@ class Sketch2DWidget(QWidget):
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
if self._is_external(line_ent):
continue
+ # Centerlines are rendered as viewport-spanning axes above;
+ # skip the finite-endpoint version here.
+ if line_ent is not None and self._is_centerline(line_ent):
+ continue
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))))
- is_construction = p1_ent.is_construction or p2_ent.is_construction
+ is_construction = p1_ent.is_construction or p2_ent.is_construction or (line_ent is not None and line_ent.is_construction)
if is_construction:
painter.setPen(QPen(QColor("#6c7086"), 1, Qt.DashLine))
else:
@@ -2077,6 +2636,39 @@ class Sketch2DWidget(QWidget):
painter.setBrush(Qt.NoBrush)
painter.drawEllipse(sc, int(sr), int(sr))
+ # ── Arcs ──
+ for arc_item in self._arcs:
+ center_ent, radius, start_ent, end_ent, sweep = arc_item[:5]
+ if not (center_ent.geometry and start_ent.geometry and end_ent.geometry):
+ continue
+ cx, cy = center_ent.geometry
+ sx, sy = start_ent.geometry
+
+ sc = self._world_to_screen(QPoint(int(round(cx)), int(round(cy))))
+ sr = int(radius * self._zoom)
+
+ # Use stored sweep if available; fall back to shortest-path.
+ if sweep is None:
+ ex, ey = end_ent.geometry
+ sa = math.atan2(sy - cy, sx - cx)
+ ea = math.atan2(ey - cy, ex - cx)
+ sweep = ea - sa
+ while sweep > math.pi:
+ sweep -= 2 * math.pi
+ while sweep < -math.pi:
+ sweep += 2 * math.pi
+
+ start_angle = math.atan2(sy - cy, sx - cx)
+
+ # QPainter: 1/16 degree, positive = CCW; 0° = 3 o'clock
+ start_deg_16 = int(math.degrees(start_angle) * 16)
+ span_deg_16 = int(math.degrees(sweep) * 16)
+
+ rect = QRect(sc.x() - sr, sc.y() - sr, sr * 2, sr * 2)
+ painter.setPen(QPen(QColor("#cdd6f4"), 2))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawArc(rect, start_deg_16, span_deg_16)
+
# ── 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])
@@ -2099,6 +2691,109 @@ class Sketch2DWidget(QWidget):
painter.setBrush(Qt.NoBrush)
painter.drawEllipse(center, int(r), int(r))
+ if self._draw_buffer and self._dynamic_line_end and self._mode == "arc":
+ if len(self._draw_buffer) == 1:
+ # Click 1 done: show circle preview (center to cursor)
+ center = self._world_to_screen(self._draw_buffer[0])
+ end = self._world_to_screen(self._dynamic_line_end)
+ 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(r), int(r))
+ elif len(self._draw_buffer) == 2:
+ # Click 2 done: show arc from start to cursor
+ cw = self._draw_buffer[0] # center world
+ sw = self._draw_buffer[1] # start world
+ ew = self._dynamic_line_end # end world (mouse)
+
+ cx_f, cy_f = cw.x(), cw.y()
+ sx_f, sy_f = sw.x(), sw.y()
+
+ arc_radius = math.sqrt((sx_f - cx_f) ** 2 + (sy_f - cy_f) ** 2)
+ sr = int(arc_radius * self._zoom)
+
+ sc = self._world_to_screen(QPoint(int(round(cx_f)), int(round(cy_f))))
+
+ # Use accumulated sweep so the arc follows the mouse
+ # smoothly even past 180° (no shortest-path flip).
+ start_angle = math.atan2(sy_f - cy_f, sx_f - cx_f)
+ sweep = self._arc_accum_sweep
+
+ start_deg_16 = int(math.degrees(start_angle) * 16)
+ span_deg_16 = int(math.degrees(sweep) * 16)
+
+ rect = QRect(sc.x() - sr, sc.y() - sr, sr * 2, sr * 2)
+ painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawArc(rect, start_deg_16, span_deg_16)
+
+ # Also draw a helper line from center to cursor so the
+ # user can see the sweep angle being defined
+ painter.setPen(QPen(QColor("#a6e3a1"), 1, Qt.DotLine))
+ painter.drawLine(sc, self._world_to_screen(ew))
+
+ # ── Slot preview ──
+ if self._draw_buffer and self._dynamic_line_end and self._mode == "slot":
+ if len(self._draw_buffer) == 1:
+ # Click 1 done: preview centerline
+ c1 = self._world_to_screen(self._draw_buffer[0])
+ c2 = self._world_to_screen(self._dynamic_line_end)
+ painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
+ painter.drawLine(c1, c2)
+ elif len(self._draw_buffer) == 2:
+ # Click 2 done: preview full slot outline
+ c1 = self._draw_buffer[0]
+ c2 = self._draw_buffer[1]
+ cursor = self._dynamic_line_end
+
+ dx_c = c2.x() - c1.x()
+ dy_c = c2.y() - c1.y()
+ L = math.sqrt(dx_c * dx_c + dy_c * dy_c)
+ if L > 0:
+ r = abs((cursor.x() - c1.x()) * dy_c
+ - (cursor.y() - c1.y()) * dx_c) / L
+ if r < 0.5:
+ r = 0.5
+ perp_x = -dy_c / L
+ perp_y = dx_c / L
+
+ def sc(pt):
+ return self._world_to_screen(QPoint(int(round(pt[0])), int(round(pt[1]))))
+
+ c1s = (c1.x(), c1.y())
+ c2s = (c2.x(), c2.y())
+ t1 = (c1.x() + r * perp_x, c1.y() + r * perp_y)
+ b1 = (c1.x() - r * perp_x, c1.y() - r * perp_y)
+ t2 = (c2.x() + r * perp_x, c2.y() + r * perp_y)
+ b2 = (c2.x() - r * perp_x, c2.y() - r * perp_y)
+
+ painter.setPen(QPen(QColor("#a6e3a1"), 2, Qt.DashLine))
+ painter.setBrush(Qt.NoBrush)
+
+ # Bottom line
+ painter.drawLine(sc(b1), sc(b2))
+ # Top line
+ painter.drawLine(sc(t2), sc(t1))
+ # Arc 1 (center C1, from t1 to b1, CCW exterior)
+ sa1 = math.atan2(t1[1] - c1s[1], t1[0] - c1s[0])
+ sweep1 = math.pi
+ n_seg = 16
+ for i in range(n_seg):
+ a1 = sa1 + (i / n_seg) * sweep1
+ a2 = sa1 + ((i + 1) / n_seg) * sweep1
+ p1 = (c1s[0] + r * math.cos(a1), c1s[1] + r * math.sin(a1))
+ p2 = (c1s[0] + r * math.cos(a2), c1s[1] + r * math.sin(a2))
+ painter.drawLine(sc(p1), sc(p2))
+ # Arc 2 (center C2, from b2 to t2)
+ sa2 = math.atan2(b2[1] - c2s[1], b2[0] - c2s[0])
+ sweep2 = math.pi
+ for i in range(n_seg):
+ a1 = sa2 + (i / n_seg) * sweep2
+ a2 = sa2 + ((i + 1) / n_seg) * sweep2
+ p1 = (c2s[0] + r * math.cos(a1), c2s[1] + r * math.sin(a1))
+ p2 = (c2s[0] + r * math.cos(a2), c2s[1] + r * math.sin(a2))
+ painter.drawLine(sc(p1), sc(p2))
+
# ── Hovered point highlight ──
if self._hovered_point:
screen_pos = self._world_to_screen(self._hovered_point)
@@ -2775,11 +3470,14 @@ class MainWindow(QMainWindow):
self._btn_move_sketch.setToolTip("Move a drawn element (drag a point/line/rectangle/circle).")
self._btn_move_sketch.setShortcut("Q")
draw_layout.addWidget(self._btn_move_sketch, 1, 2, 1, 1)
- # separator
+ self._btn_arc = QPushButton("Arc")
+ self._btn_arc.setCheckable(True)
+ draw_layout.addWidget(self._btn_arc, 2, 0)
+ # separator (shifted right to leave column 0 for Arc)
sep = QFrame()
sep.setFrameShape(QFrame.HLine)
sep.setFrameShadow(QFrame.Sunken)
- draw_layout.addWidget(sep, 2, 0, 1, 3)
+ draw_layout.addWidget(sep, 2, 1, 1, 2)
self._btn_construct = QPushButton("Cstrct")
self._btn_construct.setCheckable(True)
draw_layout.addWidget(self._btn_construct, 3, 0)
@@ -3081,6 +3779,7 @@ class MainWindow(QMainWindow):
self._btn_line.clicked.connect(lambda: self._set_sketch_mode("line"))
self._btn_rect.clicked.connect(lambda: self._set_sketch_mode("rectangle"))
self._btn_circle.clicked.connect(lambda: self._set_sketch_mode("circle"))
+ self._btn_arc.clicked.connect(lambda: self._set_sketch_mode("arc"))
self._btn_slot.clicked.connect(lambda: self._set_sketch_mode("slot"))
self._btn_move_sketch.clicked.connect(lambda: self._set_sketch_mode("select"))
self._btn_construct.clicked.connect(self._on_construct_change)
@@ -3172,6 +3871,7 @@ class MainWindow(QMainWindow):
self._btn_line,
self._btn_rect,
self._btn_circle,
+ self._btn_arc,
self._btn_slot,
self._btn_move_sketch,
self._btn_con_ptpt,
@@ -3185,13 +3885,15 @@ class MainWindow(QMainWindow):
]:
btn.setChecked(False)
- if mode in ["line", "rectangle", "circle", "slot"]:
+ if mode in ["line", "rectangle", "circle", "arc", "slot"]:
if mode == "line":
self._btn_line.setChecked(True)
elif mode == "rectangle":
self._btn_rect.setChecked(True)
elif mode == "circle":
self._btn_circle.setChecked(True)
+ elif mode == "arc":
+ self._btn_arc.setChecked(True)
elif mode == "slot":
self._btn_slot.setChecked(True)
elif mode == "select":
@@ -3212,6 +3914,7 @@ class MainWindow(QMainWindow):
self._btn_line,
self._btn_rect,
self._btn_circle,
+ self._btn_arc,
self._btn_slot,
self._btn_move_sketch,
self._btn_con_ptpt,