diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 942e505..2b1ac63 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,9 +4,12 @@
-
+
+
+
+
@@ -119,14 +122,6 @@
1703867682707
-
-
- 1729958532384
-
-
-
- 1729958532384
-
1735563255455
@@ -511,7 +506,15 @@
1786888483688
-
+
+
+ 1786910497589
+
+
+
+ 1786910497589
+
+
@@ -532,7 +535,6 @@
-
@@ -557,6 +559,7 @@
-
+
+
\ No newline at end of file
diff --git a/src/fluency/io/project_io.py b/src/fluency/io/project_io.py
index 1aa712f..b2c376f 100644
--- a/src/fluency/io/project_io.py
+++ b/src/fluency/io/project_io.py
@@ -43,9 +43,12 @@ from fluency.models.data_model import (
Body,
Component,
Connector,
+ DrawingAnnotation,
+ DrawingView,
Feature,
Project,
Sketch,
+ TechnicalDrawing,
Workplane,
)
from fluency.geometry_occ.kernel import OCCGeometryObject, OCGeometryKernel
@@ -600,6 +603,122 @@ def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
return asm
+def _drawing_view_to_dict(v: DrawingView) -> Dict[str, Any]:
+ return {
+ "id": v.id,
+ "kind": v.kind,
+ "name": v.name,
+ "direction": _coerce_listlike(v.direction),
+ "up_vector": _coerce_listlike(v.up_vector),
+ "show_hidden_lines": bool(v.show_hidden_lines),
+ "show_centerlines": bool(v.show_centerlines),
+ "scale": _to_float(v.scale, 1.0),
+ "sheet_origin": _coerce_listlike(v.sheet_origin),
+ }
+
+
+def _drawing_view_from_dict(data: Dict[str, Any]) -> DrawingView:
+ view = DrawingView(
+ id=_saved_id(data),
+ kind=data.get("kind", "front"),
+ name=data.get("name"),
+ direction=_to_3tuple(data.get("direction")) or None,
+ up_vector=_to_3tuple(data.get("up_vector")) or None,
+ show_hidden_lines=bool(data.get("show_hidden_lines", False)),
+ show_centerlines=bool(data.get("show_centerlines", False)),
+ scale=_to_float(data.get("scale"), 1.0),
+ )
+ origin = data.get("sheet_origin")
+ if isinstance(origin, (list, tuple)) and len(origin) >= 2:
+ view.sheet_origin = (_to_float(origin[0]), _to_float(origin[1]))
+ return view
+
+
+def _drawing_annotation_to_dict(a: DrawingAnnotation) -> Dict[str, Any]:
+ return {
+ "id": a.id,
+ "kind": a.kind,
+ "text": a.text,
+ "visible": bool(a.visible),
+ "references": list(a.references),
+ "sheet_position": _coerce_listlike(a.sheet_position),
+ "view_id": a.view_id,
+ "anchors": [list(pt) for pt in a.anchors],
+ "dimension_kind": a.dimension_kind,
+ "direction": _coerce_listlike(a.direction),
+ }
+
+
+def _drawing_annotation_from_dict(data: Dict[str, Any]) -> DrawingAnnotation:
+ ann = DrawingAnnotation(
+ id=_saved_id(data),
+ kind=data.get("kind", "dimension"),
+ text=data.get("text"),
+ visible=bool(data.get("visible", True)),
+ references=list(data.get("references") or []),
+ view_id=data.get("view_id", ""),
+ dimension_kind=data.get("dimension_kind", ""),
+ )
+ pos = data.get("sheet_position")
+ if isinstance(pos, (list, tuple)) and len(pos) >= 2:
+ ann.sheet_position = (_to_float(pos[0]), _to_float(pos[1]))
+ anchors = data.get("anchors") or []
+ for pt in anchors:
+ if isinstance(pt, (list, tuple)) and len(pt) >= 2:
+ ann.anchors.append((_to_float(pt[0]), _to_float(pt[1])))
+ direction = data.get("direction")
+ if isinstance(direction, (list, tuple)) and len(direction) >= 2:
+ ann.direction = (_to_float(direction[0]), _to_float(direction[1]))
+ return ann
+
+
+def _technical_drawing_to_dict(d: TechnicalDrawing) -> Dict[str, Any]:
+ return {
+ "id": d.id,
+ "name": d.name,
+ "source_kind": d.source_kind,
+ "source_id": d.source_id,
+ "views": [_drawing_view_to_dict(v) for v in d.views],
+ "annotations": [_drawing_annotation_to_dict(a) for a in d.annotations],
+ "title": d.title,
+ "part_number": d.part_number,
+ "material": d.material,
+ "revision": d.revision,
+ "notes": d.notes,
+ "sheet_size": d.sheet_size,
+ "units": d.units,
+ "auto_dimensions": bool(d.auto_dimensions),
+ "auto_views": bool(d.auto_views),
+ "created_at": d.created_at.isoformat() if d.created_at else None,
+ "modified_at": d.modified_at.isoformat() if d.modified_at else None,
+ }
+
+
+def _technical_drawing_from_dict(data: Dict[str, Any]) -> TechnicalDrawing:
+ drawing = TechnicalDrawing(
+ id=_saved_id(data),
+ name=data.get("name", "Untitled Drawing"),
+ source_kind=data.get("source_kind", "component"),
+ source_id=data.get("source_id", ""),
+ title=data.get("title", ""),
+ part_number=data.get("part_number", ""),
+ material=data.get("material", ""),
+ revision=data.get("revision", ""),
+ notes=data.get("notes", ""),
+ sheet_size=data.get("sheet_size", "A3"),
+ units=data.get("units", "mm"),
+ auto_dimensions=bool(data.get("auto_dimensions", False)),
+ auto_views=bool(data.get("auto_views", True)),
+ )
+ drawing.created_at = _parse_iso(data.get("created_at"))
+ drawing.modified_at = _parse_iso(data.get("modified_at"))
+ for v_data in data.get("views") or []:
+ drawing.views.append(_drawing_view_from_dict(v_data))
+ for a_data in data.get("annotations") or []:
+ drawing.annotations.append(_drawing_annotation_from_dict(a_data))
+ return drawing
+
+
def _project_to_dict(
project: Project,
view_state: Optional[Dict[str, Any]] = None,
@@ -612,6 +731,7 @@ def _project_to_dict(
"active_assembly": project.active_assembly,
"components": {cid: _component_to_dict(c) for cid, c in project.components.items()},
"assemblies": {aid: _assembly_to_dict(a) for aid, a in project.assemblies.items()},
+ "drawings": [_technical_drawing_to_dict(d) for d in project.drawings],
"created_at": project.created_at.isoformat() if project.created_at else None,
"modified_at": project.modified_at.isoformat() if project.modified_at else None,
"view_state": view_state or {},
@@ -886,6 +1006,12 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
for aid, a_data in (manifest.get("assemblies") or {}).items():
project.assemblies[aid] = _assembly_from_dict(a_data)
+ for d_data in manifest.get("drawings") or []:
+ try:
+ project.drawings.append(_technical_drawing_from_dict(d_data))
+ except Exception as exc:
+ logger.warning("Skipping corrupt drawing in archive: %s", exc)
+
# After all components are loaded, re-wire connector partner ids so
# they point to the freshly-loaded AssemblyComponents. (The dict
# round-trip preserves the raw strings; we just make sure the partner
diff --git a/src/fluency/models/data_model.py b/src/fluency/models/data_model.py
index 7eee380..30cdff2 100644
--- a/src/fluency/models/data_model.py
+++ b/src/fluency/models/data_model.py
@@ -724,6 +724,10 @@ class Project:
assemblies: Dict[str, Assembly] = field(default_factory=dict)
active_assembly: Optional[str] = None
+ # Technical drawings keyed to their source component/assembly.
+ # Manual dimensions and view options added in the drawing workbench
+ # are persisted here so they survive save/load.
+ drawings: List["TechnicalDrawing"] = field(default_factory=list)
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
created_at: datetime = field(default_factory=datetime.now)
@@ -800,6 +804,21 @@ class Project:
"""Look up a component by id across all project components."""
return self.components.get(component_id)
+ # ── Drawing helpers ──
+
+ def get_drawing_for(self, source_kind: str, source_id: str) -> Optional["TechnicalDrawing"]:
+ """Return the drawing bound to *(source_kind, source_id)*, if any."""
+ for drawing in self.drawings:
+ if drawing.source_kind == source_kind and drawing.source_id == source_id:
+ return drawing
+ return None
+
+ def add_drawing(self, drawing: "TechnicalDrawing") -> "TechnicalDrawing":
+ """Register *drawing* with the project and return it."""
+ self.drawings.append(drawing)
+ self.modified_at = datetime.now()
+ return drawing
+
def export_step(self, filepath: str) -> bool:
"""Export all visible bodies to STEP."""
@@ -917,6 +936,23 @@ class DrawingAnnotation:
# Associated view id (empty means global/note block).
view_id: str = ""
+ # Manual-dimension geometry in view-plane model coordinates (model
+ # units in the view's projection plane). Populated for user-placed
+ # dimensions, empty for reference-based annotations:
+ # "length": (point_on_edge1, point_on_edge2) — closest points
+ # "diameter": (left, right) — antipodal points across the centre
+ # "angle": (vertex, arm1_point, arm2_point)
+ anchors: List[Tuple[float, float]] = field(default_factory=list)
+
+ # Sub-kind of the manual dimension: "length" | "diameter" | "angle".
+ # Empty for non-dimension annotations.
+ dimension_kind: str = ""
+
+ # Unit vector along the measured distance in view-plane coordinates
+ # (length dimensions only). The model→sheet transform is a uniform
+ # scale + translation, so the direction is valid in sheet space too.
+ direction: Optional[Tuple[float, float]] = None
+
@dataclass
class TechnicalDrawing:
@@ -943,8 +979,9 @@ class TechnicalDrawing:
sheet_size: str = "A3"
units: str = "mm" # mm, in
- # Auto-generation flags for future use.
- auto_dimensions: bool = True
+ # Auto-generation flags. Auto dimensions are opt-in: the drawing
+ # workbench shows them only while the user has the toggle enabled.
+ auto_dimensions: bool = False
auto_views: bool = True
created_at: datetime = field(default_factory=datetime.now)
diff --git a/src/fluency/technical_drawing.py b/src/fluency/technical_drawing.py
index e714e5e..f779b6d 100644
--- a/src/fluency/technical_drawing.py
+++ b/src/fluency/technical_drawing.py
@@ -14,7 +14,7 @@ Exact public API:
from __future__ import annotations
import math
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Tuple
import numpy as np
@@ -68,6 +68,9 @@ class DrawingPrimitive:
center: Optional[Tuple[float, float]] = None
radius: Optional[float] = None
dash_pattern: Tuple[float, ...] = ()
+ # View this primitive belongs to (geometry primitives); used for
+ # hit-testing in the drawing workbench.
+ view_id: Optional[str] = None
@dataclass(frozen=True)
@@ -80,6 +83,9 @@ class DrawingRenderResult:
unresolved_annotation_ids: Tuple[str, ...]
source_fingerprint: str
warnings: Tuple[str, ...]
+ # Per-view model→sheet transform: view_id → (scale, offset_x, offset_y)
+ # with sheet(x, y) = (x*scale + offset_x, y*scale + offset_y).
+ view_transforms: Dict[str, Tuple[float, float, float]] = field(default_factory=dict)
# ── View presets ───────────────────────────────────────────────────────────
@@ -319,6 +325,7 @@ def _assemble_view(
view: DrawingView,
slot: Optional[Tuple[float, float, float, float]] = None,
scale_override: Optional[float] = None,
+ transforms: Optional[Dict[str, Tuple[float, float, float]]] = None,
) -> Tuple[Tuple[DrawingPrimitive, ...], Tuple[DrawingCandidate, ...], Tuple[str, ...]]:
"""Fit projected edges into *slot* and emit primitives + candidates.
@@ -326,6 +333,8 @@ def _assemble_view(
bottom-left, +y up). When omitted the projection is fitted to the
whole sheet. *scale_override* forces a specific model→sheet scale
(used to keep all orthographic views at one shared scale).
+ When *transforms* is given, the resolved
+ ``(scale, offset_x, offset_y)`` is recorded under the view id.
"""
warnings = list(warnings)
primitives: List[DrawingPrimitive] = []
@@ -366,6 +375,11 @@ def _assemble_view(
offset_x = left + (avail_w - geom_w * scale) / 2.0 - min_x * scale
offset_y = bottom + (avail_h - geom_h * scale) / 2.0 - min_y * scale
+ # Use kind as view_id for readability (UUID is opaque to users).
+ view_id = view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id)
+ if transforms is not None:
+ transforms[view_id] = (scale, offset_x, offset_y)
+
def _to_sheet(x: float, y: float) -> Tuple[float, float]:
return (x * scale + offset_x, y * scale + offset_y)
@@ -383,6 +397,7 @@ def _assemble_view(
style=style,
center=_to_sheet(cx, cy),
radius=radius * scale,
+ view_id=view_id,
)
)
else:
@@ -391,12 +406,11 @@ def _assemble_view(
kind="line",
points=(_to_sheet(*p1), _to_sheet(*p2)),
style=style,
+ view_id=view_id,
)
)
# ── Dimension candidate extraction ──────────────────────────────
- # Use kind as view_id for readability (UUID is opaque to users).
- view_id = view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id)
# 1. Overall extents (bounding-box width/height), anchored at real
# bbox corners so extension lines can start at feature extremes.
@@ -1011,8 +1025,11 @@ def _generate_dimension_primitives(
Linear/extent candidates become extension lines + an offset dimension
line with arrowheads + centred text (dimension line broken for the
- label). Diameters become a 45° leader with a horizontal tail. Angles
- become a small vertex arc + text.
+ label). Anchors on one feature line yield a dimension line
+ perpendicular to the measurement direction; anchors that are the
+ closest points of two parallel edges (distance dimensions) yield a
+ dimension line parallel to it. Diameters become a 45° leader with a
+ horizontal tail. Angles become a small vertex arc + text.
"""
# Keep dimension lines inside the sheet (with a small margin).
_sheet_min_x, _sheet_max_x = 6.0, _A3_WIDTH_MM - 6.0
@@ -1084,65 +1101,129 @@ def _generate_dimension_primitives(
d = (vx / vl, vy / vl) if vl > 1e-9 else (1.0, 0.0)
mx, my = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0
- # Half the anchor spread along the measurement direction: the
- # dimension line lands this far (plus the offset) beyond the
- # far feature line.
- spread = abs((p2[0] - p1[0]) * d[0] + (p2[1] - p1[1]) * d[1]) / 2.0
- offset = spread + _DIM_OFFSET_MM
- # Place the dimension line on the side of the anchor pair away
- # from the view centre (outside the feature).
- sign = (
- -1.0
- if (view_center[0] - mx) * d[0] + (view_center[1] - my) * d[1] > 0
- else 1.0
- )
- cdim = _clamp_sheet((mx + sign * d[0] * offset, my + sign * d[1] * offset))
+ # Decompose the anchor pair: separation along the measurement
+ # direction d and perpendicular to it. cross ≈ 0 means the
+ # anchors are the closest points on two parallel edges (a
+ # distance dimension); otherwise they sit on one feature line.
+ wdx, wdy = p2[0] - p1[0], p2[1] - p1[1]
+ along = wdx * d[0] + wdy * d[1]
+ cross = abs(wdx * d[1] - wdy * d[0])
- # Progressive stacking: push the dimension line away from
- # already-placed parallel lines whose foot spans overlap, so
- # parallel dimensions stay readable (standard CAD behaviour).
- for _ in range(8):
+ if cross > 0.5:
+ # ── Feature-line case ─────────────────────────────────
+ # The dimension line is perpendicular to d, offset away
+ # from the view centre (half the anchor spread plus the
+ # standard offset, so it lands beyond the far feature).
+ spread = abs(along) / 2.0
+ offset = spread + _DIM_OFFSET_MM
+ sign = (
+ -1.0
+ if (view_center[0] - mx) * d[0] + (view_center[1] - my) * d[1] > 0
+ else 1.0
+ )
+ cdim = _clamp_sheet((mx + sign * d[0] * offset, my + sign * d[1] * offset))
+
+ # Progressive stacking: push the dimension line away from
+ # already-placed parallel lines whose foot spans overlap, so
+ # parallel dimensions stay readable (standard CAD behaviour).
+ for _ in range(8):
+ t1 = (cdim[0] - p1[0]) * d[0] + (cdim[1] - p1[1]) * d[1]
+ t2 = (cdim[0] - p2[0]) * d[0] + (cdim[1] - p2[1]) * d[1]
+ e1 = (p1[0] + d[0] * t1, p1[1] + d[1] * t1)
+ e2 = (p2[0] + d[0] * t2, p2[1] + d[1] * t2)
+ half = math.hypot(e2[0] - e1[0], e2[1] - e1[1]) / 2.0
+ if half <= 0.5:
+ break
+ u = (
+ (e2[0] - e1[0]) / (2.0 * half),
+ (e2[1] - e1[1]) / (2.0 * half),
+ )
+ pushed = False
+ for (ux0, uy0, qx0, qy0, tmin0, tmax0) in placed_dim_lines:
+ if abs(u[0] * ux0 + u[1] * uy0) < 0.98:
+ continue # not parallel
+ ta1 = (e1[0] - qx0) * ux0 + (e1[1] - qy0) * uy0
+ ta2 = (e2[0] - qx0) * ux0 + (e2[1] - qy0) * uy0
+ if max(ta1, ta2) < tmin0 or min(ta1, ta2) > tmax0:
+ continue # spans do not overlap
+ sep = (cdim[0] - qx0) * d[0] + (cdim[1] - qy0) * d[1]
+ if abs(sep) < _DIM_STANDOFF_MM:
+ prev = cdim
+ cdim = (
+ cdim[0] + sign * d[0] * (_DIM_STANDOFF_MM - abs(sep)),
+ cdim[1] + sign * d[1] * (_DIM_STANDOFF_MM - abs(sep)),
+ )
+ cdim = _clamp_sheet(cdim)
+ if cdim == prev:
+ # Pushed against the sheet edge — stop stacking.
+ break
+ pushed = True
+ break
+ if not pushed:
+ break
+
+ # Final feet of the (possibly stacked) dimension line.
t1 = (cdim[0] - p1[0]) * d[0] + (cdim[1] - p1[1]) * d[1]
t2 = (cdim[0] - p2[0]) * d[0] + (cdim[1] - p2[1]) * d[1]
e1 = (p1[0] + d[0] * t1, p1[1] + d[1] * t1)
e2 = (p2[0] + d[0] * t2, p2[1] + d[1] * t2)
half = math.hypot(e2[0] - e1[0], e2[1] - e1[1]) / 2.0
- if half <= 0.5:
- break
- u = (
- (e2[0] - e1[0]) / (2.0 * half),
- (e2[1] - e1[1]) / (2.0 * half),
+ ext_dir = (sign * d[0], sign * d[1])
+ else:
+ # ── Inter-edge distance case ──────────────────────────
+ # The measured distance runs along d; the dimension line
+ # is parallel to d, offset from the picked line on the
+ # side away from the view centre.
+ n = (-d[1], d[0])
+ sign = (
+ -1.0
+ if (view_center[0] - mx) * n[0] + (view_center[1] - my) * n[1] > 0
+ else 1.0
)
- pushed = False
- for (ux0, uy0, qx0, qy0, tmin0, tmax0) in placed_dim_lines:
- if abs(u[0] * ux0 + u[1] * uy0) < 0.98:
- continue # not parallel
- ta1 = (e1[0] - qx0) * ux0 + (e1[1] - qy0) * uy0
- ta2 = (e2[0] - qx0) * ux0 + (e2[1] - qy0) * uy0
- if max(ta1, ta2) < tmin0 or min(ta1, ta2) > tmax0:
- continue # spans do not overlap
- sep = (cdim[0] - qx0) * d[0] + (cdim[1] - qy0) * d[1]
- if abs(sep) < _DIM_STANDOFF_MM:
- prev = cdim
- cdim = (
- cdim[0] + sign * d[0] * (_DIM_STANDOFF_MM - abs(sep)),
- cdim[1] + sign * d[1] * (_DIM_STANDOFF_MM - abs(sep)),
- )
- cdim = _clamp_sheet(cdim)
- if cdim == prev:
- # Pushed against the sheet edge — stop stacking.
- break
- pushed = True
- break
- if not pushed:
- break
+ cdim = _clamp_sheet(
+ (
+ mx + sign * n[0] * _DIM_OFFSET_MM,
+ my + sign * n[1] * _DIM_OFFSET_MM,
+ )
+ )
+ half = abs(along) / 2.0
+ u_dir = (d[0], d[1]) if along >= 0 else (-d[0], -d[1])
- # Final feet of the (possibly stacked) dimension line.
- t1 = (cdim[0] - p1[0]) * d[0] + (cdim[1] - p1[1]) * d[1]
- t2 = (cdim[0] - p2[0]) * d[0] + (cdim[1] - p2[1]) * d[1]
- e1 = (p1[0] + d[0] * t1, p1[1] + d[1] * t1)
- e2 = (p2[0] + d[0] * t2, p2[1] + d[1] * t2)
- half = math.hypot(e2[0] - e1[0], e2[1] - e1[1]) / 2.0
+ # Progressive stacking (same rules, pushing along n).
+ for _ in range(8):
+ if half <= 0.5:
+ break
+ e1 = (cdim[0] - u_dir[0] * half, cdim[1] - u_dir[1] * half)
+ e2 = (cdim[0] + u_dir[0] * half, cdim[1] + u_dir[1] * half)
+ pushed = False
+ for (ux0, uy0, qx0, qy0, tmin0, tmax0) in placed_dim_lines:
+ if abs(u_dir[0] * ux0 + u_dir[1] * uy0) < 0.98:
+ continue # not parallel
+ ta1 = (e1[0] - qx0) * ux0 + (e1[1] - qy0) * uy0
+ ta2 = (e2[0] - qx0) * ux0 + (e2[1] - qy0) * uy0
+ if max(ta1, ta2) < tmin0 or min(ta1, ta2) > tmax0:
+ continue # spans do not overlap
+ sep = (cdim[0] - qx0) * n[0] + (cdim[1] - qy0) * n[1]
+ if abs(sep) < _DIM_STANDOFF_MM:
+ prev = cdim
+ cdim = (
+ cdim[0] + sign * n[0] * (_DIM_STANDOFF_MM - abs(sep)),
+ cdim[1] + sign * n[1] * (_DIM_STANDOFF_MM - abs(sep)),
+ )
+ cdim = _clamp_sheet(cdim)
+ if cdim == prev:
+ # Pushed against the sheet edge — stop stacking.
+ break
+ pushed = True
+ break
+ if not pushed:
+ break
+
+ e1 = (cdim[0] - u_dir[0] * half, cdim[1] - u_dir[1] * half)
+ e2 = (cdim[0] + u_dir[0] * half, cdim[1] + u_dir[1] * half)
+ ext_dir = (sign * n[0], sign * n[1])
+
+ # ── Shared rendering of the positioned dimension line ─────
if half > 0.5:
u = ((e2[0] - e1[0]) / (2.0 * half), (e2[1] - e1[1]) / (2.0 * half))
s1 = (e1[0] - cdim[0]) * u[0] + (e1[1] - cdim[1]) * u[1]
@@ -1151,10 +1232,10 @@ def _generate_dimension_primitives(
(u[0], u[1], cdim[0], cdim[1], min(s1, s2), max(s1, s2))
)
# Extension lines: anchor → 2mm past the dimension line.
- for anchor, t in ((p1, t1), (p2, t2)):
+ for anchor, foot in ((p1, e1), (p2, e2)):
end = (
- anchor[0] + d[0] * (t + sign * _DIM_EXT_OVERSHOOT_MM),
- anchor[1] + d[1] * (t + sign * _DIM_EXT_OVERSHOOT_MM),
+ foot[0] + ext_dir[0] * _DIM_EXT_OVERSHOOT_MM,
+ foot[1] + ext_dir[1] * _DIM_EXT_OVERSHOOT_MM,
)
prims.append(
DrawingPrimitive(
@@ -1279,6 +1360,108 @@ def _generate_dimension_primitives(
return prims
+_MANUAL_DIMENSION_KINDS = ("length", "diameter", "angle")
+
+
+def build_manual_candidates(
+ drawing: TechnicalDrawing,
+ view_transforms: Dict[str, Tuple[float, float, float]],
+) -> Tuple[List[DrawingCandidate], List[str], List[str]]:
+ """Convert user-placed annotations into renderable dimension candidates.
+
+ Manual annotations store their feature geometry in view-plane model
+ coordinates (see ``DrawingAnnotation.anchors``); here it is re-projected
+ through the current view transforms so the dimensions re-lay out
+ correctly whenever views, slots, or the shared scale change. Returns
+ ``(candidates, resolved_ids, unresolved_ids)``.
+ """
+ candidates: List[DrawingCandidate] = []
+ resolved: List[str] = []
+ unresolved: List[str] = []
+
+ for ann in drawing.annotations:
+ if not ann.visible or ann.dimension_kind not in _MANUAL_DIMENSION_KINDS:
+ continue
+ if len(ann.anchors) < 2:
+ unresolved.append(ann.id)
+ continue
+ transform = view_transforms.get(ann.view_id)
+ if transform is None:
+ unresolved.append(ann.id)
+ continue
+ scale, offset_x, offset_y = transform
+
+ def to_sheet(pt: Tuple[float, float]) -> Tuple[float, float]:
+ return (pt[0] * scale + offset_x, pt[1] * scale + offset_y)
+
+ a0, a1 = ann.anchors[0], ann.anchors[1]
+ if ann.dimension_kind == "angle":
+ if len(ann.anchors) < 3:
+ unresolved.append(ann.id)
+ continue
+ vertex, arm1, arm2 = ann.anchors
+ v1x, v1y = arm1[0] - vertex[0], arm1[1] - vertex[1]
+ v2x, v2y = arm2[0] - vertex[0], arm2[1] - vertex[1]
+ m1 = math.hypot(v1x, v1y)
+ m2 = math.hypot(v2x, v2y)
+ if m1 < 1e-9 or m2 < 1e-9:
+ unresolved.append(ann.id)
+ continue
+ cos_a = (v1x * v2x + v1y * v2y) / (m1 * m2)
+ value = math.degrees(math.acos(max(-1.0, min(1.0, cos_a))))
+ if value < 0.5 or value > 179.5:
+ unresolved.append(ann.id)
+ continue
+ candidates.append(
+ DrawingCandidate(
+ key=f"manual:{ann.id}",
+ view_id=ann.view_id,
+ kind="angle",
+ references=(ann.id,),
+ value=value,
+ anchor_points=(to_sheet(vertex), to_sheet(arm1), to_sheet(arm2)),
+ label=f"{value:.{_DISPLAY_PRECISION}f}°",
+ )
+ )
+ resolved.append(ann.id)
+ continue
+
+ value = math.dist(a0, a1)
+ if value < 1e-6:
+ unresolved.append(ann.id)
+ continue
+ if ann.dimension_kind == "length":
+ if ann.direction:
+ dx, dy = float(ann.direction[0]), float(ann.direction[1])
+ if math.hypot(dx, dy) < 1e-9:
+ dx, dy = a1[0] - a0[0], a1[1] - a0[1]
+ else:
+ dx, dy = a1[0] - a0[0], a1[1] - a0[1]
+ # value >= 1e-6 guarantees the fallback vector is non-zero.
+ mag = math.hypot(dx, dy)
+ direction: Tuple[float, float] = (dx / mag, dy / mag)
+ label = f"{value:.{_DISPLAY_PRECISION}f}"
+ else: # diameter
+ direction = ()
+ label = f"Ø{value:.{_DISPLAY_PRECISION}f}"
+
+ candidates.append(
+ DrawingCandidate(
+ key=f"manual:{ann.id}",
+ view_id=ann.view_id,
+ kind=ann.dimension_kind,
+ references=(ann.id,),
+ value=value,
+ anchor_points=(to_sheet(a0), to_sheet(a1)),
+ label=label,
+ direction=direction,
+ )
+ )
+ resolved.append(ann.id)
+
+ return candidates, resolved, unresolved
+
+
def generate_drawing(
drawing: TechnicalDrawing,
project: Project,
@@ -1335,6 +1518,9 @@ def generate_drawing(
if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
}
+ # Per-view model→sheet transforms, filled in by _assemble_view.
+ transforms: Dict[str, Tuple[float, float, float]] = {}
+
for view in drawing.views:
vid = _vid_of(view)
slot = view_slots.get(vid)
@@ -1343,14 +1529,25 @@ def generate_drawing(
common_scale * view.scale if vid in ortho_vids and common_scale else None
)
prims, cands, vwarns = _assemble_view(
- edges, view_warnings, view, slot, scale_override
+ edges, view_warnings, view, slot, scale_override, transforms
)
all_primitives.extend(prims)
all_candidates.extend(cands)
warnings.extend(vwarns)
- # Auto-place dimensions from candidates (isometric views are never
- # dimensioned on real drawings).
+ # User-placed dimensions: convert the model-space annotations into
+ # sheet-space candidates so they re-lay out with the current views.
+ manual_cands, manual_resolved, manual_unresolved = build_manual_candidates(
+ drawing, transforms
+ )
+ manual_ids = {c.references[0] for c in manual_cands if c.references}
+ resolved_ids: List[str] = list(manual_resolved)
+ unresolved_ids: List[str] = list(manual_unresolved)
+
+ # Place dimensions per view: the auto selection (only while the user
+ # has auto dimensions enabled) plus the manual dimensions, so the
+ # stacking/overlap logic treats them alike. Isometric views are never
+ # dimensioned on real drawings.
for view in drawing.views:
if view.kind == "isometric":
continue
@@ -1361,17 +1558,21 @@ def generate_drawing(
if slot
else (_A3_WIDTH_MM / 2.0, _A3_HEIGHT_MM / 2.0)
)
- selected = _select_dimensions_for_placement(all_candidates, vid)
+ selected: List[DrawingCandidate] = []
+ if drawing.auto_dimensions:
+ selected = _select_dimensions_for_placement(all_candidates, vid)
+ selected.extend(c for c in manual_cands if c.view_id == vid)
all_primitives.extend(_generate_dimension_primitives(selected, center))
- # Match annotations to candidates.
- resolved_ids: List[str] = []
- unresolved_ids: List[str] = []
+ # Match legacy (reference-based) annotations to candidates. Manual
+ # dimensions were already rendered above as full dimension primitives.
candidate_by_key: Dict[str, DrawingCandidate] = {c.key: c for c in all_candidates}
for ann in drawing.annotations:
if not ann.visible:
continue
+ if ann.id in manual_ids:
+ continue
if ann.kind == "note":
resolved_ids.append(ann.id)
continue
@@ -1405,6 +1606,7 @@ def generate_drawing(
drawing.source_kind, drawing.source_id
),
warnings=tuple(warnings),
+ view_transforms=transforms,
)
diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py
index 79dc1f8..ca42758 100644
--- a/src/fluency/ui/main_window.py
+++ b/src/fluency/ui/main_window.py
@@ -1917,7 +1917,9 @@ class MainWindow(QMainWindow):
self._drawing_tab = TechnicalDrawingWidget()
self._ui.InputTab.addTab(self._drawing_tab, "Drawing")
self._drawing_tab.set_project(self._project, self._kernel)
- self._refresh_drawing_source_catalog()
+ # Dimension tools and view toggles in the workbench mutate the
+ # project's drawings — treat that as an unsaved change.
+ self._drawing_tab.drawing_changed.connect(self._mark_dirty)
# Component buttons (dynamically generated per component, not in UI).
# Wrapped in a QScrollArea so many components can scroll horizontally.
@@ -2522,7 +2524,6 @@ class MainWindow(QMainWindow):
logger.info(f"Created component: {comp.name}")
# No sketch in the fresh component — drop the world triad.
self._sync_sketch_gizmo()
- self._refresh_drawing_source_catalog()
def _delete_component(self):
idx = self._get_active_component_index()
@@ -2541,7 +2542,6 @@ class MainWindow(QMainWindow):
self._refresh_lists()
logger.info("Deleted component")
- self._refresh_drawing_source_catalog()
def _on_component_button_clicked(self):
idx = self._get_active_component_index()
@@ -2551,7 +2551,6 @@ class MainWindow(QMainWindow):
self._assembly_view_active = False
self._refresh_lists()
self._redraw_bodies()
- self._refresh_drawing_source_catalog()
# Propagate the new selection to the drawing tab.
if self._current_component:
try:
@@ -2587,14 +2586,6 @@ class MainWindow(QMainWindow):
self._body_list.addItem(item)
self._refresh_operations_list()
- def _refresh_drawing_source_catalog(self) -> None:
- """Update the drawing tab's list of available components/assemblies."""
- component_ids = list(self._project.components.keys())
- assembly_ids = list(self._project.assemblies.keys()) if self._project.assemblies else []
- try:
- self._drawing_tab.set_source_catalog(component_ids, assembly_ids)
- except Exception as e:
- logger.warning(f"Failed to refresh drawing source catalog: {e}")
# ── Body operations list (per-body feature history) ───────────────────
@@ -7331,10 +7322,9 @@ class MainWindow(QMainWindow):
self._create_initial_component()
- # Re-point the drawing tab at the new project — its combo and
- # project reference still belong to the previous project.
+ # Re-point the drawing tab at the new project — its project
+ # reference still belongs to the previous project.
self._drawing_tab.set_project(self._project, self._kernel)
- self._refresh_drawing_source_catalog()
if self._current_component:
self._drawing_tab.set_active_component(self._current_component)
finally:
@@ -7684,7 +7674,6 @@ class MainWindow(QMainWindow):
# its source selection — it still holds the references captured
# at setup, which made Generate draw from the previous project.
self._drawing_tab.set_project(self._project, self._kernel)
- self._refresh_drawing_source_catalog()
if self._current_component:
self._drawing_tab.set_active_component(self._current_component)
finally:
@@ -7902,52 +7891,20 @@ class MainWindow(QMainWindow):
self._render_tab.set_assembly(assembly_parts, camera=viewport_camera)
def _load_drawing_tab_source(self) -> None:
- """Auto-populate the drawing tab with the current component or assembly."""
- from fluency.models.data_model import TechnicalDrawing, DrawingView
- from fluency.technical_drawing import generate_drawing, _STANDARD_VIEWS
-
- source_kind = "component"
- source_id = ""
+ """Point the drawing tab at the current component (or first assembly).
+ The drawing itself is owned by the project per source, so the
+ workbench re-adopts the stored drawing — preserving its views,
+ manually placed dimensions, and the auto-dimensions toggle.
+ """
# Prefer _current_component (set by component button clicks).
comp = self._current_component or self._project.get_active_component()
if comp and any(b.visible and b.geometry for b in comp.bodies.values()):
- source_kind = "component"
- source_id = comp.id
- elif self._project.assemblies:
- asm_id = next(iter(self._project.assemblies.keys()))
- source_kind = "assembly"
- source_id = asm_id
-
- if not source_id:
- return
-
- # Build default drawing with front, top, right, isometric views.
- views = []
- for kind in ("front", "top", "right", "isometric"):
- dir_, up_ = _STANDARD_VIEWS[kind]
- vname = kind.capitalize() if kind != "isometric" else "Isometric"
- views.append(DrawingView(kind=kind, name=vname, direction=dir_, up_vector=up_))
-
- drawing = TechnicalDrawing(
- name=f"Drawing of {comp.name if comp else 'Assembly'}",
- source_kind=source_kind,
- source_id=source_id,
- views=views,
- title=comp.name if comp else "Assembly Drawing",
- part_number="",
- material="",
- revision="A",
- )
-
- self._drawing_tab.set_drawing(drawing)
-
- # Auto-generate.
- try:
- result = generate_drawing(drawing, self._project, self._kernel)
- self._drawing_tab.set_render_result(result)
- except Exception as e:
- logger.warning(f"Failed to auto-generate drawing: {e}")
+ self._drawing_tab.set_active_component(comp)
+ else:
+ # No drawable component — let the widget fall back to the
+ # project's assemblies (or show "no source").
+ self._drawing_tab.generate()
def _on_tab_changed(self, index: int) -> None:
"""When the user switches to Render or Drawing tab, auto-load selected geometry."""
widget = self._ui.InputTab.widget(index)
diff --git a/src/fluency/ui/technical_drawing_widget.py b/src/fluency/ui/technical_drawing_widget.py
index 375d9c2..3abb8a7 100644
--- a/src/fluency/ui/technical_drawing_widget.py
+++ b/src/fluency/ui/technical_drawing_widget.py
@@ -1,16 +1,24 @@
"""
Technical Drawing workbench widget.
-Embeddable QWidget for creating, editing, and exporting technical drawings
+Embeddable QWidget for creating and exporting technical drawings
from component and assembly geometry. Designed to sit in MainWindow's
InputTab alongside Sketch, Code, and Render tabs.
+
+The UI is intentionally minimal: the source is resolved automatically
+from the currently selected component (or the first assembly), and a
+single Generate button builds the drawing from the checked views. The
+only remaining controls are the per-view options, the title block, and
+the PDF/SVG export buttons.
"""
from __future__ import annotations
-from typing import Dict, List, Optional
+from datetime import datetime
+import math
+from typing import Dict, List, Optional, Sequence, Tuple
-from PySide6.QtCore import Qt, Signal, QRectF, QPointF
+from PySide6.QtCore import Qt, QRectF, QPointF, Signal
from PySide6.QtGui import QPainter, QColor, QFont, QMouseEvent, QWheelEvent
from PySide6.QtWidgets import (
QWidget,
@@ -19,30 +27,30 @@ from PySide6.QtWidgets import (
QSplitter,
QPushButton,
QCheckBox,
- QRadioButton,
- QButtonGroup,
- QComboBox,
QLabel,
QLineEdit,
QGroupBox,
QScrollArea,
- QTableWidget,
- QTableWidgetItem,
- QHeaderView,
QTextEdit,
- QAbstractItemView,
+ QFileDialog,
QSizePolicy,
+ QButtonGroup,
)
-from fluency.models.data_model import TechnicalDrawing, DrawingView, DrawingAnnotation
+from fluency.models.data_model import (
+ TechnicalDrawing,
+ DrawingView,
+ DrawingAnnotation,
+)
from fluency.technical_drawing import (
DrawingRenderResult,
- DrawingPrimitive,
generate_drawing,
render_drawing,
export_drawing_svg,
export_drawing_pdf,
_STANDARD_VIEW_ROWS,
+ _DIM_FONT_W,
+ _DIM_FONT_H,
)
# Sheet layout constants.
@@ -50,9 +58,104 @@ _A3_MM_W = 420.0
_A3_MM_H = 297.0
+# ── 2D geometry helpers (view-plane model coordinates) ────────────────────
+
+
+def _point_to_segment(
+ p: Tuple[float, float],
+ a: Tuple[float, float],
+ b: Tuple[float, float],
+) -> Tuple[Tuple[float, float], float]:
+ """Closest point *q* on segment ab to *p*, and the distance |p-q|."""
+ abx, aby = b[0] - a[0], b[1] - a[1]
+ length_sq = abx * abx + aby * aby
+ if length_sq <= 1e-12:
+ return a, math.hypot(p[0] - a[0], p[1] - a[1])
+ t = ((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / length_sq
+ t = max(0.0, min(1.0, t))
+ q = (a[0] + abx * t, a[1] + aby * t)
+ return q, math.hypot(p[0] - q[0], p[1] - q[1])
+
+
+def _closest_point_on_segment(
+ p: Tuple[float, float],
+ a: Tuple[float, float],
+ b: Tuple[float, float],
+) -> Tuple[float, float]:
+ """Closest point on segment ab to *p*."""
+ q, _ = _point_to_segment(p, a, b)
+ return q
+
+
+def _closest_points_on_segments(
+ a1: Tuple[float, float],
+ a2: Tuple[float, float],
+ b1: Tuple[float, float],
+ b2: Tuple[float, float],
+) -> Tuple[Tuple[float, float], Tuple[float, float], float]:
+ """Closest points q1 (on a1a2) and q2 (on b1b2), plus their distance.
+
+ For non-parallel segments whose infinite lines cross inside both
+ segments this is the exact segment crossing. Otherwise (parallel
+ segments, or a crossing outside the segment spans) the closest pair
+ is found by projecting the four endpoints — which also yields the
+ true perpendicular distance for overlapping parallel edges.
+ """
+ r = (a2[0] - a1[0], a2[1] - a1[1])
+ s = (b2[0] - b1[0], b2[1] - b1[1])
+ denom = r[0] * s[1] - r[1] * s[0]
+ if abs(denom) > 1e-9:
+ qp = (b1[0] - a1[0], b1[1] - a1[1])
+ t = (qp[0] * s[1] - qp[1] * s[0]) / denom
+ u = (qp[0] * r[1] - qp[1] * r[0]) / denom
+ if 0.0 <= t <= 1.0 and 0.0 <= u <= 1.0:
+ q1 = (a1[0] + r[0] * t, a1[1] + r[1] * t)
+ q2 = (b1[0] + s[0] * u, b1[1] + s[1] * u)
+ return q1, q2, math.hypot(q1[0] - q2[0], q1[1] - q2[1])
+ best: Optional[Tuple[Tuple[float, float], Tuple[float, float], float]] = None
+ for p in (a1, a2):
+ q2, d = _point_to_segment(p, b1, b2)
+ if best is None or d < best[2]:
+ best = (p, q2, d)
+ for q in (b1, b2):
+ q1, d = _point_to_segment(q, a1, a2)
+ if best is None or d < best[2]:
+ best = (q1, q, d)
+ assert best is not None
+ return best
+
+
+def _line_intersection(
+ p1: Tuple[float, float],
+ p2: Tuple[float, float],
+ p3: Tuple[float, float],
+ p4: Tuple[float, float],
+) -> Optional[Tuple[float, float]]:
+ """Intersection of the two infinite lines through p1p2 / p3p4.
+
+ Returns None when the lines are parallel.
+ """
+ r = (p2[0] - p1[0], p2[1] - p1[1])
+ s = (p4[0] - p3[0], p4[1] - p3[1])
+ denom = r[0] * s[1] - r[1] * s[0]
+ if abs(denom) < 1e-9:
+ return None
+ qp = (p3[0] - p1[0], p3[1] - p1[1])
+ t = (qp[0] * s[1] - qp[1] * s[0]) / denom
+ return (p1[0] + r[0] * t, p1[1] + r[1] * t)
+
+
class DrawingCanvas(QWidget):
"""Custom QPainter canvas with zoom/pan for technical drawing display."""
+ # Feature picked while a dimension tool is active. Payload:
+ # {"kind": "segment", "view_id", "p1", "p2"} (model coords)
+ # {"kind": "circle", "view_id", "center", "radius"} (model coords)
+ # {"kind": "dimension", "candidate_key"}
+ featurePicked = Signal(object)
+ # Escape was pressed while a pick tool is active.
+ pickEscaped = Signal()
+
def __init__(self, parent=None):
super().__init__(parent)
self._render_result: Optional[DrawingRenderResult] = None
@@ -60,9 +163,11 @@ class DrawingCanvas(QWidget):
self._pan_x: float = 0.0
self._pan_y: float = 0.0
self._last_mouse: Optional[QPointF] = None
+ self._pick_mode: str = ""
self.setMinimumSize(400, 300)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setMouseTracking(True)
+ self.setFocusPolicy(Qt.StrongFocus)
def set_render_result(self, result: Optional[DrawingRenderResult]) -> None:
self._render_result = result
@@ -71,6 +176,14 @@ class DrawingCanvas(QWidget):
self._pan_y = 0.0
self.update()
+ def set_pick_mode(self, mode: str) -> None:
+ """Activate a picking tool ("distance","diameter","angle","delete").
+
+ An empty string returns to plain display mode.
+ """
+ self._pick_mode = mode or ""
+ self.setCursor(Qt.CrossCursor if mode else Qt.ArrowCursor)
+
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
@@ -84,7 +197,11 @@ class DrawingCanvas(QWidget):
painter.end()
return
- # Compute sheet rect in device coords with zoom/pan.
+ render_drawing(painter, self._render_result, self._sheet_rect())
+ painter.end()
+
+ def _sheet_rect(self) -> QRectF:
+ """Sheet rect in device coordinates (zoom/pan applied)."""
aspect = _A3_MM_W / _A3_MM_H
w = self.width()
h = self.height()
@@ -98,10 +215,111 @@ class DrawingCanvas(QWidget):
ox = (w - draw_w) / 2.0 + self._pan_x
oy = (h - draw_h) / 2.0 + self._pan_y
- sheet_rect = QRectF(ox, oy, draw_w, draw_h)
+ return QRectF(ox, oy, draw_w, draw_h)
- render_drawing(painter, self._render_result, sheet_rect)
- painter.end()
+ def to_sheet(self, pos: QPointF) -> Tuple[float, float]:
+ """Convert a device pixel position to sheet mm coordinates."""
+ rect = self._sheet_rect()
+ scale = rect.width() / _A3_MM_W
+ if scale <= 0:
+ return (0.0, 0.0)
+ return (
+ (pos.x() - rect.x()) / scale,
+ _A3_MM_H - (pos.y() - rect.y()) / scale,
+ )
+
+ def _pick_tolerance_mm(self) -> float:
+ """Pick radius in sheet mm (about 6 device pixels)."""
+ rect = self._sheet_rect()
+ scale = rect.width() / _A3_MM_W
+ return 6.0 / scale if scale > 0 else 6.0
+
+ def _sheet_to_model(
+ self, view_id: str, pt: Tuple[float, float]
+ ) -> Optional[Tuple[float, float]]:
+ """Invert the view's model→sheet transform for a sheet point."""
+ if self._render_result is None:
+ return None
+ t = self._render_result.view_transforms.get(view_id)
+ if t is None or t[0] <= 0:
+ return None
+ scale, ox, oy = t
+ return ((pt[0] - ox) / scale, (pt[1] - oy) / scale)
+
+ def _pick_feature(self, pos: QPointF) -> Optional[dict]:
+ """Hit-test the pick at *pos* for the active pick mode."""
+ result = self._render_result
+ if result is None:
+ return None
+ pt = self.to_sheet(pos)
+ tol = self._pick_tolerance_mm()
+
+ if self._pick_mode == "delete":
+ return self._pick_dimension(pt, tol)
+
+ best: Optional[dict] = None
+ best_d = tol
+ for prim in result.primitives:
+ if prim.candidate_key is not None or prim.style not in ("visible", "hidden"):
+ continue
+ if not prim.view_id:
+ continue
+ transform = result.view_transforms.get(prim.view_id)
+ if transform is None or transform[0] <= 0:
+ continue
+ scale = transform[0]
+
+ if prim.kind == "line" and len(prim.points) == 2:
+ q, d = _point_to_segment(pt, prim.points[0], prim.points[1])
+ if d < best_d:
+ best_d = d
+ m1 = self._sheet_to_model(prim.view_id, prim.points[0])
+ m2 = self._sheet_to_model(prim.view_id, prim.points[1])
+ if m1 and m2:
+ best = {
+ "kind": "segment",
+ "view_id": prim.view_id,
+ "p1": m1,
+ "p2": m2,
+ }
+ elif prim.kind == "circle" and prim.center and prim.radius:
+ d = abs(
+ math.hypot(
+ pt[0] - prim.center[0], pt[1] - prim.center[1]
+ )
+ - prim.radius
+ )
+ if d < best_d:
+ best_d = d
+ mc = self._sheet_to_model(prim.view_id, prim.center)
+ if mc:
+ best = {
+ "kind": "circle",
+ "view_id": prim.view_id,
+ "center": mc,
+ "radius": prim.radius / scale,
+ }
+ return best
+
+ def _pick_dimension(self, pt: Tuple[float, float], tol: float) -> Optional[dict]:
+ """Hit-test placed dimension primitives (for the delete tool)."""
+ assert self._render_result is not None
+ for prim in self._render_result.primitives:
+ if prim.style != "dimension" or not prim.candidate_key:
+ continue
+ if prim.kind == "text" and prim.points and prim.text:
+ x, y = prim.points[0]
+ w = len(prim.text) * _DIM_FONT_W
+ if (
+ x - 1.0 <= pt[0] <= x + w + 1.0
+ and y - 2.0 <= pt[1] <= y + _DIM_FONT_H
+ ):
+ return {"kind": "dimension", "candidate_key": prim.candidate_key}
+ elif prim.kind == "line" and len(prim.points) == 2:
+ _, d = _point_to_segment(pt, prim.points[0], prim.points[1])
+ if d <= tol:
+ return {"kind": "dimension", "candidate_key": prim.candidate_key}
+ return None
def wheelEvent(self, event: QWheelEvent) -> None:
factor = 1.1 if event.angleDelta().y() > 0 else 0.9
@@ -112,6 +330,11 @@ class DrawingCanvas(QWidget):
if event.button() == Qt.MiddleButton:
self._last_mouse = event.position()
self.setCursor(Qt.ClosedHandCursor)
+ return
+ if event.button() == Qt.LeftButton and self._pick_mode:
+ info = self._pick_feature(event.position())
+ if info is not None:
+ self.featurePicked.emit(info)
def mouseMoveEvent(self, event: QMouseEvent) -> None:
if self._last_mouse is not None:
@@ -124,15 +347,22 @@ class DrawingCanvas(QWidget):
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MiddleButton:
self._last_mouse = None
- self.setCursor(Qt.ArrowCursor)
+ self.setCursor(Qt.CrossCursor if self._pick_mode else Qt.ArrowCursor)
+
+ def keyPressEvent(self, event) -> None:
+ if event.key() == Qt.Key_Escape and self._pick_mode:
+ self.pickEscaped.emit()
+ return
+ super().keyPressEvent(event)
class TechnicalDrawingWidget(QWidget):
"""Embeddable technical drawing workbench."""
+ # Emitted whenever the drawing definition is modified by the user
+ # (dimensions added/removed, auto toggle, view changes) so the host
+ # can mark the project dirty.
drawing_changed = Signal()
- source_requested = Signal(str, str) # source_kind, source_id
- export_requested = Signal(str, object) # format ("pdf"/"svg"), drawing
def __init__(self, parent=None):
super().__init__(parent)
@@ -142,13 +372,24 @@ class TechnicalDrawingWidget(QWidget):
self._project = None
self._kernel = None
- self._component_ids: List[str] = []
- self._assembly_ids: List[str] = []
+ # Source last selected via set_active_component / set_drawing.
+ # Falls back to the project's active component when unset.
+ self._active_source_kind: Optional[str] = None
+ self._active_source_id: Optional[str] = None
+
+ # First pick of the active dimension tool (edge/circle info dict).
+ self._first_pick: Optional[dict] = None
+
self._view_checkboxes: Dict[str, QCheckBox] = {}
self._hidden_line_checks: Dict[str, QCheckBox] = {}
- self._selected_dim_keys: set = set()
self._centerline_checks: Dict[str, QCheckBox] = {}
+ self._title_edit: Optional[QLineEdit] = None
+ self._part_no_edit: Optional[QLineEdit] = None
+ self._material_edit: Optional[QLineEdit] = None
+ self._revision_edit: Optional[QLineEdit] = None
+ self._notes_edit: Optional[QTextEdit] = None
+
self._init_ui()
# ── Public API ──────────────────────────────────────────────────────────
@@ -158,24 +399,14 @@ class TechnicalDrawingWidget(QWidget):
self._project = project
self._kernel = kernel
- def set_source_catalog(
- self, component_ids: List[str], assembly_ids: List[str]
- ) -> None:
- """Update the source combo with available components/assemblies."""
- self._component_ids = component_ids
- self._assembly_ids = assembly_ids
- self._rebuild_source_combo()
-
def set_active_component(self, component) -> None:
- """Update the drawing tab to use the given component/assembly as source.
- Selects it in the combo and regenerates."""
- self._mode_component_btn.setChecked(True)
- # Ensure the combo is in component mode and has this component selected.
- self._rebuild_source_combo()
- idx = self._source_combo.findData(component.id)
- if idx >= 0:
- self._source_combo.setCurrentIndex(idx)
- # Delegate to the same path as the Generate button.
+ """Use the given component as the drawing source and regenerate."""
+ self._active_source_kind = "component"
+ self._active_source_id = component.id
+ self._on_generate()
+
+ def generate(self) -> None:
+ """Public entry point: generate for the current source."""
self._on_generate()
def set_drawing(self, drawing: Optional[TechnicalDrawing]) -> None:
@@ -183,21 +414,24 @@ class TechnicalDrawingWidget(QWidget):
self._drawing = drawing
self._render_result = None
if drawing is not None:
+ self._active_source_kind = drawing.source_kind
+ self._active_source_id = drawing.source_id
self._populate_from_drawing(drawing)
+ self._auto_dim_check.blockSignals(True)
+ self._auto_dim_check.setChecked(drawing.auto_dimensions)
+ self._auto_dim_check.blockSignals(False)
+ self._clear_tool_selection()
+ self._first_pick = None
+ self._canvas.set_pick_mode("")
self._canvas.set_render_result(None)
- self._generate_btn.setEnabled(self._drawing is not None)
- self._refresh_annotation_table()
+ self._update_export_state()
def get_drawing(self) -> Optional[TechnicalDrawing]:
"""Get the current drawing (may be modified by UI)."""
if self._drawing is None:
return None
self._sync_views_to_drawing()
- self._drawing.title = self._title_edit.text()
- self._drawing.part_number = self._part_no_edit.text()
- self._drawing.material = self._material_edit.text()
- self._drawing.revision = self._revision_edit.text()
- self._drawing.notes = self._notes_edit.toPlainText()
+ self._sync_title_block_to_drawing()
return self._drawing
def set_render_result(self, result: Optional[DrawingRenderResult]) -> None:
@@ -206,19 +440,13 @@ class TechnicalDrawingWidget(QWidget):
self._canvas.set_render_result(result)
if result is not None:
self._status_label.setText(
- f"Generated — {len(result.primitives)} primitives, "
- f"{len(result.candidates)} candidates"
+ f"Generated — {len(result.primitives)} primitives"
)
if result.warnings:
self._status_label.setText(
self._status_label.text() + f" ({len(result.warnings)} warnings)"
)
- self._export_pdf_btn.setEnabled(True)
- self._export_svg_btn.setEnabled(True)
- else:
- self._export_pdf_btn.setEnabled(False)
- self._export_svg_btn.setEnabled(False)
- self._refresh_dim_table()
+ self._update_export_state()
# ── UI construction ─────────────────────────────────────────────────────
@@ -231,32 +459,6 @@ class TechnicalDrawingWidget(QWidget):
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(4, 4, 4, 4)
- # Source selection.
- src_group = QGroupBox("Source")
- src_layout = QVBoxLayout(src_group)
-
- mode_layout = QHBoxLayout()
- self._mode_component_btn = QRadioButton("Component")
- self._mode_assembly_btn = QRadioButton("Assembly")
- self._mode_component_btn.setChecked(True)
- # Rebuild the combo on user click so the selected id always matches
- # the active mode — otherwise Generate combines a kind from one mode
- # with an id from the other.
- self._mode_component_btn.clicked.connect(self._rebuild_source_combo)
- self._mode_assembly_btn.clicked.connect(self._rebuild_source_combo)
- mode_layout.addWidget(self._mode_component_btn)
- mode_layout.addWidget(self._mode_assembly_btn)
- src_layout.addLayout(mode_layout)
-
- self._source_combo = QComboBox()
- src_layout.addWidget(self._source_combo)
-
- self._use_selection_btn = QPushButton("Use Current Selection")
- self._use_selection_btn.clicked.connect(self._on_use_selection)
- src_layout.addWidget(self._use_selection_btn)
-
- left_layout.addWidget(src_group)
-
# Views.
views_group = QGroupBox("Views")
views_layout = QVBoxLayout(views_group)
@@ -289,38 +491,84 @@ class TechnicalDrawingWidget(QWidget):
# Default: Front, Top, Right, Isometric.
for vid in ("front", "top", "right", "isometric"):
self._view_checkboxes[vid].setChecked(True)
- # Match the DrawingView model defaults (hidden lines + centerlines
- # off) so Generate produces the same drawing as the tab-switch
- # auto-load in MainWindow._load_drawing_tab_source.
- for cb in self._hidden_line_checks.values():
- cb.setChecked(False)
- for cb in self._centerline_checks.values():
- cb.setChecked(False)
left_layout.addWidget(views_group)
+ # Dimensions (manual placement).
+ dim_group = QGroupBox("Dimensions")
+ dim_layout = QVBoxLayout(dim_group)
+
+ tool_row = QHBoxLayout()
+ self._tool_group = QButtonGroup(self)
+ self._tool_group.setExclusive(True)
+ self._tool_buttons: Dict[str, QPushButton] = {}
+ for tool, label, tip in (
+ (
+ "distance",
+ "Distance",
+ "Pick two edges — the dimension line is placed "
+ "perpendicular to them, measuring the distance between",
+ ),
+ (
+ "diameter",
+ "Diameter",
+ "Pick a circle — its diameter is added",
+ ),
+ (
+ "angle",
+ "Angle",
+ "Pick two edges — the angle between them is added",
+ ),
+ ):
+ btn = QPushButton(label)
+ btn.setCheckable(True)
+ btn.setToolTip(tip)
+ btn.toggled.connect(
+ lambda checked, t=tool: self._on_tool_toggled(t, checked)
+ )
+ self._tool_group.addButton(btn)
+ self._tool_buttons[tool] = btn
+ tool_row.addWidget(btn)
+ dim_layout.addLayout(tool_row)
+
+ self._delete_dim_btn = QPushButton("Delete")
+ self._delete_dim_btn.setToolTip("Click a placed manual dimension to remove it")
+ self._delete_dim_btn.clicked.connect(self._on_delete_clicked)
+ dim_layout.addWidget(self._delete_dim_btn)
+
+ self._auto_dim_check = QCheckBox("Auto dimensions")
+ self._auto_dim_check.setChecked(False)
+ self._auto_dim_check.setToolTip(
+ "Automatically place dimensions on all views. Off by default; "
+ "manual dimensions added above are always kept."
+ )
+ self._auto_dim_check.toggled.connect(self._on_auto_toggled)
+ dim_layout.addWidget(self._auto_dim_check)
+
+ self._clear_dims_btn = QPushButton("Clear manual dimensions")
+ self._clear_dims_btn.setToolTip("Remove all manually placed dimensions")
+ self._clear_dims_btn.clicked.connect(self._on_clear_clicked)
+ dim_layout.addWidget(self._clear_dims_btn)
+
+ left_layout.addWidget(dim_group)
+
# Sheet / title block.
sheet_group = QGroupBox("Title Block")
sheet_layout = QVBoxLayout(sheet_group)
for lbl, attr in [
- ("Title", "title"),
- ("Part No.", "part_number"),
- ("Material", "material"),
- ("Revision", "revision"),
+ ("Title", "_title_edit"),
+ ("Part No.", "_part_no_edit"),
+ ("Material", "_material_edit"),
+ ("Revision", "_revision_edit"),
]:
row = QHBoxLayout()
row.addWidget(QLabel(lbl))
edit = QLineEdit()
- setattr(self, f"_{attr}_edit", edit)
+ setattr(self, attr, edit)
row.addWidget(edit)
sheet_layout.addLayout(row)
- self._title_edit = self.__dict__.get("_title_edit", QLineEdit())
- self._part_no_edit = self.__dict__.get("_part_no_edit", QLineEdit())
- self._material_edit = self.__dict__.get("_material_edit", QLineEdit())
- self._revision_edit = self.__dict__.get("_revision_edit", QLineEdit())
-
sheet_layout.addWidget(QLabel("Notes:"))
self._notes_edit = QTextEdit()
self._notes_edit.setMaximumHeight(80)
@@ -332,29 +580,13 @@ class TechnicalDrawingWidget(QWidget):
actions_group = QGroupBox("Actions")
actions_layout = QVBoxLayout(actions_group)
- self._auto_generate_btn = QPushButton("Auto-Generate Drawing")
- self._auto_generate_btn.setToolTip(
- "Create a new drawing from current source with optimal views and dimensions"
+ self._generate_btn = QPushButton("Generate")
+ self._generate_btn.setToolTip(
+ "Build a drawing of the selected component with the views checked above"
)
- self._auto_generate_btn.clicked.connect(self._on_auto_generate)
- actions_layout.addWidget(self._auto_generate_btn)
-
- self._generate_btn = QPushButton("Generate / Regenerate")
- self._generate_btn.setEnabled(True)
self._generate_btn.clicked.connect(self._on_generate)
actions_layout.addWidget(self._generate_btn)
- # Dimension options.
- dim_row = QHBoxLayout()
- self._auto_dim_check = QCheckBox("Auto dimensions")
- self._auto_dim_check.setChecked(True)
- dim_row.addWidget(self._auto_dim_check)
- actions_layout.addLayout(dim_row)
-
- self._save_btn = QPushButton("Save Drawing")
- self._save_btn.clicked.connect(self._on_save)
- actions_layout.addWidget(self._save_btn)
-
export_row = QHBoxLayout()
self._export_pdf_btn = QPushButton("Export PDF")
self._export_pdf_btn.setEnabled(False)
@@ -383,109 +615,77 @@ class TechnicalDrawingWidget(QWidget):
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
- # ── Right side: canvas + annotation table ───────────────────────
- right = QWidget()
- right_layout = QVBoxLayout(right)
- right_layout.setContentsMargins(0, 0, 0, 0)
-
+ # ── Right side: canvas ──────────────────────────────────────────
self._canvas = DrawingCanvas()
- right_layout.addWidget(self._canvas, 1)
-
- # Annotation table.
- ann_group = QGroupBox("Annotations")
- ann_layout = QVBoxLayout(ann_group)
-
- self._annotation_table = QTableWidget(0, 5)
- self._annotation_table.setHorizontalHeaderLabels(
- ["Kind", "Value / Text", "Visible", "Orphaned", "Action"]
- )
- self._annotation_table.horizontalHeader().setSectionResizeMode(
- QHeaderView.Stretch
- )
- self._annotation_table.setMaximumHeight(150)
- self._annotation_table.setSelectionBehavior(QAbstractItemView.SelectRows)
- ann_layout.addWidget(self._annotation_table)
-
- add_ann_row = QHBoxLayout()
- add_ann_row.addWidget(QLabel("Add:"))
- self._add_note_btn = QPushButton("Note")
- self._add_note_btn.clicked.connect(self._on_add_note)
- add_ann_row.addWidget(self._add_note_btn)
- add_ann_row.addStretch()
- ann_layout.addLayout(add_ann_row)
-
- right_layout.addWidget(ann_group)
-
- # Dimension candidates table (for user selection).
- dim_group = QGroupBox("Dimension Candidates")
- dim_layout = QVBoxLayout(dim_group)
-
- self._dim_table = QTableWidget(0, 5)
- self._dim_table.setHorizontalHeaderLabels(["View", "Kind", "Value", "Include", "Action"])
- self._dim_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
- self._dim_table.setMaximumHeight(120)
- self._dim_table.setSelectionBehavior(QAbstractItemView.SelectRows)
- dim_layout.addWidget(self._dim_table)
-
- self._refresh_dim_table()
- right_layout.addWidget(dim_group)
+ self._canvas.featurePicked.connect(self._on_feature_picked)
+ self._canvas.pickEscaped.connect(self._cancel_pick)
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(scroll)
- splitter.addWidget(right)
+ splitter.addWidget(self._canvas)
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
layout.addWidget(splitter)
# ── Internal helpers ──────────────────────────────────────────────────
- def _rebuild_source_combo(self) -> None:
- self._source_combo.clear()
- if self._mode_component_btn.isChecked():
- for cid in self._component_ids:
- self._source_combo.addItem(cid, userData=cid)
- else:
- for aid in self._assembly_ids:
- self._source_combo.addItem(aid, userData=aid)
def _resolve_source(self):
- """Resolve ``(source_kind, source_id)`` from the current UI state.
+ """Resolve ``(source_kind, source_id)``.
- Prefers the combo selection when it still refers to an id present in
- the current project. Otherwise falls back to the project's active
- component, then the first assembly — mirroring the tab-switch
- auto-load in ``MainWindow._load_drawing_tab_source``. Returns
- ``(None, None)`` when no source is available.
+ Prefers the source last selected via ``set_active_component`` or
+ ``set_drawing``; otherwise falls back to the project's active
+ component, then the first assembly. Returns ``(None, None)`` when
+ no source is available.
"""
- source_kind = (
- "component" if self._mode_component_btn.isChecked() else "assembly"
- )
- source_id = self._source_combo.currentData()
+ if self._active_source_id and self._project is not None:
+ catalog = (
+ self._project.components
+ if self._active_source_kind == "component"
+ else self._project.assemblies
+ )
+ if self._active_source_id in catalog:
+ return self._active_source_kind, self._active_source_id
+ self._active_source_kind = None
+ self._active_source_id = None
- # The combo may still hold an id from a previous project after a
- # project swap — treat that as unselected.
- catalog = (
- self._project.components
- if source_kind == "component"
- else self._project.assemblies
- )
- if source_id not in catalog:
- source_id = None
-
- if not source_id:
+ if self._project is not None:
comp = self._project.get_active_component()
if comp and any(b.visible and b.geometry for b in comp.bodies.values()):
return "component", comp.id
if self._project.assemblies:
return "assembly", next(iter(self._project.assemblies.keys()))
- return None, None
+ return None, None
+
+ def _build_views(self) -> List[DrawingView]:
+ """Build the DrawingView list from the view checkboxes."""
+ views: List[DrawingView] = []
+ for vid, vname, vdir, vup in _STANDARD_VIEW_ROWS:
+ if not self._view_checkboxes[vid].isChecked():
+ continue
+ views.append(
+ DrawingView(
+ name=vname,
+ kind=vid,
+ direction=vdir,
+ up_vector=vup,
+ show_hidden_lines=self._hidden_line_checks[vid].isChecked(),
+ show_centerlines=self._centerline_checks[vid].isChecked(),
+ )
+ )
+ if not views:
+ # Nothing checked — fall back to the full standard set so
+ # Generate always produces a drawing.
+ for vid, vname, vdir, vup in _STANDARD_VIEW_ROWS:
+ views.append(
+ DrawingView(kind=vid, name=vname, direction=vdir, up_vector=vup)
+ )
+ return views
- return source_kind, source_id
def _populate_from_drawing(self, drawing: TechnicalDrawing) -> None:
- """Sync UI controls from a drawing definition."""
+ """Sync view and title-block controls from a drawing definition."""
# Block checkbox signals during population to avoid triggering
- # _on_view_toggled mid-update (which would call _on_generate with
- # partially-set checkboxes and clear the views).
+ # _on_view_toggled mid-update.
for cb in self._view_checkboxes.values():
cb.blockSignals(True)
for cb in self._hidden_line_checks.values():
@@ -494,47 +694,30 @@ class TechnicalDrawingWidget(QWidget):
cb.blockSignals(True)
try:
- # Source mode.
- if drawing.source_kind == "component":
- self._mode_component_btn.setChecked(True)
- else:
- self._mode_assembly_btn.setChecked(True)
-
- # Select source in combo (match by text since ID may not be in the list yet).
- idx = self._source_combo.findText(drawing.source_id)
- if idx >= 0:
- self._source_combo.setCurrentIndex(idx)
- else:
- # Fallback: set userData directly so currentData() returns it.
- self._source_combo.clear()
- if drawing.source_kind == "component":
- for cid in self._component_ids:
- self._source_combo.addItem(cid, userData=cid)
- else:
- for aid in self._assembly_ids:
- self._source_combo.addItem(aid, userData=aid)
- idx = self._source_combo.findText(drawing.source_id)
- if idx >= 0:
- self._source_combo.setCurrentIndex(idx)
- # Views.
- enabled_views = {v.kind for v in drawing.views}
+ enabled = {v.kind: v for v in drawing.views}
for vid, cb in self._view_checkboxes.items():
- cb.setChecked(vid in enabled_views)
+ cb.setChecked(vid in enabled)
+ for vid, cb in self._hidden_line_checks.items():
+ v = enabled.get(vid)
+ cb.setChecked(v is not None and v.show_hidden_lines)
+ for vid, cb in self._centerline_checks.items():
+ v = enabled.get(vid)
+ cb.setChecked(v is not None and v.show_centerlines)
- for v in drawing.views:
- if v.kind in self._hidden_line_checks:
- self._hidden_line_checks[v.kind].setChecked(v.show_hidden_lines)
- if v.kind in self._centerline_checks:
- self._centerline_checks[v.kind].setChecked(v.show_centerlines)
+ # Auto-dimensions toggle.
+ self._auto_dim_check.blockSignals(True)
+ self._auto_dim_check.setChecked(drawing.auto_dimensions)
+ self._auto_dim_check.blockSignals(False)
# Title block.
+ assert self._title_edit is not None and self._part_no_edit is not None
+ assert self._material_edit is not None and self._revision_edit is not None
+ assert self._notes_edit is not None
self._title_edit.setText(drawing.title)
self._part_no_edit.setText(drawing.part_number)
self._material_edit.setText(drawing.material)
self._revision_edit.setText(drawing.revision)
self._notes_edit.setPlainText(drawing.notes)
-
- self._refresh_annotation_table()
finally:
for cb in self._view_checkboxes.values():
cb.blockSignals(False)
@@ -547,254 +730,286 @@ class TechnicalDrawingWidget(QWidget):
"""Write current view checkboxes back to the drawing."""
if self._drawing is None:
return
- new_views: List[DrawingView] = []
- for vid, vname, vdir, vup in _STANDARD_VIEW_ROWS:
- if not self._view_checkboxes[vid].isChecked():
- continue
- # Keep existing view if present, else create.
- existing = None
- for v in self._drawing.views:
- if v.kind == vid:
- existing = v
- break
- if existing is not None:
- existing.show_hidden_lines = self._hidden_line_checks[vid].isChecked()
- existing.show_centerlines = self._centerline_checks[vid].isChecked()
- new_views.append(existing)
- else:
- new_views.append(
- DrawingView(
- name=vname,
- kind=vid,
- direction=vdir,
- up_vector=vup,
- show_hidden_lines=self._hidden_line_checks[vid].isChecked(),
- show_centerlines=self._centerline_checks[vid].isChecked(),
- )
- )
- self._drawing.views = new_views
+ self._drawing.views = self._build_views()
- def _refresh_annotation_table(self) -> None:
- """Refresh annotation table from current drawing."""
- self._annotation_table.setRowCount(0)
+ def _sync_title_block_to_drawing(self) -> None:
+ """Write title-block edits back to the drawing."""
if self._drawing is None:
return
- for i, ann in enumerate(self._drawing.annotations):
- self._annotation_table.insertRow(i)
- self._annotation_table.setItem(
- i, 0, QTableWidgetItem(ann.kind)
- )
- self._annotation_table.setItem(
- i, 1, QTableWidgetItem(
- ann.text or ""
- )
- )
+ assert self._title_edit is not None and self._part_no_edit is not None
+ assert self._material_edit is not None and self._revision_edit is not None
+ assert self._notes_edit is not None
+ self._drawing.title = self._title_edit.text()
+ self._drawing.part_number = self._part_no_edit.text()
+ self._drawing.material = self._material_edit.text()
+ self._drawing.revision = self._revision_edit.text()
+ self._drawing.notes = self._notes_edit.toPlainText()
- vis_cb = QCheckBox()
- vis_cb.setChecked(ann.visible)
- vis_cb.toggled.connect(
- lambda checked, a=ann: setattr(a, "visible", checked)
- )
- self._annotation_table.setCellWidget(i, 2, vis_cb)
-
- orphan_label = QLabel("")
- self._annotation_table.setCellWidget(i, 3, orphan_label)
-
- del_btn = QPushButton("×")
- del_btn.setFixedWidth(24)
- del_btn.clicked.connect(
- lambda checked=False, aid=ann.id: self._delete_annotation(aid)
- )
- self._annotation_table.setCellWidget(i, 4, del_btn)
-
- def _refresh_dim_table(self) -> None:
- """Refresh the dimension candidates table from render result."""
- self._dim_table.setRowCount(0)
- if self._render_result is None:
- return
- for i, c in enumerate(self._render_result.candidates):
- self._dim_table.insertRow(i)
- self._dim_table.setItem(i, 0, QTableWidgetItem(c.view_id))
- self._dim_table.setItem(i, 1, QTableWidgetItem(c.kind))
- self._dim_table.setItem(i, 2, QTableWidgetItem(c.label))
-
- inc_cb = QCheckBox()
- inc_cb.setChecked(c.key in self._selected_dim_keys)
- inc_cb.toggled.connect(lambda checked, k=c.key: self._on_dim_include(k, checked))
- self._dim_table.setCellWidget(i, 3, inc_cb)
-
- del_btn = QPushButton("×")
- del_btn.setFixedWidth(24)
- del_btn.clicked.connect(lambda checked=False, k=c.key: self._on_dim_remove(k))
- self._dim_table.setCellWidget(i, 4, del_btn)
-
- def _on_dim_include(self, key: str, checked: bool) -> None:
- if checked:
- self._selected_dim_keys.add(key)
- else:
- self._selected_dim_keys.discard(key)
+ def _update_export_state(self) -> None:
+ has_result = self._render_result is not None
+ self._export_pdf_btn.setEnabled(has_result)
+ self._export_svg_btn.setEnabled(has_result)
def _on_view_toggled(self, vid: str) -> None:
"""Regenerate when any view/HL/CL checkbox changes."""
- if self._drawing is not None and self._project is not None and self._kernel is not None:
- try:
- result = generate_drawing(self._drawing, self._project, self._kernel)
- self.set_render_result(result)
- except Exception as e:
- self._status_label.setText(f"Regeneration failed: {e}")
-
- def _delete_annotation(self, ann_id: str) -> None:
- if self._drawing is None:
- return
- self._drawing.annotations = [
- a for a in self._drawing.annotations if a.id != ann_id
- ]
- self._refresh_annotation_table()
+ if self._drawing is not None:
+ self._on_generate()
+ self.drawing_changed.emit()
# ── Slots ──────────────────────────────────────────────────────────────
- def _on_use_selection(self) -> None:
- src_kind = (
- "component" if self._mode_component_btn.isChecked() else "assembly"
- )
- self.source_requested.emit(src_kind, "")
-
- def _on_auto_generate(self) -> None:
- """Create a new drawing from current source with optimal views."""
- from fluency.models.data_model import TechnicalDrawing, DrawingView
-
- if self._project is None or self._kernel is None:
- self._status_label.setText("No project/kernel available")
- return
-
- source_kind, source_id = self._resolve_source()
- if not source_id:
- self._status_label.setText("No source selected")
- return
-
- # Build default drawing with standard views.
- views = []
- for vid, vname, vdir, vup in _STANDARD_VIEW_ROWS:
- if self._view_checkboxes[vid].isChecked():
- views.append(
- DrawingView(
- kind=vid,
- name=vname,
- direction=vdir,
- show_hidden_lines=self._hidden_line_checks[vid].isChecked(),
- show_centerlines=self._centerline_checks[vid].isChecked(),
- )
- )
-
- comp = None
- if source_kind == "component":
- comp = self._project.get_component_by_id(source_id)
-
- drawing = TechnicalDrawing(
- name=f"Drawing of {comp.name if comp else 'Assembly'}",
- source_kind=source_kind,
- source_id=source_id,
- views=views,
- title=comp.name if comp else "Assembly Drawing",
- part_number="",
- material="",
- revision="A",
- auto_dimensions=self._auto_dim_check.isChecked(),
- )
-
- self._drawing = drawing
- self._populate_from_drawing(drawing)
- self._on_generate()
-
def _on_generate(self) -> None:
- """Generate the drawing — always re-reads the current source and
- builds a fresh TechnicalDrawing, same as tab-switching."""
- from fluency.models.data_model import TechnicalDrawing, DrawingView
+ """Generate a drawing for the current source.
+ Reuses the existing drawing (preserving title-block edits) when it
+ already targets the resolved source, otherwise builds a fresh one
+ from the view checkboxes.
+ """
if self._project is None or self._kernel is None:
self._status_label.setText("No project/kernel available")
return
- # Determine source: prefer combo selection, fall back to the
- # project's active component / first assembly (see _resolve_source).
source_kind, source_id = self._resolve_source()
- if not source_id:
+ if source_kind is None or not source_id:
self._status_label.setText("No source available for drawing")
return
- # Build views from current checkbox state.
- views = []
- for vid, vname, vdir, vup in _STANDARD_VIEW_ROWS:
- if self._view_checkboxes[vid].isChecked():
- views.append(
- DrawingView(
- kind=vid,
- name=vname,
- direction=vdir,
- up_vector=vup,
- show_hidden_lines=self._hidden_line_checks[vid].isChecked(),
- show_centerlines=self._centerline_checks[vid].isChecked(),
- )
- )
- if not views:
- for vid, vname, vdir, vup in _STANDARD_VIEW_ROWS:
- views.append(
- DrawingView(kind=vid, name=vname, direction=vdir, up_vector=vup)
- )
-
- # Resolve component name for the title.
comp = None
if source_kind == "component":
comp = self._project.get_component_by_id(source_id)
- drawing = TechnicalDrawing(
- name=f"Drawing of {comp.name if comp else 'Assembly'}",
- source_kind=source_kind,
- source_id=source_id,
- views=views,
- title=comp.name if comp else "Assembly Drawing",
- part_number="",
- material="",
- revision="A",
- auto_dimensions=self._auto_dim_check.isChecked(),
- )
+ if (
+ self._drawing is not None
+ and self._drawing.source_kind == source_kind
+ and self._drawing.source_id == source_id
+ ):
+ drawing = self._drawing
+ else:
+ # Adopt the drawing stored with the project for this source —
+ # that is where manual dimensions and the auto toggle persist.
+ drawing = self._project.get_drawing_for(source_kind, source_id)
+ if drawing is None:
+ drawing = TechnicalDrawing(
+ name=f"Drawing of {comp.name if comp else 'Assembly'}",
+ source_kind=source_kind,
+ source_id=source_id,
+ title=comp.name if comp else "Assembly Drawing",
+ revision="A",
+ )
+ self._project.add_drawing(drawing)
+ drawing.views = self._build_views()
+ # Carry user edits forward before repopulating the UI from the
+ # drawing (which would otherwise overwrite them).
+ self._sync_title_block_to_drawing()
self._drawing = drawing
self._populate_from_drawing(drawing)
try:
result = generate_drawing(drawing, self._project, self._kernel)
self.set_render_result(result)
- self.drawing_changed.emit()
except Exception as e:
self._status_label.setText(f"Generation failed: {e}")
- def _on_export(self, fmt: str) -> None:
- if self._drawing is None:
- return
- self._sync_views_to_drawing()
- self.export_requested.emit(fmt, self._drawing)
+ # ── Dimension tools ───────────────────────────────────────────────────
- def _on_add_note(self) -> None:
- if self._drawing is None:
- return
- ann = DrawingAnnotation(kind="note", text="New note")
- self._drawing.annotations.append(ann)
- self._refresh_annotation_table()
+ def _on_tool_toggled(self, tool: str, checked: bool) -> None:
+ """A dimension pick tool was toggled on/off."""
+ if checked:
+ self._first_pick = None
+ self._canvas.set_pick_mode(tool)
+ prompts = {
+ "distance": "Distance: click the first edge",
+ "diameter": "Diameter: click a circle",
+ "angle": "Angle: click the first edge",
+ }
+ self._status_label.setText(prompts.get(tool, ""))
+ else:
+ self._canvas.set_pick_mode("")
- def _on_save(self) -> None:
- """Save the current drawing."""
- if self._drawing is None:
- return
- self._sync_views_to_drawing()
- from pathlib import Path
- from qtpy.QtWidgets import QFileDialog
- path, _ = QFileDialog.getSaveFileName(
- self, "Save Drawing", "", "Drawing Files (*.drawing.json)"
+ def _on_delete_clicked(self) -> None:
+ self._first_pick = None
+ self._canvas.set_pick_mode("delete")
+ self._status_label.setText(
+ "Delete: click a manual dimension to remove it (Esc cancels)"
)
+
+ def _cancel_pick(self) -> None:
+ """Deactivate all pick tools (Esc or a completed pick)."""
+ self._clear_tool_selection()
+ self._canvas.set_pick_mode("")
+ self._first_pick = None
+
+ def _clear_tool_selection(self) -> None:
+ """Uncheck every dimension tool button.
+
+ ``setChecked(False)`` is a no-op for the currently checked button
+ of an *exclusive* QButtonGroup, so exclusivity is dropped while
+ the buttons are cleared and restored afterwards.
+ """
+ self._tool_group.setExclusive(False)
+ for btn in self._tool_buttons.values():
+ btn.blockSignals(True)
+ btn.setChecked(False)
+ btn.blockSignals(False)
+ self._tool_group.setExclusive(True)
+
+ def _on_auto_toggled(self, checked: bool) -> None:
+ if self._drawing is None:
+ return
+ self._drawing.auto_dimensions = checked
+ self._on_generate()
+ self.drawing_changed.emit()
+
+ def _on_clear_clicked(self) -> None:
+ if self._drawing is None:
+ return
+ remaining = [a for a in self._drawing.annotations if not a.dimension_kind]
+ removed = len(self._drawing.annotations) - len(remaining)
+ if not removed:
+ self._status_label.setText("No manual dimensions to clear")
+ return
+ self._drawing.annotations = remaining
+ self._drawing.modified_at = datetime.now()
+ self._on_generate()
+ self.drawing_changed.emit()
+ self._status_label.setText(f"Removed {removed} manual dimension(s)")
+
+ def _on_feature_picked(self, info: dict) -> None:
+ mode = self._canvas._pick_mode
+ if mode == "delete":
+ self._on_delete_pick(info)
+ elif mode == "diameter":
+ self._on_diameter_pick(info)
+ elif mode in ("distance", "angle"):
+ self._on_edge_pick(info, mode)
+
+ def _on_edge_pick(self, info: dict, mode: str) -> None:
+ if (
+ self._first_pick is None
+ or info.get("view_id") != self._first_pick.get("view_id")
+ ):
+ # First edge (or picked in a different view: restart there).
+ self._first_pick = info
+ self._status_label.setText(
+ "Select the second edge in the same view (Esc cancels)"
+ )
+ return
+
+ a1, a2 = self._first_pick["p1"], self._first_pick["p2"]
+ b1, b2 = info["p1"], info["p2"]
+ view_id = info["view_id"]
+
+ if mode == "distance":
+ q1, q2, dist = _closest_points_on_segments(a1, a2, b1, b2)
+ if dist < 0.01:
+ self._status_label.setText(
+ "The two edges coincide — no distance to measure"
+ )
+ return
+ self._add_manual_dimension(
+ "length",
+ anchors=(q1, q2),
+ view_id=view_id,
+ direction=((q2[0] - q1[0]) / dist, (q2[1] - q1[1]) / dist),
+ done_msg=f"Distance dimension added: {dist:.2f}",
+ )
+ else: # angle
+ vertex = _line_intersection(a1, a2, b1, b2)
+ if vertex is None:
+ self._status_label.setText(
+ "The two edges are parallel — no angle to measure"
+ )
+ return
+ arm1 = _closest_point_on_segment(vertex, a1, a2)
+ arm2 = _closest_point_on_segment(vertex, b1, b2)
+ d1 = math.hypot(arm1[0] - vertex[0], arm1[1] - vertex[1])
+ d2 = math.hypot(arm2[0] - vertex[0], arm2[1] - vertex[1])
+ if d1 < 1e-6 or d2 < 1e-6:
+ self._status_label.setText(
+ "The edges only meet at an endpoint — no angle to measure"
+ )
+ return
+ self._add_manual_dimension(
+ "angle",
+ anchors=(vertex, arm1, arm2),
+ view_id=view_id,
+ done_msg="Angle dimension added",
+ )
+
+ def _on_diameter_pick(self, info: dict) -> None:
+ if info.get("kind") != "circle":
+ return
+ cx, cy = info["center"]
+ r = info["radius"]
+ self._add_manual_dimension(
+ "diameter",
+ anchors=((cx - r, cy), (cx + r, cy)),
+ view_id=info["view_id"],
+ done_msg=f"Diameter dimension added: Ø{2 * r:.2f}",
+ )
+
+ def _on_delete_pick(self, info: dict) -> None:
+ key = info.get("candidate_key") or ""
+ self._canvas.set_pick_mode("")
+ if not key.startswith("manual:"):
+ self._status_label.setText(
+ "That dimension is auto-placed — untick Auto dimensions to remove it"
+ )
+ return
+ ann_id = key[len("manual:"):]
+ if self._drawing is None:
+ return
+ self._drawing.annotations = [
+ a for a in self._drawing.annotations if a.id != ann_id
+ ]
+ self._drawing.modified_at = datetime.now()
+ self._on_generate()
+ self.drawing_changed.emit()
+ self._status_label.setText("Dimension removed")
+
+ def _add_manual_dimension(
+ self,
+ dimension_kind: str,
+ anchors: Sequence[Tuple[float, float]],
+ view_id: str,
+ direction: Optional[Tuple[float, float]] = None,
+ done_msg: str = "Dimension added",
+ ) -> None:
+ """Append a manual dimension annotation and regenerate."""
+ if self._drawing is None:
+ return
+ ann = DrawingAnnotation(
+ kind="dimension",
+ dimension_kind=dimension_kind,
+ view_id=view_id,
+ anchors=[(float(x), float(y)) for x, y in anchors],
+ direction=(float(direction[0]), float(direction[1]))
+ if direction
+ else None,
+ )
+ self._drawing.annotations.append(ann)
+ self._drawing.modified_at = datetime.now()
+ self._on_generate()
+ self.drawing_changed.emit()
+ self._cancel_pick()
+ self._status_label.setText(done_msg)
+
+
+ def _on_export(self, fmt: str) -> None:
+ """Export the current render result to PDF or SVG."""
+ if self._drawing is None or self._render_result is None:
+ return
+ self._sync_views_to_drawing()
+ title = "Export PDF" if fmt == "pdf" else "Export SVG"
+ filter_ = "PDF Files (*.pdf)" if fmt == "pdf" else "SVG Files (*.svg)"
+ path, _ = QFileDialog.getSaveFileName(self, title, "", filter_)
if not path:
return
try:
- Path(path).write_text(self._drawing.model_dump_json(indent=2))
- self._status_label.setText(f"Saved to {path}")
+ if fmt == "pdf":
+ export_drawing_pdf(self._render_result, path)
+ else:
+ export_drawing_svg(self._render_result, path)
+ self._status_label.setText(f"Exported to {path}")
except Exception as e:
- self._status_label.setText(f"Save failed: {e}")
+ self._status_label.setText(f"Export failed: {e}")