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 @@ - - - - { - "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}")