diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 398539f..42a2482 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -6,11 +6,8 @@
-
-
-
@@ -47,47 +44,48 @@
- {
- "keyToString": {
- "Python.2dtest.executor": "Run",
- "Python.3d_windows.executor": "Run",
- "Python.Unnamed.executor": "Run",
- "Python.base.executor": "Run",
- "Python.data_model.executor": "Run",
- "Python.debug_dragging.executor": "Run",
- "Python.draw_widget2d.executor": "Run",
- "Python.draw_widget_solve.executor": "Run",
- "Python.fluency.executor": "Run",
- "Python.fluencyb.executor": "Run",
- "Python.gl_widget.executor": "Run",
- "Python.gui_ui.executor": "Run",
- "Python.kernel.executor": "Run",
- "Python.main.executor": "Run",
- "Python.main_window.executor": "Run",
- "Python.meshtest.executor": "Run",
- "Python.occ_renderer.executor": "Run",
- "Python.occ_to_mesh.executor": "Run",
- "Python.render_backend.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",
- "RunOnceActivity.ShowReadmeOnStart": "true",
- "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
- "RunOnceActivity.git.unshallow": "true",
- "RunOnceActivity.typescript.service.memoryLimit.init": "true",
- "codeWithMe.voiceChat.enabledByDefault": "false",
- "git-widget-placeholder": "feature/occ-migration",
- "last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency/Tesfiles",
- "node.js.detected.package.eslint": "true",
- "node.js.selected.package.eslint": "(autodetect)",
- "node.js.selected.package.tslint": "(autodetect)",
- "nodejs_package_manager_path": "npm",
- "settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
+
+}]]>
-
+
diff --git a/src/fluency/models/data_model.py b/src/fluency/models/data_model.py
index 89bc32d..0cfe638 100644
--- a/src/fluency/models/data_model.py
+++ b/src/fluency/models/data_model.py
@@ -800,36 +800,6 @@ class Project:
"""Look up a component by id across all project components."""
return self.components.get(component_id)
- def add_component(self, component: Optional[Component] = None) -> Component:
- """Add a component to the project."""
- if component is None:
- component = Component(name=f"Component {len(self.components) + 1}")
- self.components[component.id] = component
- if self.active_component is None:
- self.active_component = component.id
- self.modified_at = datetime.now()
- return component
-
- def remove_component(self, component_id: str) -> bool:
- """Remove a component from the project."""
- if component_id in self.components:
- del self.components[component_id]
- if self.active_component == component_id:
- self.active_component = next(iter(self.components.keys()), None)
- self.modified_at = datetime.now()
- return True
- return False
-
- def get_active_component(self) -> Optional[Component]:
- """Get the currently active component."""
- if self.active_component and self.active_component in self.components:
- return self.components[self.active_component]
- return None
-
- def set_active_component(self, component_id: Optional[str]) -> None:
- """Set the active component."""
- self.active_component = component_id
- self.modified_at = datetime.now()
def export_step(self, filepath: str) -> bool:
"""Export all visible bodies to STEP."""
@@ -898,3 +868,84 @@ class Project:
for comp in self.components.values():
sketches.extend(comp.sketches.values())
return sketches
+
+ def compute_source_fingerprint(
+ self, source_kind: str, source_id: str
+ ) -> str:
+ """Compute a simple fingerprint for a source reference."""
+ import hashlib
+ data = f"{source_kind}:{source_id}"
+ return hashlib.sha256(data.encode()).hexdigest()[:16]
+# ── Technical Drawing models ───────────────────────────────────────────────
+
+@dataclass
+class DrawingView:
+ """One projected view in a technical drawing."""
+
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ kind: str = "front" # front, back, top, bottom, right, left, isometric, custom
+ name: Optional[str] = None # human-readable label; defaults from kind
+
+ # View direction and up vector in world coords (for custom views).
+ # Ignored when kind is one of the standard presets.
+ direction: Optional[Tuple[float, float, float]] = None
+ up_vector: Optional[Tuple[float, float, float]] = None
+
+ show_hidden_lines: bool = True
+ show_centerlines: bool = True
+ scale: float = 1.0
+
+ # Sheet position (mm from sheet origin) — set by layout engine.
+ sheet_origin: Tuple[float, float] = (0.0, 0.0)
+
+
+@dataclass
+class DrawingAnnotation:
+ """One annotation (dimension or note) on a technical drawing."""
+
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ kind: str = "dimension" # dimension, note, tolerance, surface_finish, weld_symbol
+ text: Optional[str] = None
+ visible: bool = True
+
+ # References to DrawingCandidate keys this annotation is bound to.
+ references: List[str] = field(default_factory=list)
+
+ # Sheet position (mm). For dimensions, anchor point; for notes, placement.
+ sheet_position: Tuple[float, float] = (0.0, 0.0)
+
+ # Associated view id (empty means global/note block).
+ view_id: str = ""
+
+
+@dataclass
+class TechnicalDrawing:
+ """A complete technical drawing definition."""
+
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ name: str = "Untitled Drawing"
+
+ # Source geometry reference.
+ source_kind: str = "component" # component, assembly
+ source_id: str = ""
+
+ views: List[DrawingView] = field(default_factory=list)
+ annotations: List[DrawingAnnotation] = field(default_factory=list)
+
+ # Title block metadata.
+ title: str = ""
+ part_number: str = ""
+ material: str = ""
+ revision: str = ""
+ notes: str = ""
+
+ # Sheet size (A0..A4 or custom mm). Default A3.
+ sheet_size: str = "A3"
+ units: str = "mm" # mm, in
+
+ # Auto-generation flags for future use.
+ auto_dimensions: bool = True
+ auto_views: bool = True
+
+ created_at: datetime = field(default_factory=datetime.now)
+ modified_at: datetime = field(default_factory=datetime.now)
diff --git a/src/fluency/technical_drawing.py b/src/fluency/technical_drawing.py
new file mode 100644
index 0000000..510937a
--- /dev/null
+++ b/src/fluency/technical_drawing.py
@@ -0,0 +1,1188 @@
+"""
+Technical Drawing engine for Fluency CAD.
+
+Pure module — no Qt widget dependencies. Produces projected vector
+primitives, dimension candidates, and exportable render results from
+component and assembly geometry using OCC hidden-line removal.
+
+Exact public API:
+ DrawingSourcePart, DrawingCandidate, DrawingPrimitive, DrawingRenderResult
+ build_source_parts, generate_view, generate_drawing
+ render_drawing, export_drawing_svg, export_drawing_pdf
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+from typing import Any, Dict, List, Optional, Sequence, Tuple
+
+import numpy as np
+
+from fluency.models.data_model import DrawingView, Project, TechnicalDrawing
+from fluency.geometry_occ.kernel import OCGeometryKernel
+
+from PySide6.QtCore import Qt, QPointF, QRectF
+from PySide6.QtGui import QPainter, QPen, QColor, QFont
+
+# ── Public records ─────────────────────────────────────────────────────────
+
+
+@dataclass(frozen=True)
+class DrawingSourcePart:
+ """One geometry source for projection."""
+
+ part_id: str
+ display_name: str
+ shape: Any # TopoDS_Shape (OCP wrapped)
+ color: Tuple[float, float, float]
+ component_id: str
+ assembly_instance_id: Optional[str] = None
+
+
+@dataclass(frozen=True)
+class DrawingCandidate:
+ """A dimension candidate extracted from projected geometry."""
+
+ key: str
+ view_id: str
+ kind: str # "extent", "length", "diameter", "radius", "angle"
+ references: Tuple[str, ...]
+ value: float
+ anchor_points: Tuple[Tuple[float, float], ...]
+ label: str
+
+
+@dataclass(frozen=True)
+class DrawingPrimitive:
+ """One vector primitive ready to paint."""
+
+ kind: str # "line","circle","arc","centerline","dimension","arrowhead","text","balloon","table"
+ points: Tuple[Tuple[float, float], ...]
+ style: str # "visible","hidden","construction","dimension"
+ text: Optional[str] = None
+ candidate_key: Optional[str] = None
+ center: Optional[Tuple[float, float]] = None
+ radius: Optional[float] = None
+ dash_pattern: Tuple[float, ...] = ()
+
+
+@dataclass(frozen=True)
+class DrawingRenderResult:
+ """Complete renderable drawing output."""
+
+ primitives: Tuple[DrawingPrimitive, ...]
+ candidates: Tuple[DrawingCandidate, ...]
+ resolved_annotation_ids: Tuple[str, ...]
+ unresolved_annotation_ids: Tuple[str, ...]
+ source_fingerprint: str
+ warnings: Tuple[str, ...]
+
+
+# ── View presets ───────────────────────────────────────────────────────────
+
+_STANDARD_VIEWS: Dict[str, Tuple[Tuple[float, float, float], Tuple[float, float, float]]] = {
+ "front": ((0.0, -1.0, 0.0), (0.0, 0.0, 1.0)),
+ "back": ((0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
+ "top": ((0.0, 0.0, 1.0), (0.0, -1.0, 0.0)),
+ "bottom": ((0.0, 0.0, -1.0), (0.0, 1.0, 0.0)),
+ "right": ((1.0, 0.0, 0.0), (0.0, 0.0, 1.0)),
+ "left": ((-1.0, 0.0, 0.0), (0.0, 0.0, 1.0)),
+ "isometric": ((1.0, -1.0, 1.0), (0.0, 0.0, 1.0)),
+}
+
+# Stable ordered view rows (id, display name, direction, up) for UI iteration.
+# _STANDARD_VIEWS stays a dict keyed by id; this list gives a predictable order.
+_STANDARD_VIEW_ROWS: List[Tuple[str, str, Tuple[float, float, float], Tuple[float, float, float]]] = [
+ (k, k.capitalize(), v[0], v[1]) for k, v in _STANDARD_VIEWS.items()
+]
+
+_A3_WIDTH_MM = 420.0
+_A3_HEIGHT_MM = 297.0
+_TITLE_MARGIN_MM = 40.0
+_DISPLAY_PRECISION = 2
+
+
+def _normalize(v: Tuple[float, float, float]) -> Tuple[float, float, float]:
+ x, y, z = v
+ norm = math.sqrt(x * x + y * y + z * z)
+ if norm < 1e-12:
+ return (0.0, 0.0, 1.0)
+ inv = 1.0 / norm
+ return (x * inv, y * inv, z * inv)
+
+
+def _cross(a: Tuple[float, float, float], b: Tuple[float, float, float]) -> Tuple[float, float, float]:
+ return (
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0],
+ )
+
+
+def _dot(a: Tuple[float, float, float], b: Tuple[float, float, float]) -> float:
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
+
+
+# ── Source-part builders ───────────────────────────────────────────────────
+
+
+def build_source_parts(
+ project: Project,
+ source_kind: str,
+ source_id: str,
+ kernel: OCGeometryKernel,
+) -> Tuple[Tuple[DrawingSourcePart, ...], Tuple[str, ...]]:
+ """Collect visible solid bodies as source parts for projection.
+
+ Returns ``(parts, warnings)``.
+ """
+ warnings: List[str] = []
+ parts: List[DrawingSourcePart] = []
+
+ if source_kind == "component":
+ comp = project.get_component_by_id(source_id)
+ if comp is None:
+ return (), (f"Component {source_id} not found",)
+ for bid, body in sorted(comp.bodies.items()):
+ if body.geometry is None or not body.visible:
+ continue
+ shape = kernel._get_shape(body.geometry)
+ if shape is None:
+ warnings.append(f"Body {body.name} ({bid}) has no extractable shape")
+ continue
+ parts.append(
+ DrawingSourcePart(
+ part_id=bid,
+ display_name=body.name,
+ shape=shape,
+ color=body.color,
+ component_id=source_id,
+ )
+ )
+ if not parts:
+ warnings.append("Component has no visible solid geometry")
+
+ elif source_kind == "assembly":
+ asm = project.assemblies.get(source_id)
+ if asm is None:
+ return (), (f"Assembly {source_id} not found",)
+ for ac_id, ac in sorted(asm.components.items()):
+ comp = project.get_component_by_id(ac.component_id)
+ if comp is None:
+ warnings.append(f"Assembly component {ac_id} refs missing component {ac.component_id}")
+ continue
+ for bid, body in sorted(comp.bodies.items()):
+ if body.geometry is None or not body.visible:
+ continue
+ shape = kernel._get_shape(body.geometry)
+ if shape is None:
+ warnings.append(f"Body {body.name} ({bid}) has no extractable shape")
+ continue
+ transformed = _apply_ocp_transform(shape, ac.position, ac.rotation)
+ parts.append(
+ DrawingSourcePart(
+ part_id=f"{ac_id}/{bid}",
+ display_name=f"{comp.name}:{body.name}",
+ shape=transformed,
+ color=body.color,
+ component_id=ac.component_id,
+ assembly_instance_id=ac_id,
+ )
+ )
+ if not parts:
+ warnings.append("Assembly has no visible solid geometry")
+
+ else:
+ return (), (f"Unknown source kind: {source_kind}",)
+
+ return tuple(parts), tuple(warnings)
+
+
+def _apply_ocp_transform(shape: Any, position: np.ndarray, rotation: np.ndarray) -> Any:
+ """Apply position+rotation to an OCP TopoDS_Shape, return new shape."""
+ from OCP.gp import gp_Trsf, gp_Vec, gp_Quaternion
+ from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
+
+ trsf = gp_Trsf()
+ rx = np.asarray(rotation, dtype=float).reshape(3, 3)
+ q = _mat_to_quat(rx)
+ q_ocp = gp_Quaternion(q[0], q[1], q[2], q[3])
+ trsf.SetRotation(q_ocp)
+ pos = np.asarray(position, dtype=float).flatten()
+ trsf_vec = gp_Vec(float(pos[0]), float(pos[1]), float(pos[2]))
+ trsf.SetTranslationPart(trsf_vec)
+ return BRepBuilderAPI_Transform(shape, trsf, True).Shape()
+
+
+def _mat_to_quat(m: np.ndarray) -> Tuple[float, float, float, float]:
+ """Convert 3x3 rotation matrix to (w, x, y, z) quaternion."""
+ trace = m[0, 0] + m[1, 1] + m[2, 2]
+ if trace > 0:
+ s = math.sqrt(trace + 1.0) * 2.0
+ w = 0.25 * s
+ x = (m[2, 1] - m[1, 2]) / s
+ y = (m[0, 2] - m[2, 0]) / s
+ z = (m[1, 0] - m[0, 1]) / s
+ elif m[0, 0] > m[1, 1] and m[0, 0] > m[2, 2]:
+ s = math.sqrt(1.0 + m[0, 0] - m[1, 1] - m[2, 2]) * 2.0
+ w = (m[2, 1] - m[1, 2]) / s
+ x = 0.25 * s
+ y = (m[0, 1] + m[1, 0]) / s
+ z = (m[0, 2] + m[2, 0]) / s
+ elif m[1, 1] > m[2, 2]:
+ s = math.sqrt(1.0 + m[1, 1] - m[0, 0] - m[2, 2]) * 2.0
+ w = (m[0, 2] - m[2, 0]) / s
+ x = (m[0, 1] + m[1, 0]) / s
+ y = 0.25 * s
+ z = (m[1, 2] + m[2, 1]) / s
+ else:
+ s = math.sqrt(1.0 + m[2, 2] - m[0, 0] - m[1, 1]) * 2.0
+ w = (m[1, 0] - m[0, 1]) / s
+ x = (m[0, 2] + m[2, 0]) / s
+ y = (m[1, 2] + m[2, 1]) / s
+ z = 0.25 * s
+ return (w, x, y, z)
+
+
+# ── View projection ────────────────────────────────────────────────────────
+
+
+def generate_view(
+ source_parts: Sequence[DrawingSourcePart],
+ view: DrawingView,
+) -> Tuple[Tuple[DrawingPrimitive, ...], Tuple[DrawingCandidate, ...], Tuple[str, ...]]:
+ """Project one view from source parts.
+
+ Returns ``(primitives, candidates, warnings)``.
+ """
+ primitives: List[DrawingPrimitive] = []
+ candidates: List[DrawingCandidate] = []
+ warnings: List[str] = []
+
+ direction = view.direction or (0.0, -1.0, 0.0)
+ direction = _normalize(direction)
+
+ # Collect 2D edges from HLR projection (HLR output is already in view plane).
+ all_edges_2d: List[Tuple[Tuple[float, float], Tuple[float, float], str]] = []
+ circle_data: List[Tuple[float, float, float]] = [] # (cx, cy, radius) from circles
+ arc_segments: List[List[Tuple[float, float]]] = [] # sampled arc points
+
+ for part in source_parts:
+ try:
+ vis_edges, hid_edges = _project_part_edges(part.shape, direction)
+ except Exception as exc:
+ warnings.append(f"HLR projection failed for {part.display_name}: {exc}")
+ continue
+
+ all_edges_2d.extend(vis_edges)
+ if view.show_hidden_lines:
+ all_edges_2d.extend(hid_edges)
+
+ if not all_edges_2d:
+ warnings.append(f"View '{view.name or view.kind}': no projected edges")
+ return tuple(primitives), tuple(candidates), tuple(warnings)
+
+ # Separate circles/arcs from line segments for dimension extraction.
+ line_segments: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
+ circle_data: List[Tuple[float, float, float]] = []
+ arc_segments: List[List[Tuple[float, float]]] = []
+ for p1, p2, curve_type in all_edges_2d:
+ if curve_type == "circle_center_radius":
+ # Accurate circle data from OCC Geom_Circle.
+ cx, cy = p1
+ radius = p2[0] - p1[0]
+ if radius > 0.5:
+ circle_data.append((cx, cy, radius))
+ elif curve_type == "circle":
+ # Fallback approximation from arc endpoints.
+ mid = ((p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0)
+ r = math.dist(p1, mid)
+ if r > 0.5:
+ circle_data.append((mid[0], mid[1], r))
+ elif curve_type == "other":
+ arc_segments.append([p1, p2])
+ else:
+ line_segments.append((p1, p2))
+
+ # Fit to A3 sheet.
+ all_pts = [pt for e in all_edges_2d for pt in (e[0], e[1])]
+ min_x = min(p[0] for p in all_pts)
+ max_x = max(p[0] for p in all_pts)
+ min_y = min(p[1] for p in all_pts)
+ max_y = max(p[1] for p in all_pts)
+ geom_w = max(max_x - min_x, 1.0)
+ geom_h = max(max_y - min_y, 1.0)
+ available_w = _A3_WIDTH_MM - _TITLE_MARGIN_MM * 2
+ available_h = _A3_HEIGHT_MM - _TITLE_MARGIN_MM * 2
+ scale = min(available_w / geom_w, available_h / geom_h) * view.scale
+ offset_x = (_A3_WIDTH_MM - geom_w * scale) / 2.0 - min_x * scale
+ offset_y = (_A3_HEIGHT_MM - geom_h * scale) / 2.0 - min_y * scale
+
+ def _to_sheet(x: float, y: float) -> Tuple[float, float]:
+ return (x * scale + offset_x, y * scale + offset_y)
+
+ # Convert edges to primitives.
+ for p1, p2, style in all_edges_2d:
+ s1 = _to_sheet(*p1)
+ s2 = _to_sheet(*p2)
+ primitives.append(DrawingPrimitive(kind="line", points=(s1, s2), style=style))
+
+ # ── 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 (width and height of projected geometry).
+ width_val = (max_x - min_x) * scale
+ height_val = (max_y - min_y) * scale
+
+ bw = _to_sheet((min_x + max_x) / 2.0, min_y - 5.0 / scale)
+ tw = _to_sheet((min_x + max_x) / 2.0, max_y + 5.0 / scale)
+ candidates.append(
+ DrawingCandidate(
+ key=f"{view_id}:extent:width",
+ view_id=view_id,
+ kind="extent",
+ references=(),
+ value=width_val,
+ anchor_points=(bw, tw),
+ label=f"Ø{width_val:.{_DISPLAY_PRECISION}f}" if abs(width_val - height_val) < width_val * 0.01 else f"{width_val:.{_DISPLAY_PRECISION}f}",
+ )
+ )
+
+ bh = _to_sheet(min_x - 5.0 / scale, (min_y + max_y) / 2.0)
+ th = _to_sheet(max_x + 5.0 / scale, (min_y + max_y) / 2.0)
+ candidates.append(
+ DrawingCandidate(
+ key=f"{view_id}:extent:height",
+ view_id=view_id,
+ kind="extent",
+ references=(),
+ value=height_val,
+ anchor_points=(bh, th),
+ label=f"{height_val:.{_DISPLAY_PRECISION}f}",
+ )
+ )
+
+ # 2. Diameter candidates from detected circles.
+ _extract_diameter_candidates(circle_data, view_id, scale, offset_x, offset_y, candidates)
+
+ # 3. Linear distance candidates between prominent parallel edges.
+ _extract_linear_candidates(line_segments, view_id, scale, offset_x, offset_y, candidates)
+
+ # 4. Angle candidates from intersecting lines.
+ _extract_angle_candidates(line_segments, view_id, scale, offset_x, offset_y, candidates)
+
+ return tuple(primitives), tuple(candidates), tuple(warnings)
+
+
+def _project_part_edges(
+ shape: Any,
+ direction: Tuple[float, float, float],
+) -> Tuple[
+ List[Tuple[Tuple[float, float], Tuple[float, float], str]],
+ List[Tuple[Tuple[float, float], Tuple[float, float], str]],
+]:
+ """Project one part's edges using HLRBRep_Algo.
+
+ HLR output edges already lie in the projector's view plane (Z≈0).
+ Returns (visible_edges, hidden_edges) as 2D (p1, p2, curve_type).
+ """
+ from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape
+ from OCP.HLRAlgo import HLRAlgo_Projector
+ from OCP.gp import gp_Ax2, gp_Pnt, gp_Dir
+ from OCP.TopExp import TopExp_Explorer
+ from OCP.TopAbs import TopAbs_EDGE
+ from OCP.TopoDS import TopoDS
+ from OCP.BRepLib import BRepLib
+
+ _HIDE_TOL = 1.0 / 1e6
+
+ dx, dy, dz = direction
+ projector = HLRAlgo_Projector(gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(dx, dy, dz)))
+ hlr = HLRBRep_Algo()
+ hlr.Add(shape, 0)
+ hlr.Projector(projector)
+ hlr.Update()
+ hlr.Hide()
+
+ hlr_shapes = HLRBRep_HLRToShape(hlr)
+
+ visible_edges: List[Tuple[Tuple[float, float], Tuple[float, float], str]] = []
+ hidden_edges: List[Tuple[Tuple[float, float], Tuple[float, float], str]] = []
+
+ def _add_edges(compound: Any, out: List) -> None:
+ if compound.IsNull():
+ return
+ BRepLib.BuildCurves3d_s(compound, _HIDE_TOL)
+ exp = TopExp_Explorer(compound, TopAbs_EDGE)
+ while exp.More():
+ edge = TopoDS.Edge_s(exp.Current())
+ _collect_edge(edge, out)
+ exp.Next()
+
+ _add_edges(hlr_shapes.VCompound(), visible_edges)
+ _add_edges(hlr_shapes.Rg1LineVCompound(), visible_edges)
+ _add_edges(hlr_shapes.OutLineVCompound(), visible_edges)
+ _add_edges(hlr_shapes.HCompound(), hidden_edges)
+ _add_edges(hlr_shapes.OutLineHCompound(), hidden_edges)
+
+ return visible_edges, hidden_edges
+
+
+def _collect_edge(
+ edge: Any,
+ out: List[Tuple[Tuple[float, float], Tuple[float, float], str]],
+ num_samples: int = 32,
+) -> None:
+ """Sample an OCP edge from HLR output (already in view plane) into 2D segments."""
+ from OCP.BRepAdaptor import BRepAdaptor_Curve
+ from OCP.GeomAbs import GeomAbs_Line, GeomAbs_Circle
+ from OCP.Geom import Geom_Circle
+
+ if edge.IsNull():
+ return
+ curve = BRepAdaptor_Curve(edge)
+ ct = curve.GetType()
+ first = curve.FirstParameter()
+ last = curve.LastParameter()
+
+ if ct == GeomAbs_Line:
+ p1 = curve.Value(first)
+ p2 = curve.Value(last)
+ out.append(((p1.X(), p1.Y()), (p2.X(), p2.Y()), "line"))
+ elif ct == GeomAbs_Circle:
+ # Extract real circle geometry for accurate diameter detection.
+ geom_circ = curve.Circle() # Geom_Circle
+ center = geom_circ.Position().Location()
+ radius = geom_circ.Radius()
+ p1 = curve.Value(first)
+ p2 = curve.Value(last)
+ out.append(((p1.X(), p1.Y()), (p2.X(), p2.Y()), "circle"))
+ # Also record center and radius as special marker.
+ out.append(
+ ((center.X(), center.Y()), (center.X() + radius, center.Y()), "circle_center_radius"),
+ )
+ else:
+ prev: Optional[Tuple[float, float]] = None
+ for i in range(num_samples + 1):
+ t = first + (last - first) * i / num_samples
+ p = curve.Value(t)
+ cur = (p.X(), p.Y())
+ if prev is not None:
+ out.append((prev, cur, "other"))
+ prev = cur
+
+
+# ── Dimension candidate extraction helpers ─────────────────────────────────
+
+def _extract_diameter_candidates(
+ circle_data: List[Tuple[float, float, float]],
+ view_id: str,
+ scale: float,
+ offset_x: float,
+ offset_y: float,
+ candidates: List[DrawingCandidate],
+) -> None:
+ """Detect diameter dimensions from projected circles."""
+ if not circle_data:
+ return
+
+ def _to_sheet(x: float, y: float) -> Tuple[float, float]:
+ return (x * scale + offset_x, y * scale + offset_y)
+
+ # Cluster circles by radius (within 1% tolerance) to find distinct diameters.
+ clusters: List[List[Tuple[float, float, float]]] = []
+ for cx, cy, r in circle_data:
+ placed = False
+ for cluster in clusters:
+ ref_r = cluster[0][2]
+ if abs(r - ref_r) / max(ref_r, 1e-9) < 0.01:
+ cluster.append((cx, cy, r))
+ placed = True
+ break
+ if not placed:
+ clusters.append([(cx, cy, r)])
+
+ for i, cluster in enumerate(clusters):
+ avg_r = sum(c[2] for c in cluster) / len(cluster)
+ diam = 2.0 * avg_r * scale
+ # Use first circle center as anchor.
+ cx, cy = cluster[0][0], cluster[0][1]
+ p1 = _to_sheet(cx - avg_r, cy)
+ p2 = _to_sheet(cx + avg_r, cy)
+ candidates.append(
+ DrawingCandidate(
+ key=f"{view_id}:diameter:{i}",
+ view_id=view_id,
+ kind="diameter",
+ references=(),
+ value=diam,
+ anchor_points=(p1, p2),
+ label=f"Ø{diam:.{_DISPLAY_PRECISION}f}",
+ )
+ )
+
+
+def _extract_linear_candidates(
+ line_segments: List[Tuple[Tuple[float, float], Tuple[float, float]]],
+ view_id: str,
+ scale: float,
+ offset_x: float,
+ offset_y: float,
+ candidates: List[DrawingCandidate],
+) -> None:
+ """Detect linear distance dimensions between prominent parallel edges."""
+ if len(line_segments) < 2:
+ return
+
+ def _to_sheet(x: float, y: float) -> Tuple[float, float]:
+ return (x * scale + offset_x, y * scale + offset_y)
+
+ # Find pairs of approximately parallel segments and measure distance between them.
+ # Limit to avoid combinatorial explosion.
+ max_pairs = 20
+ pair_count = 0
+
+ for i in range(len(line_segments)):
+ if pair_count >= max_pairs:
+ break
+ p1, p2 = line_segments[i]
+ dx1 = p2[0] - p1[0]
+ dy1 = p2[1] - p1[1]
+ len1 = math.sqrt(dx1 * dx1 + dy1 * dy1)
+ if len1 < 2.0:
+ continue
+
+ for j in range(i + 1, len(line_segments)):
+ if pair_count >= max_pairs:
+ break
+ q1, q2 = line_segments[j]
+ dx2 = q2[0] - q1[0]
+ dy2 = q2[1] - q1[1]
+ len2 = math.sqrt(dx2 * dx2 + dy2 * dy2)
+ if len2 < 2.0:
+ continue
+
+ # Check if segments are approximately parallel (dot product of normalized dirs).
+ dot = (dx1 * dx2 + dy1 * dy2) / (len1 * len2)
+ if abs(dot) < 0.95:
+ continue
+
+ # Measure perpendicular distance between segment midpoints.
+ mx1, my1 = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0
+ mx2, my2 = (q1[0] + q2[0]) / 2.0, (q1[1] + q2[1]) / 2.0
+
+ # Distance perpendicular to segment direction.
+ nx, ny = -dy1 / len1, dx1 / len1 # normal
+ dist = abs((mx2 - mx1) * nx + (my2 - my1) * ny) * scale
+
+ if dist < 0.5 or dist > 500.0:
+ continue
+
+ s_m1 = _to_sheet(mx1, my1)
+ s_m2 = _to_sheet(mx2, my2)
+ candidates.append(
+ DrawingCandidate(
+ key=f"{view_id}:linear:{pair_count}",
+ view_id=view_id,
+ kind="length",
+ references=(),
+ value=dist,
+ anchor_points=(s_m1, s_m2),
+ label=f"{dist:.{_DISPLAY_PRECISION}f}",
+ )
+ )
+ pair_count += 1
+
+
+def _extract_angle_candidates(
+ line_segments: List[Tuple[Tuple[float, float], Tuple[float, float]]],
+ view_id: str,
+ scale: float,
+ offset_x: float,
+ offset_y: float,
+ candidates: List[DrawingCandidate],
+) -> None:
+ """Detect angle dimensions between intersecting lines."""
+ if len(line_segments) < 2:
+ return
+
+ def _to_sheet(x: float, y: float) -> Tuple[float, float]:
+ return (x * scale + offset_x, y * scale + offset_y)
+
+ # Find pairs of segments that share an endpoint and compute angle.
+ max_angles = 10
+ angle_count = 0
+
+ for i in range(len(line_segments)):
+ if angle_count >= max_angles:
+ break
+ p1, p2 = line_segments[i]
+ dx1 = p2[0] - p1[0]
+ dy1 = p2[1] - p1[1]
+
+ for j in range(i + 1, len(line_segments)):
+ if angle_count >= max_angles:
+ break
+ q1, q2 = line_segments[j]
+
+ # Check if segments share an endpoint.
+ shared = None
+ for a, b in [(p1, q1), (p1, q2), (p2, q1), (p2, q2)]:
+ if math.dist(a, b) < 0.5:
+ shared = a
+ break
+
+ if shared is None:
+ continue
+
+ # Direction vectors from shared point.
+ dx2 = q2[0] - q1[0]
+ dy2 = q2[1] - q1[1]
+
+ # Adjust direction based on which endpoint is shared.
+ if shared == q2:
+ dx2, dy2 = -dx2, -dy2
+
+ dot = dx1 * dx2 + dy1 * dy2
+ mag1 = math.sqrt(dx1 * dx1 + dy1 * dy1)
+ mag2 = math.sqrt(dx2 * dx2 + dy2 * dy2)
+ if mag1 < 0.5 or mag2 < 0.5:
+ continue
+
+ cos_a = dot / (mag1 * mag2)
+ angle_rad = math.acos(max(-1.0, min(1.0, cos_a)))
+ angle_deg = math.degrees(angle_rad)
+
+ # Skip near-zero or near-180 angles.
+ if angle_deg < 5.0 or angle_deg > 175.0:
+ continue
+
+ s_shared = _to_sheet(*shared)
+ candidates.append(
+ DrawingCandidate(
+ key=f"{view_id}:angle:{angle_count}",
+ view_id=view_id,
+ kind="angle",
+ references=(),
+ value=angle_deg,
+ anchor_points=(s_shared, s_shared),
+ label=f"{angle_deg:.{_DISPLAY_PRECISION}f}°",
+ )
+ )
+ angle_count += 1
+
+
+# ── Drawing generation ─────────────────────────────────────────────────────
+
+
+def _layout_views_on_sheet(
+ views: Sequence[DrawingView],
+) -> Dict[str, Tuple[float, float]]:
+ """Compute sheet positions for each view in standard orthographic layout.
+
+ Returns mapping view_id → (sheet_x, sheet_y) in mm from sheet origin.
+ Uses third-angle projection convention (common in manufacturing).
+ """
+ if not views:
+ return {}
+
+ positions: Dict[str, Tuple[float, float]] = {}
+
+ # Sheet usable area (leave room for title block at bottom-right).
+ margin = 10.0
+ title_block_w = 120.0
+ title_block_h = 60.0
+ avail_w = _A3_WIDTH_MM - margin * 2 - title_block_w
+ avail_h = _A3_HEIGHT_MM - margin * 2 - title_block_h
+
+ # View spacing and size estimate (fraction of available area).
+ view_gap = 15.0
+ max_view_w = avail_w * 0.45
+ max_view_h = avail_h * 0.35
+
+ # Find anchor view: prefer "front", then first standard view.
+ front_id = None
+ for v in views:
+ if v.kind == "front":
+ front_id = v.kind
+ break
+ if front_id is None:
+ for v in views:
+ if v.kind in ("front", "top", "right"):
+ front_id = v.kind
+ break
+ if front_id is None and views:
+ front_id = views[0].kind
+
+ # Center the anchor view horizontally on available area.
+ center_x = margin + avail_w / 2.0
+ center_y = margin + avail_h * 0.55
+
+ for v in views:
+ vid = v.kind if v.kind in _STANDARD_VIEWS else (v.name or v.id)
+ if v.kind == "front":
+ positions[vid] = (center_x - max_view_w / 2.0, center_y)
+ elif v.kind == "top":
+ fy = positions.get(front_id, (center_x, center_y))[1]
+ positions[vid] = (center_x - max_view_w / 2.0, fy - max_view_h - view_gap)
+ elif v.kind == "right":
+ fx = positions.get(front_id, (center_x, center_y))[0]
+ positions[vid] = (fx + max_view_w + view_gap, center_y)
+ elif v.kind == "left":
+ fx = positions.get(front_id, (center_x, center_y))[0]
+ positions[vid] = (fx - max_view_w - view_gap, center_y)
+ elif v.kind == "bottom":
+ fy = positions.get(front_id, (center_x, center_y))[1]
+ positions[vid] = (center_x - max_view_w / 2.0, fy + max_view_h + view_gap)
+ elif v.kind == "back":
+ fx = positions.get(front_id, (center_x, center_y))[0]
+ positions[vid] = (fx + max_view_w + view_gap * 2, center_y)
+ elif v.kind == "isometric":
+ fx = positions.get(front_id, (center_x, center_y))[0]
+ fy = positions.get(front_id, (center_x, center_y))[1]
+ positions[vid] = (fx + max_view_w / 2.0 + view_gap, fy - max_view_h * 1.5 - view_gap)
+ else:
+ used_x = [p[0] for p in positions.values()]
+ nx = margin if not used_x else max(used_x) + max_view_w + view_gap
+ ny = center_y
+ positions[vid] = (nx, ny)
+
+ return positions
+
+
+def _select_dimensions_for_placement(
+ candidates: Sequence[DrawingCandidate],
+ view_id: str,
+) -> List[DrawingCandidate]:
+ """Select a subset of dimension candidates to place on the drawing.
+
+ Filters for manufacturing relevance and avoids redundant dimensions.
+ Prioritizes: diameters > extents > significant linear distances > angles.
+ """
+ selected: List[DrawingCandidate] = []
+ seen_values: set = set() # track approximate values to avoid duplicates
+
+ def _value_key(val: float) -> float:
+ return round(val / 0.5) * 0.5 # bucket by 0.5 for dedup
+
+ # Sort candidates by priority and value significance.
+ def _priority(c: DrawingCandidate) -> Tuple[int, float]:
+ kind_order = {"diameter": 0, "extent": 1, "length": 2, "angle": 3, "radius": 4}
+ return (kind_order.get(c.kind, 5), -c.value)
+
+ for c in sorted(candidates, key=_priority):
+ if c.view_id != view_id:
+ continue
+ vkey = _value_key(c.value)
+ # Skip duplicates within same kind+view.
+ if (c.kind, vkey) in seen_values:
+ continue
+ # Skip tiny or enormous dimensions.
+ if c.value < 0.1 or c.value > 2000.0:
+ continue
+ selected.append(c)
+ seen_values.add((c.kind, vkey))
+
+ return selected
+
+
+def _generate_dimension_primitives(
+ candidates: Sequence[DrawingCandidate],
+ offset_x: float,
+ offset_y: float,
+) -> List[DrawingPrimitive]:
+ """Convert dimension candidates into renderable primitives with overlap avoidance."""
+ prims: List[DrawingPrimitive] = []
+
+ # Track occupied zones to avoid overlapping dimension text.
+ occupied: List[Tuple[float, float, float, float]] = [] # (x0, y0, x1, y1) in sheet coords
+
+ def _would_overlap(x: float, y: float, w: float, h: float) -> bool:
+ for x0, y0, x1, y1 in occupied:
+ if not (x + w < x0 or x > x1 or y + h < y0 or y > y1):
+ return True
+ return False
+
+ def _add_zone(x: float, y: float, w: float, h: float) -> None:
+ occupied.append((x - 2, y - 2, x + w + 2, y + h + 2))
+
+ for c in candidates:
+ if len(c.anchor_points) < 2:
+ continue
+
+ p1, p2 = c.anchor_points[0], c.anchor_points[1]
+ label_w = len(c.label) * 4.5 # approximate text width at drawing font size
+ label_h = 6.0
+
+ if c.kind == "diameter":
+ # Diameter: place text near center with leader line.
+ cx, cy = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0
+ tx, ty = cx - label_w / 2.0, cy - 8.0
+ if _would_overlap(tx, ty, label_w, label_h):
+ ty = cy + 8.0
+
+ prims.append(
+ DrawingPrimitive(
+ kind="text",
+ points=(),
+ style="dimension",
+ text=c.label,
+ candidate_key=c.key,
+ center=(tx, ty),
+ )
+ )
+ _add_zone(tx, ty, label_w, label_h)
+
+ elif c.kind in ("extent", "length"):
+ # Linear: place dimension line and text between anchors.
+ mx, my = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0
+
+ # Offset dimension line away from geometry by ~8mm.
+ dx = p2[0] - p1[0]
+ dy = p2[1] - p1[1]
+ dist = math.sqrt(dx * dx + dy * dy)
+ if dist > 0.5:
+ nx, ny = -dy / dist, dx / dist
+ dim_x = mx + nx * 8.0
+ dim_y = my + ny * 8.0
+ else:
+ dim_x, dim_y = mx, my
+
+ # Dimension line endpoints (offset from anchors along normal).
+ tx = dim_x - label_w / 2.0
+ ty = dim_y - label_h / 2.0
+
+ if _would_overlap(tx, ty, label_w, label_h):
+ tx += 5.0
+ ty += 5.0
+
+ prims.append(
+ DrawingPrimitive(
+ kind="line",
+ points=((p1[0], p1[1]), (dim_x, dim_y)),
+ style="dimension",
+ candidate_key=c.key,
+ )
+ )
+ prims.append(
+ DrawingPrimitive(
+ kind="line",
+ points=((p2[0], p2[1]), (dim_x, dim_y)),
+ style="dimension",
+ candidate_key=c.key,
+ )
+ )
+ prims.append(
+ DrawingPrimitive(
+ kind="text",
+ points=(),
+ style="dimension",
+ text=c.label,
+ candidate_key=c.key,
+ center=(tx, ty),
+ )
+ )
+ _add_zone(tx, ty, label_w, label_h)
+
+ elif c.kind == "angle":
+ # Angle: place text near vertex with arc indicator.
+ tx = p1[0] + 5.0
+ ty = p1[1] - 8.0
+ if _would_overlap(tx, ty, label_w, label_h):
+ ty = p1[1] + 8.0
+
+ prims.append(
+ DrawingPrimitive(
+ kind="text",
+ points=(),
+ style="dimension",
+ text=c.label,
+ candidate_key=c.key,
+ center=(tx, ty),
+ )
+ )
+ _add_zone(tx, ty, label_w, label_h)
+
+ return prims
+
+
+def generate_drawing(
+ drawing: TechnicalDrawing,
+ project: Project,
+ kernel: OCGeometryKernel,
+) -> DrawingRenderResult:
+ """Generate a complete drawing from a TechnicalDrawing definition."""
+ warnings: List[str] = []
+ all_primitives: List[DrawingPrimitive] = []
+ all_candidates: List[DrawingCandidate] = []
+
+ parts, part_warnings = build_source_parts(
+ project, drawing.source_kind, drawing.source_id, kernel
+ )
+ warnings.extend(part_warnings)
+
+ if not parts:
+ warnings.append("No source geometry available for drawing")
+ return DrawingRenderResult(
+ primitives=(),
+ candidates=(),
+ resolved_annotation_ids=(),
+ unresolved_annotation_ids=(),
+ source_fingerprint=project.compute_source_fingerprint(
+ drawing.source_kind, drawing.source_id
+ ),
+ warnings=tuple(warnings),
+ )
+
+ # Layout views on sheet before generating individual projections.
+ view_positions = _layout_views_on_sheet(drawing.views)
+
+ for view in drawing.views:
+ vid = view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id)
+ pos = view_positions.get(vid, (0.0, 0.0))
+ prims, cands, view_warnings = generate_view(parts, view)
+ # Offset primitives to their sheet position.
+ if pos != (0.0, 0.0):
+ offset_prims = []
+ for p in prims:
+ new_pts = tuple(
+ (pt[0] + pos[0], pt[1] + pos[1]) for pt in p.points
+ )
+ new_center = None
+ if p.center is not None:
+ new_center = (p.center[0] + pos[0], p.center[1] + pos[1])
+ offset_prims.append(
+ DrawingPrimitive(
+ kind=p.kind, points=new_pts, style=p.style, text=p.text,
+ candidate_key=p.candidate_key, center=new_center, radius=p.radius,
+ dash_pattern=p.dash_pattern,
+ )
+ )
+ prims = offset_prims
+
+ all_primitives.extend(prims)
+ all_candidates.extend(cands)
+ warnings.extend(view_warnings)
+
+ # Auto-place dimensions from candidates.
+ for view in drawing.views:
+ vid = view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id)
+ selected = _select_dimensions_for_placement(all_candidates, vid)
+ dim_prims = _generate_dimension_primitives(selected, 0.0, 0.0)
+ all_primitives.extend(dim_prims)
+
+ # Match annotations to candidates.
+ resolved_ids: List[str] = []
+ unresolved_ids: List[str] = []
+ 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.kind == "note":
+ resolved_ids.append(ann.id)
+ continue
+ matched = False
+ for ref in ann.references:
+ if ref in candidate_by_key:
+ ann_prim = DrawingPrimitive(
+ kind="dimension",
+ points=(ann.sheet_position,),
+ style="dimension",
+ text=ann.text or candidate_by_key[ref].label,
+ candidate_key=ref,
+ )
+ all_primitives.append(ann_prim)
+ resolved_ids.append(ann.id)
+ matched = True
+ break
+ if not matched:
+ unresolved_ids.append(ann.id)
+
+ # Title block.
+ title_prims = _title_block_primitives(drawing)
+ all_primitives.extend(title_prims)
+
+ return DrawingRenderResult(
+ primitives=tuple(all_primitives),
+ candidates=tuple(all_candidates),
+ resolved_annotation_ids=tuple(resolved_ids),
+ unresolved_annotation_ids=tuple(unresolved_ids),
+ source_fingerprint=project.compute_source_fingerprint(
+ drawing.source_kind, drawing.source_id
+ ),
+ warnings=tuple(warnings),
+ )
+
+
+def _title_block_primitives(drawing: TechnicalDrawing) -> List[DrawingPrimitive]:
+ """Generate title block primitives at the bottom-right of the sheet."""
+ prims: List[DrawingPrimitive] = []
+ margin = 5.0
+ box_h = _TITLE_MARGIN_MM - 10.0
+ box_w = 180.0
+ left = _A3_WIDTH_MM - box_w - margin
+ bottom = margin
+ line_h = 12.0
+
+ # Border.
+ for sx, sy, ex, ey in [
+ (left, bottom, left + box_w, bottom),
+ (left, bottom + box_h, left + box_w, bottom + box_h),
+ (left, bottom, left, bottom + box_h),
+ (left + box_w, bottom, left + box_w, bottom + box_h),
+ ]:
+ prims.append(DrawingPrimitive(kind="line", points=((sx, sy), (ex, ey)), style="visible"))
+
+ fields = [
+ ("Title:", drawing.title, 0),
+ ("Part No:", drawing.part_number, 1),
+ ("Material:", drawing.material, 2),
+ ("Rev:", drawing.revision, 3),
+ ]
+ for label, value, row in fields:
+ ty = bottom + box_h - line_h * (row + 1) - 4
+ prims.append(
+ DrawingPrimitive(
+ kind="text",
+ points=((left + 4, ty),),
+ style="dimension",
+ text=f"{label} {value}",
+ )
+ )
+
+ return prims
+
+
+
+def render_drawing(
+ painter: QPainter,
+ render_result: DrawingRenderResult,
+ sheet_rect: QRectF,
+) -> None:
+ """Paint a drawing render result onto a QPainter.
+
+ *sheet_rect* defines the canvas area in device coordinates (mm).
+ """
+ sx = sheet_rect.width() / _A3_WIDTH_MM
+ sy = sheet_rect.height() / _A3_HEIGHT_MM
+ scale = min(sx, sy)
+
+ draw_w = _A3_WIDTH_MM * scale
+ draw_h = _A3_HEIGHT_MM * scale
+ ox = sheet_rect.x() + (sheet_rect.width() - draw_w) / 2.0
+ oy = sheet_rect.y() + (sheet_rect.height() - draw_h) / 2.0
+
+ def _to_device(x_mm: float, y_mm: float) -> QPointF:
+ return QPointF(ox + x_mm * scale, oy + (_A3_HEIGHT_MM - y_mm) * scale)
+
+ # White background.
+ painter.fillRect(QRectF(ox, oy, draw_w, draw_h), QColor(255, 255, 255))
+
+ # Sheet border.
+ border_pen = QPen(QColor(0, 0, 0), 1.0 * scale)
+ border_pen.setCosmetic(True)
+ painter.setPen(border_pen)
+ painter.drawRect(QRectF(ox, oy, draw_w, draw_h))
+
+ style_pens = {
+ "visible": QPen(QColor(0, 0, 0), 1.5),
+ "hidden": QPen(QColor(128, 128, 128), 1.0),
+ "construction": QPen(QColor(0, 0, 255), 0.5),
+ "dimension": QPen(QColor(0, 0, 0), 1.0),
+ }
+ for sp in style_pens.values():
+ sp.setCosmetic(True)
+
+ style_pens["hidden"].setStyle(Qt.PenStyle.DashLine)
+ style_pens["construction"].setStyle(Qt.PenStyle.DashDotLine)
+
+ font = QFont("sans-serif", max(6, int(8 * scale)))
+ painter.setFont(font)
+
+ for prim in render_result.primitives:
+ pen = style_pens.get(prim.style, style_pens["visible"])
+ painter.setPen(pen)
+
+ if prim.kind == "line":
+ p1 = _to_device(*prim.points[0])
+ p2 = _to_device(*prim.points[1])
+ painter.drawLine(p1, p2)
+
+ elif prim.kind == "circle":
+ if prim.center and prim.radius:
+ c = _to_device(*prim.center)
+ r = prim.radius * scale
+ painter.drawEllipse(c, r, r)
+
+ elif prim.kind == "text":
+ if not prim.points:
+ continue
+ p = _to_device(*prim.points[0])
+ if prim.text:
+ painter.drawText(p, prim.text)
+
+ elif prim.kind == "dimension":
+ if not prim.points:
+ continue
+ p = _to_device(*prim.points[0])
+ if prim.text:
+ painter.drawText(p + QPointF(0, -4 * scale), prim.text)
+ painter.drawLine(p, p + QPointF(0, -6 * scale))
+
+
+def export_drawing_svg(
+ render_result: DrawingRenderResult,
+ filepath: str,
+) -> None:
+ """Export a drawing as SVG.
+
+ Raises ValueError if the render result is empty.
+ """
+ if not render_result.primitives:
+ raise ValueError("No renderable content; generate the drawing first.")
+
+ from PySide6.QtSvg import QSvgGenerator
+
+ from PySide6.QtCore import QSize
+
+ generator = QSvgGenerator()
+ generator.setFileName(filepath)
+ generator.setSize(QSize(2100, 1485)) # ~A3 at 5px/mm
+ generator.setViewBox(QRectF(0, 0, _A3_WIDTH_MM, _A3_HEIGHT_MM))
+ generator.setTitle("Fluency Technical Drawing")
+
+ painter = QPainter(generator)
+ try:
+ render_drawing(painter, render_result, QRectF(0, 0, _A3_WIDTH_MM, _A3_HEIGHT_MM))
+ finally:
+ painter.end()
+
+
+def export_drawing_pdf(
+ render_result: DrawingRenderResult,
+ filepath: str,
+) -> None:
+ """Export a drawing as PDF.
+
+ Raises ValueError if the render result is empty.
+ """
+ if not render_result.primitives:
+ raise ValueError("No renderable content; generate the drawing first.")
+
+ from PySide6.QtPrintSupport import QPrinter
+ from PySide6.QtGui import QPageSize, QPageLayout
+
+ printer = QPrinter(QPrinter.PrinterMode.HighResolution)
+ printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat)
+ printer.setOutputFileName(filepath)
+ printer.setPageSize(QPageSize(QPageSize.PageSizeId.A3))
+ printer.setPageOrientation(QPageLayout.Orientation.Landscape)
+ printer.setFullPage(True)
+
+ painter = QPainter(printer)
+ try:
+ render_drawing(painter, render_result, QRectF(0, 0, _A3_WIDTH_MM, _A3_HEIGHT_MM))
+ finally:
+ painter.end()
diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py
index 92ab4bc..79dc1f8 100644
--- a/src/fluency/ui/main_window.py
+++ b/src/fluency/ui/main_window.py
@@ -1911,6 +1911,13 @@ class MainWindow(QMainWindow):
self._render_tab = RenderTabContent()
self._ui.InputTab.addTab(self._render_tab, "Render")
+ # ── Drawing tab (adds "Drawing" tab to InputTab) ───────────
+ from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
+
+ 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()
# Component buttons (dynamically generated per component, not in UI).
# Wrapped in a QScrollArea so many components can scroll horizontally.
@@ -2515,6 +2522,7 @@ 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()
@@ -2533,6 +2541,7 @@ 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()
@@ -2542,6 +2551,13 @@ 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:
+ self._drawing_tab.set_active_component(self._current_component)
+ except Exception as e:
+ logger.warning(f"Failed to update drawing tab source: {e}")
# Scroll to the selected button.
if 0 <= idx < len(self._component_buttons):
_scroll_to_button(self._component_buttons[idx], self._component_scroll)
@@ -2571,6 +2587,14 @@ 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) ───────────────────
@@ -7306,6 +7330,13 @@ class MainWindow(QMainWindow):
self._btn_to_sketch.setEnabled(False)
self._create_initial_component()
+
+ # Re-point the drawing tab at the new project — its combo and
+ # project reference still belong 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:
self._suspend_dirty = False
self._project_path = None
@@ -7648,6 +7679,14 @@ class MainWindow(QMainWindow):
if sk.occ_sketch is not None:
self._sketch_widget.set_sketch(sk.occ_sketch)
self._current_sketch = sk
+
+ # Re-point the drawing tab at the new project/kernel and sync
+ # 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:
self._suspend_dirty = False
@@ -7862,10 +7901,60 @@ class MainWindow(QMainWindow):
else:
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 = ""
+
+ # 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}")
def _on_tab_changed(self, index: int) -> None:
- """When the user switches to the Render tab, auto-load the selected body."""
- if self._ui.InputTab.widget(index) is self._render_tab:
+ """When the user switches to Render or Drawing tab, auto-load selected geometry."""
+ widget = self._ui.InputTab.widget(index)
+ if widget is self._render_tab:
self._load_render_tab_shape()
+ elif widget is self._drawing_tab:
+ self._load_drawing_tab_source()
# ─── Sketch Undo/Redo ─────────────────────────────────────────────────
diff --git a/src/fluency/ui/technical_drawing_widget.py b/src/fluency/ui/technical_drawing_widget.py
new file mode 100644
index 0000000..cd0a585
--- /dev/null
+++ b/src/fluency/ui/technical_drawing_widget.py
@@ -0,0 +1,800 @@
+"""
+Technical Drawing workbench widget.
+
+Embeddable QWidget for creating, editing, and exporting technical drawings
+from component and assembly geometry. Designed to sit in MainWindow's
+InputTab alongside Sketch, Code, and Render tabs.
+"""
+
+from __future__ import annotations
+
+from typing import Dict, List, Optional
+
+from PySide6.QtCore import Qt, Signal, QRectF, QPointF
+from PySide6.QtGui import QPainter, QColor, QFont, QMouseEvent, QWheelEvent
+from PySide6.QtWidgets import (
+ QWidget,
+ QVBoxLayout,
+ QHBoxLayout,
+ QSplitter,
+ QPushButton,
+ QCheckBox,
+ QRadioButton,
+ QButtonGroup,
+ QComboBox,
+ QLabel,
+ QLineEdit,
+ QGroupBox,
+ QScrollArea,
+ QTableWidget,
+ QTableWidgetItem,
+ QHeaderView,
+ QTextEdit,
+ QAbstractItemView,
+ QSizePolicy,
+)
+
+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,
+)
+
+# Sheet layout constants.
+_A3_MM_W = 420.0
+_A3_MM_H = 297.0
+
+
+class DrawingCanvas(QWidget):
+ """Custom QPainter canvas with zoom/pan for technical drawing display."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._render_result: Optional[DrawingRenderResult] = None
+ self._zoom: float = 1.0
+ self._pan_x: float = 0.0
+ self._pan_y: float = 0.0
+ self._last_mouse: Optional[QPointF] = None
+ self.setMinimumSize(400, 300)
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+ self.setMouseTracking(True)
+
+ def set_render_result(self, result: Optional[DrawingRenderResult]) -> None:
+ self._render_result = result
+ self._zoom = 1.0
+ self._pan_x = 0.0
+ self._pan_y = 0.0
+ self.update()
+
+ def paintEvent(self, event) -> None:
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.Antialiasing)
+ painter.fillRect(self.rect(), QColor(200, 200, 200))
+
+ if self._render_result is None or not self._render_result.primitives:
+ painter.setPen(QColor(128, 128, 128))
+ font = QFont("sans-serif", 14)
+ painter.setFont(font)
+ painter.drawText(self.rect(), Qt.AlignCenter, "No drawing generated")
+ painter.end()
+ return
+
+ # Compute sheet rect in device coords with zoom/pan.
+ aspect = _A3_MM_W / _A3_MM_H
+ w = self.width()
+ h = self.height()
+
+ if w / h > aspect:
+ draw_h = h * 0.85 * self._zoom
+ draw_w = draw_h * aspect
+ else:
+ draw_w = w * 0.85 * self._zoom
+ draw_h = draw_w / aspect
+
+ 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)
+
+ render_drawing(painter, self._render_result, sheet_rect)
+ painter.end()
+
+ def wheelEvent(self, event: QWheelEvent) -> None:
+ factor = 1.1 if event.angleDelta().y() > 0 else 0.9
+ self._zoom = max(0.1, min(10.0, self._zoom * factor))
+ self.update()
+
+ def mousePressEvent(self, event: QMouseEvent) -> None:
+ if event.button() == Qt.MiddleButton:
+ self._last_mouse = event.position()
+ self.setCursor(Qt.ClosedHandCursor)
+
+ def mouseMoveEvent(self, event: QMouseEvent) -> None:
+ if self._last_mouse is not None:
+ delta = event.position() - self._last_mouse
+ self._pan_x += delta.x()
+ self._pan_y += delta.y()
+ self._last_mouse = event.position()
+ self.update()
+
+ def mouseReleaseEvent(self, event: QMouseEvent) -> None:
+ if event.button() == Qt.MiddleButton:
+ self._last_mouse = None
+ self.setCursor(Qt.ArrowCursor)
+
+
+class TechnicalDrawingWidget(QWidget):
+ """Embeddable technical drawing workbench."""
+
+ 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)
+
+ self._drawing: Optional[TechnicalDrawing] = None
+ self._render_result: Optional[DrawingRenderResult] = None
+ self._project = None
+ self._kernel = None
+
+ self._component_ids: List[str] = []
+ self._assembly_ids: List[str] = []
+ 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._init_ui()
+
+ # ── Public API ──────────────────────────────────────────────────────────
+
+ def set_project(self, project, kernel) -> None:
+ """Set the current project and kernel reference."""
+ 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.
+ self._on_generate()
+
+ def set_drawing(self, drawing: Optional[TechnicalDrawing]) -> None:
+ """Load a drawing definition."""
+ self._drawing = drawing
+ self._render_result = None
+ if drawing is not None:
+ self._populate_from_drawing(drawing)
+ self._canvas.set_render_result(None)
+ self._generate_btn.setEnabled(self._drawing is not None)
+ self._refresh_annotation_table()
+
+ 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()
+ return self._drawing
+
+ def set_render_result(self, result: Optional[DrawingRenderResult]) -> None:
+ """Display a generated render result."""
+ self._render_result = result
+ 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"
+ )
+ 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()
+
+ # ── UI construction ─────────────────────────────────────────────────────
+
+ def _init_ui(self) -> None:
+ layout = QHBoxLayout(self)
+
+ # ── Left controls ───────────────────────────────────────────────
+ left = QWidget()
+ left.setFixedWidth(300)
+ 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)
+
+ for vid, vname, vdir, vup in _STANDARD_VIEW_ROWS:
+ row = QHBoxLayout()
+ cb = QCheckBox(vname)
+ self._view_checkboxes[vid] = cb
+ row.addWidget(cb)
+
+ hl = QCheckBox("HL")
+ hl.setToolTip("Show hidden lines")
+ self._hidden_line_checks[vid] = hl
+ row.addWidget(hl)
+
+ cl = QCheckBox("CL")
+ cl.setToolTip("Show centerlines")
+ self._centerline_checks[vid] = cl
+ row.addWidget(cl)
+
+ views_layout.addLayout(row)
+
+ # Wire view/HL/CL toggles to regenerate.
+ for vid, cb in self._view_checkboxes.items():
+ cb.toggled.connect(lambda checked, v=vid: self._on_view_toggled(v))
+ for vid, cb in self._hidden_line_checks.items():
+ cb.toggled.connect(lambda checked, v=vid: self._on_view_toggled(v))
+ for vid, cb in self._centerline_checks.items():
+ cb.toggled.connect(lambda checked, v=vid: self._on_view_toggled(v))
+ # 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
+ # on) 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(True)
+ for cb in self._centerline_checks.values():
+ cb.setChecked(True)
+
+ left_layout.addWidget(views_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"),
+ ]:
+ row = QHBoxLayout()
+ row.addWidget(QLabel(lbl))
+ edit = QLineEdit()
+ setattr(self, f"_{attr}_edit", 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)
+ sheet_layout.addWidget(self._notes_edit)
+
+ left_layout.addWidget(sheet_group)
+
+ # Actions.
+ 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._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)
+ self._export_pdf_btn.clicked.connect(lambda: self._on_export("pdf"))
+ export_row.addWidget(self._export_pdf_btn)
+
+ self._export_svg_btn = QPushButton("Export SVG")
+ self._export_svg_btn.setEnabled(False)
+ self._export_svg_btn.clicked.connect(lambda: self._on_export("svg"))
+ export_row.addWidget(self._export_svg_btn)
+
+ actions_layout.addLayout(export_row)
+ left_layout.addWidget(actions_group)
+
+ # Status.
+ self._status_label = QLabel("No drawing loaded")
+ self._status_label.setWordWrap(True)
+ left_layout.addWidget(self._status_label)
+
+ left_layout.addStretch()
+ left.setLayout(left_layout)
+
+ # ── Scroll area for left panel ──────────────────────────────────
+ scroll = QScrollArea()
+ scroll.setWidget(left)
+ 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)
+
+ 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)
+
+ splitter = QSplitter(Qt.Horizontal)
+ splitter.addWidget(scroll)
+ splitter.addWidget(right)
+ 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.
+
+ 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.
+ """
+ source_kind = (
+ "component" if self._mode_component_btn.isChecked() else "assembly"
+ )
+ source_id = self._source_combo.currentData()
+
+ # 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:
+ 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 source_kind, source_id
+ def _populate_from_drawing(self, drawing: TechnicalDrawing) -> None:
+ """Sync UI 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).
+ for cb in self._view_checkboxes.values():
+ cb.blockSignals(True)
+ for cb in self._hidden_line_checks.values():
+ cb.blockSignals(True)
+ for cb in self._centerline_checks.values():
+ 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}
+ for vid, cb in self._view_checkboxes.items():
+ cb.setChecked(vid in enabled_views)
+
+ 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)
+
+ # Title block.
+ 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)
+ for cb in self._hidden_line_checks.values():
+ cb.blockSignals(False)
+ for cb in self._centerline_checks.values():
+ cb.blockSignals(False)
+
+ def _sync_views_to_drawing(self) -> None:
+ """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
+
+ def _refresh_annotation_table(self) -> None:
+ """Refresh annotation table from current drawing."""
+ self._annotation_table.setRowCount(0)
+ 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 ""
+ )
+ )
+
+ 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 _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()
+
+ # ── 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
+
+ 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:
+ 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(),
+ )
+
+ 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)
+
+ 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_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)"
+ )
+ if not path:
+ return
+ try:
+ Path(path).write_text(self._drawing.model_dump_json(indent=2))
+ self._status_label.setText(f"Saved to {path}")
+ except Exception as e:
+ self._status_label.setText(f"Save failed: {e}")