""" 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, field 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 # Unit vector in sheet space along which the distance is measured # (linear/extent candidates). Empty for diameter/angle. direction: Tuple[float, float] = () @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, ...] = () # View this primitive belongs to (geometry primitives); used for # hit-testing in the drawing workbench. view_id: Optional[str] = None @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, ...] # Per-view model→sheet transform: view_id → (scale, offset_x, offset_y) # with sheet(x, y) = (x*scale + offset_x, y*scale + offset_y). view_transforms: Dict[str, Tuple[float, float, float]] = field(default_factory=dict) # ── View presets ─────────────────────────────────────────────────────────── _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 _project_view( source_parts: Sequence[DrawingSourcePart], view: DrawingView, ) -> Tuple[ List[Tuple[Tuple[float, float], Tuple[float, float], str, str]], List[str], ]: """Project one view's edges via HLR. Returns ``(edges, warnings)`` where each edge is ``(p1, p2, curve_type, style)`` in model (view-plane) units and *style* is ``"visible"`` or ``"hidden"``. """ warnings: List[str] = [] edges: List[Tuple[Tuple[float, float], Tuple[float, float], str, str]] = [] direction = view.direction or (0.0, -1.0, 0.0) direction = _normalize(direction) 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 edges.extend((p1, p2, ct, "visible") for p1, p2, ct in vis_edges) if view.show_hidden_lines: edges.extend((p1, p2, ct, "hidden") for p1, p2, ct in hid_edges) return edges, warnings def _edges_bounds( edges: Sequence[Tuple[Tuple[float, float], Tuple[float, float], str, str]], ) -> Tuple[float, float, float, float]: """Bounding box ``(min_x, min_y, max_x, max_y)`` of projected edges. Full circles contribute their extremes, not just the centre/radius marker points. """ min_x = min_y = math.inf max_x = max_y = -math.inf for p1, p2, curve_type, _style in edges: if curve_type == "circle_full": cx, cy = p1 radius = p2[0] - p1[0] min_x = min(min_x, cx - radius) max_x = max(max_x, cx + radius) min_y = min(min_y, cy - radius) max_y = max(max_y, cy + radius) else: for pt in (p1, p2): min_x = min(min_x, pt[0]) max_x = max(max_x, pt[0]) min_y = min(min_y, pt[1]) max_y = max(max_y, pt[1]) if min_x > max_x: return (0.0, 0.0, 0.0, 0.0) return (min_x, min_y, max_x, max_y) def _assemble_view( edges: Sequence[Tuple[Tuple[float, float], Tuple[float, float], str, str]], warnings: Sequence[str], view: DrawingView, slot: Optional[Tuple[float, float, float, float]] = None, scale_override: Optional[float] = None, transforms: Optional[Dict[str, Tuple[float, float, float]]] = None, ) -> Tuple[Tuple[DrawingPrimitive, ...], Tuple[DrawingCandidate, ...], Tuple[str, ...]]: """Fit projected edges into *slot* and emit primitives + candidates. *slot* is ``(left, bottom, width, height)`` in sheet mm (origin bottom-left, +y up). When omitted the projection is fitted to the whole sheet. *scale_override* forces a specific model→sheet scale (used to keep all orthographic views at one shared scale). When *transforms* is given, the resolved ``(scale, offset_x, offset_y)`` is recorded under the view id. """ warnings = list(warnings) primitives: List[DrawingPrimitive] = [] candidates: List[DrawingCandidate] = [] if not edges: warnings.append(f"View '{view.name or view.kind}': no projected edges") return tuple(primitives), tuple(candidates), tuple(warnings) # Separate geometry by curve type (model units). line_segments: List[Tuple[Tuple[float, float], Tuple[float, float]]] = [] circle_data: List[Tuple[float, float, float]] = [] # (cx, cy, r) for p1, p2, curve_type, _style in edges: if curve_type == "circle_full": cx, cy = p1 radius = p2[0] - p1[0] if radius > 0.5: circle_data.append((cx, cy, radius)) elif curve_type == "line": line_segments.append((p1, p2)) # "other" = sampled arc points; used for fitting/rendering only. min_x, min_y, max_x, max_y = _edges_bounds(edges) geom_w = max(max_x - min_x, 1e-6) geom_h = max(max_y - min_y, 1e-6) if slot is not None: left, bottom, avail_w, avail_h = slot else: left, bottom = 0.0, 0.0 avail_w = _A3_WIDTH_MM - _TITLE_MARGIN_MM * 2 avail_h = _A3_HEIGHT_MM - _TITLE_MARGIN_MM * 2 if scale_override is not None: scale = scale_override else: scale = min(avail_w / geom_w, avail_h / geom_h) * view.scale offset_x = left + (avail_w - geom_w * scale) / 2.0 - min_x * scale offset_y = bottom + (avail_h - geom_h * scale) / 2.0 - min_y * scale # Use kind as view_id for readability (UUID is opaque to users). view_id = view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id) if transforms is not None: transforms[view_id] = (scale, offset_x, offset_y) def _to_sheet(x: float, y: float) -> Tuple[float, float]: return (x * scale + offset_x, y * scale + offset_y) # Convert edges to primitives (proper circle + hidden-line styles). for p1, p2, curve_type, style in edges: if curve_type == "circle_full": cx, cy = p1 radius = p2[0] - p1[0] if radius <= 0.5: continue primitives.append( DrawingPrimitive( kind="circle", points=(), style=style, center=_to_sheet(cx, cy), radius=radius * scale, view_id=view_id, ) ) else: primitives.append( DrawingPrimitive( kind="line", points=(_to_sheet(*p1), _to_sheet(*p2)), style=style, view_id=view_id, ) ) # ── Dimension candidate extraction ────────────────────────────── # 1. Overall extents (bounding-box width/height), anchored at real # bbox corners so extension lines can start at feature extremes. # Values are true model dimensions (scale-independent) — the labels # must match the part, not the sheet scale. width_val = (max_x - min_x) height_val = (max_y - min_y) bl = _to_sheet(min_x, min_y) br = _to_sheet(max_x, min_y) tl = _to_sheet(min_x, max_y) candidates.append( DrawingCandidate( key=f"{view_id}:extent:width", view_id=view_id, kind="extent", references=(), value=width_val, anchor_points=(bl, br), label=f"{width_val:.{_DISPLAY_PRECISION}f}", direction=(0.0, -1.0), ) ) candidates.append( DrawingCandidate( key=f"{view_id}:extent:height", view_id=view_id, kind="extent", references=(), value=height_val, anchor_points=(bl, tl), label=f"{height_val:.{_DISPLAY_PRECISION}f}", direction=(-1.0, 0.0), ) ) # 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 generate_view( source_parts: Sequence[DrawingSourcePart], view: DrawingView, slot: Optional[Tuple[float, float, float, float]] = None, ) -> Tuple[Tuple[DrawingPrimitive, ...], Tuple[DrawingCandidate, ...], Tuple[str, ...]]: """Project one view from source parts. *slot* optionally limits the fit to ``(left, bottom, width, height)`` in sheet mm; when omitted the projection is fitted to the full A3 sheet. Returns ``(primitives, candidates, warnings)``. """ edges, warnings = _project_view(source_parts, view) return _assemble_view(edges, warnings, view, slot, None) 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: # Full circles are recorded as a centre/radius marker (used for # rendering + diameter detection); partial arcs are sampled into # polyline segments so fillets do not become full circles. geom_circ = curve.Circle() # Geom_Circle center = geom_circ.Position().Location() radius = geom_circ.Radius() span = last - first if span >= 2.0 * math.pi - 0.05: out.append( ((center.X(), center.Y()), (center.X() + radius, center.Y()), "circle_full"), ) else: prev: Optional[Tuple[float, float]] = None for i in range(num_samples + 1): t = first + span * i / num_samples p = curve.Value(t) cur = (p.X(), p.Y()) if prev is not None: out.append((prev, cur, "other")) prev = cur 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 # true model diameter, not sheet-scaled # 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 (model units — # labels must show true part dimensions, not sheet-scaled ones). nx, ny = -dy1 / len1, dx1 / len1 # normal dist = abs((mx2 - mx1) * nx + (my2 - my1) * ny) 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}", direction=(nx, ny), ) ) 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. Anchors are ``(vertex, arm1_end, arm2_end)`` in sheet coordinates so the renderer can draw a small arc between the two arms. """ 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] 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 the shared point along each segment. other1 = p2 if shared == p1 else p1 other2 = q2 if shared == q1 else q1 dx1 = other1[0] - shared[0] dy1 = other1[1] - shared[1] dx2 = other2[0] - shared[0] dy2 = other2[1] - shared[1] 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 candidates.append( DrawingCandidate( key=f"{view_id}:angle:{angle_count}", view_id=view_id, kind="angle", references=(), value=angle_deg, anchor_points=( _to_sheet(*shared), _to_sheet(*other1), _to_sheet(*other2), ), label=f"{angle_deg:.{_DISPLAY_PRECISION}f}°", ) ) angle_count += 1 # ── Drawing generation ───────────────────────────────────────────────────── def _layout_views_on_sheet( views: Sequence[DrawingView], bboxes: Dict[str, Tuple[float, float, float, float]], ) -> Tuple[Dict[str, Tuple[float, float, float, float]], Optional[float]]: """Compute a slot rectangle for each view in standard orthographic layout. *bboxes* maps view_id → ``(min_x, min_y, max_x, max_y)`` in model units (from :func:`_edges_bounds`). Returns ``(slots, common_scale)``: slots are ``(left, bottom, width, height)`` in sheet mm (origin at the sheet's bottom-left corner, +y up). Layout (third-angle projection, aligned projections):: top isometric left front right back bottom All orthographic views share one scale (the tightest fit that keeps every projection in its footprint) so the views stay mutually consistent, and each view is centred in its allotted space. """ if not views: return {}, None margin = 10.0 gap = 12.0 title_block_h = 55.0 full_w = _A3_WIDTH_MM - 2 * margin # The bottom view sits at the bottom of the projection column, so the # whole column stays clear of the title block (bottom-right corner). col_bottom = margin + title_block_h col_top = _A3_HEIGHT_MM - margin col_avail = col_top - col_bottom def dims(vid: str) -> Tuple[float, float]: b = bboxes.get(vid) if b is None: return 1.0, 1.0 return max(b[2] - b[0], 1e-6), max(b[3] - b[1], 1e-6) ortho_kinds = [ v.kind for v in views if v.kind in _STANDARD_VIEWS and v.kind != "isometric" ] mid_order = ["left", "front", "right", "back"] col_order = ["top", "front", "bottom"] # top → bottom present_mid = [k for k in mid_order if k in ortho_kinds] present_col = [k for k in col_order if k in ortho_kinds] slots: Dict[str, Tuple[float, float, float, float]] = {} common_scale: Optional[float] = None if present_mid or present_col: row_w = sum(dims(k)[0] for k in present_mid) col_h = sum(dims(k)[1] for k in present_col) scale_opts: List[float] = [] if present_mid: scale_opts.append((full_w - gap * (len(present_mid) - 1)) / row_w) if present_col: scale_opts.append((col_avail - gap * (len(present_col) - 1)) / col_h) common_scale = min(scale_opts) # Middle row: left → front → right → back, centred on the sheet. total_row = row_w * common_scale + gap * (len(present_mid) - 1) x = margin + (full_w - total_row) / 2.0 row_slots: Dict[str, Tuple[float, float, float]] = {} for k in present_mid: w, h = dims(k) row_slots[k] = (x, w * common_scale, h * common_scale) x += w * common_scale + gap # Column: top → front → bottom, stacked from the top edge down and # centred in the available column (which stays clear of the title # block). total_col = col_h * common_scale + gap * (len(present_col) - 1) y = col_bottom + col_avail - (col_avail - total_col) / 2.0 col_slots: Dict[str, Tuple[float, float, float]] = {} for k in present_col: w, h = dims(k) sh = h * common_scale col_slots[k] = (y - sh, w * common_scale, sh) y -= sh + gap anchor = ( "front" if "front" in present_mid else (present_mid[0] if present_mid else present_col[0]) ) if anchor in row_slots: ax, aw, ah = row_slots[anchor] ay = ( col_slots[anchor][0] if anchor in col_slots else col_bottom + (col_avail - ah) / 2.0 ) else: ay, aw, ah = col_slots[anchor] ax = margin + (full_w - aw) / 2.0 anchor_cx = ax + aw / 2.0 for k in set(present_mid) | set(present_col): w, h = dims(k) sw, sh = w * common_scale, h * common_scale if k in row_slots and k in col_slots: sx = row_slots[k][0] sy = col_slots[k][0] elif k in row_slots: # Mid-row view without a column slot: centre on the anchor. sx = row_slots[k][0] sy = ay + (ah - sh) / 2.0 else: # Column view without a mid slot: align with the anchor. sx = anchor_cx - sw / 2.0 sy = col_slots[k][0] slots[k] = (sx, sy, sw, sh) # Isometric: free region to the right of the main block. if any(v.kind == "isometric" for v in views): iso_x0 = ax + aw + gap if "top" in col_slots: iso_x0 = max(iso_x0, anchor_cx + col_slots["top"][1] / 2.0 + gap) iso_y0 = ay + ah + gap iso_x1 = _A3_WIDTH_MM - margin iso_y1 = _A3_HEIGHT_MM - margin if iso_x1 - iso_x0 < 30.0 or iso_y1 - iso_y0 < 30.0: # No room at the right — fall back to the bottom-left corner. left_x = slots.get("left", (margin + full_w * 0.5,))[0] iso_x1 = min(iso_x1, left_x - gap) bottom_y = slots.get("bottom", (0.0, col_bottom + col_avail * 0.5, 0, 0))[1] iso_y1 = min(iso_y1, bottom_y - gap) slots["isometric"] = ( iso_x0, iso_y0, max(iso_x1 - iso_x0, 10.0), max(iso_y1 - iso_y0, 10.0), ) else: # No standard ortho views — give the isometric most of the sheet. if any(v.kind == "isometric" for v in views): slots["isometric"] = (margin, margin, full_w * 0.55, col_avail) # Custom (non-standard) views fill the bottom-left corner. custom = [v for v in views if v.kind not in _STANDARD_VIEWS] if custom: left_edge = slots.get("left", (margin + full_w * 0.4,))[0] bottom_edge = slots.get("bottom", (0.0, col_bottom + col_avail * 0.4, 0, 0))[1] cw = max(left_edge - margin - gap, 60.0) ch = max(bottom_edge - margin - gap, 60.0) for i, v in enumerate(custom): vid = v.name or v.id slots[vid] = (margin, margin + i * (ch + gap), cw, ch) return slots, common_scale # ── Dimension selection & placement ─────────────────────────────────────── # Per-view dimension budget: keep sheets readable for a machinist. _DIM_KIND_CAPS = {"diameter": 3, "extent": 2, "length": 4, "angle": 2} _MAX_DIMENSIONS_PER_VIEW = 10 # Rendering metrics for the drawing font size. _DIM_TEXT_H_MM = 3.0 # text cap height (standard A3 drawing) _DIM_FONT_W = 1.9 # approx. mm width per character _DIM_FONT_H = 4.0 # text box height in mm _DIM_OFFSET_MM = 6.0 # dimension-line offset from the measured feature _DIM_EXT_OVERSHOOT_MM = 2.0 # extension-line overshoot past the dim line _DIM_STANDOFF_MM = 11.0 # min gap between stacked parallel dim lines _ARROW_MM = 3.0 _LEADER_LEAD_MM = 8.0 _LEADER_TAIL_MM = 10.0 _ANGLE_ARC_MM = 5.0 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. Caps the number of placed dimensions per view so sheets stay readable. """ view_candidates = [c for c in candidates if c.view_id == view_id] def _value_key(val: float) -> float: return round(val / 0.5) * 0.5 # bucket by 0.5 for dedup # Overall-extent values: linear candidates matching an extent are # redundant (they measure the same overall size). extent_keys = { _value_key(c.value) for c in view_candidates if c.kind == "extent" and 0.1 <= c.value <= 2000.0 } # 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) selected: List[DrawingCandidate] = [] seen_values: set = set() # track approximate values to avoid duplicates per_kind: Dict[str, int] = {} for c in sorted(view_candidates, key=_priority): if len(selected) >= _MAX_DIMENSIONS_PER_VIEW: break # Skip tiny or enormous dimensions. if c.value < 0.1 or c.value > 2000.0: continue # Respect the per-kind budget. if per_kind.get(c.kind, 0) >= _DIM_KIND_CAPS.get(c.kind, 3): continue vkey = _value_key(c.value) # Skip duplicates within same kind+view. if (c.kind, vkey) in seen_values: continue # Skip linear duplicates of overall extents. if c.kind == "length" and vkey in extent_keys: continue selected.append(c) seen_values.add((c.kind, vkey)) per_kind[c.kind] = per_kind.get(c.kind, 0) + 1 return selected def _generate_dimension_primitives( candidates: Sequence[DrawingCandidate], view_center: Tuple[float, float], ) -> List[DrawingPrimitive]: """Convert dimension candidates into ISO-style renderable primitives. Linear/extent candidates become extension lines + an offset dimension line with arrowheads + centred text (dimension line broken for the label). Anchors on one feature line yield a dimension line perpendicular to the measurement direction; anchors that are the closest points of two parallel edges (distance dimensions) yield a dimension line parallel to it. Diameters become a 45° leader with a horizontal tail. Angles become a small vertex arc + text. """ # Keep dimension lines inside the sheet (with a small margin). _sheet_min_x, _sheet_max_x = 6.0, _A3_WIDTH_MM - 6.0 _sheet_min_y, _sheet_max_y = 6.0, _A3_HEIGHT_MM - 6.0 def _clamp_sheet(pt: Tuple[float, float]) -> Tuple[float, float]: return ( min(max(pt[0], _sheet_min_x), _sheet_max_x), min(max(pt[1], _sheet_min_y), _sheet_max_y), ) 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 # Placed dimension lines: (u_x, u_y, q_x, q_y, t_min, t_max) — unit # direction u, point q on the line, foot span relative to q along u. placed_dim_lines: List[Tuple[float, float, float, float, float, float]] = [] 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)) def _add_text(center_x: float, center_y: float, text: str, key: Optional[str]) -> None: w = len(text) * _DIM_FONT_W x = center_x - w / 2.0 base_y = center_y + 1.0 # text sits just above the reference point if _would_overlap(x, base_y, w, _DIM_FONT_H): base_y = center_y - 1.0 - _DIM_FONT_H # drop below instead prims.append( DrawingPrimitive( kind="text", points=((x, base_y),), style="dimension", text=text, candidate_key=key, center=(center_x, center_y), ) ) _add_zone(x, base_y, w, _DIM_FONT_H) def _add_arrow(tip: Tuple[float, float], u: Tuple[float, float]) -> None: bx, by = tip[0] - u[0] * _ARROW_MM, tip[1] - u[1] * _ARROW_MM px, py = -u[1] * _ARROW_MM * 0.4, u[0] * _ARROW_MM * 0.4 prims.append( DrawingPrimitive(kind="line", points=(tip, (bx + px, by + py)), style="dimension") ) prims.append( DrawingPrimitive(kind="line", points=(tip, (bx - px, by - py)), style="dimension") ) for c in candidates: if len(c.anchor_points) < 2: continue if c.kind in ("extent", "length"): p1, p2 = c.anchor_points[0], c.anchor_points[1] d = c.direction dl = math.hypot(d[0], d[1]) if d else 0.0 if dl > 1e-9: d = (d[0] / dl, d[1] / dl) else: vx, vy = p2[0] - p1[0], p2[1] - p1[1] vl = math.hypot(vx, vy) d = (vx / vl, vy / vl) if vl > 1e-9 else (1.0, 0.0) mx, my = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0 # Decompose the anchor pair: separation along the measurement # direction d and perpendicular to it. cross ≈ 0 means the # anchors are the closest points on two parallel edges (a # distance dimension); otherwise they sit on one feature line. wdx, wdy = p2[0] - p1[0], p2[1] - p1[1] along = wdx * d[0] + wdy * d[1] cross = abs(wdx * d[1] - wdy * d[0]) if cross > 0.5: # ── Feature-line case ───────────────────────────────── # The dimension line is perpendicular to d, offset away # from the view centre (half the anchor spread plus the # standard offset, so it lands beyond the far feature). spread = abs(along) / 2.0 offset = spread + _DIM_OFFSET_MM sign = ( -1.0 if (view_center[0] - mx) * d[0] + (view_center[1] - my) * d[1] > 0 else 1.0 ) cdim = _clamp_sheet((mx + sign * d[0] * offset, my + sign * d[1] * offset)) # Progressive stacking: push the dimension line away from # already-placed parallel lines whose foot spans overlap, so # parallel dimensions stay readable (standard CAD behaviour). for _ in range(8): t1 = (cdim[0] - p1[0]) * d[0] + (cdim[1] - p1[1]) * d[1] t2 = (cdim[0] - p2[0]) * d[0] + (cdim[1] - p2[1]) * d[1] e1 = (p1[0] + d[0] * t1, p1[1] + d[1] * t1) e2 = (p2[0] + d[0] * t2, p2[1] + d[1] * t2) half = math.hypot(e2[0] - e1[0], e2[1] - e1[1]) / 2.0 if half <= 0.5: break u = ( (e2[0] - e1[0]) / (2.0 * half), (e2[1] - e1[1]) / (2.0 * half), ) pushed = False for (ux0, uy0, qx0, qy0, tmin0, tmax0) in placed_dim_lines: if abs(u[0] * ux0 + u[1] * uy0) < 0.98: continue # not parallel ta1 = (e1[0] - qx0) * ux0 + (e1[1] - qy0) * uy0 ta2 = (e2[0] - qx0) * ux0 + (e2[1] - qy0) * uy0 if max(ta1, ta2) < tmin0 or min(ta1, ta2) > tmax0: continue # spans do not overlap sep = (cdim[0] - qx0) * d[0] + (cdim[1] - qy0) * d[1] if abs(sep) < _DIM_STANDOFF_MM: prev = cdim cdim = ( cdim[0] + sign * d[0] * (_DIM_STANDOFF_MM - abs(sep)), cdim[1] + sign * d[1] * (_DIM_STANDOFF_MM - abs(sep)), ) cdim = _clamp_sheet(cdim) if cdim == prev: # Pushed against the sheet edge — stop stacking. break pushed = True break if not pushed: break # Final feet of the (possibly stacked) dimension line. t1 = (cdim[0] - p1[0]) * d[0] + (cdim[1] - p1[1]) * d[1] t2 = (cdim[0] - p2[0]) * d[0] + (cdim[1] - p2[1]) * d[1] e1 = (p1[0] + d[0] * t1, p1[1] + d[1] * t1) e2 = (p2[0] + d[0] * t2, p2[1] + d[1] * t2) half = math.hypot(e2[0] - e1[0], e2[1] - e1[1]) / 2.0 ext_dir = (sign * d[0], sign * d[1]) else: # ── Inter-edge distance case ────────────────────────── # The measured distance runs along d; the dimension line # is parallel to d, offset from the picked line on the # side away from the view centre. n = (-d[1], d[0]) sign = ( -1.0 if (view_center[0] - mx) * n[0] + (view_center[1] - my) * n[1] > 0 else 1.0 ) cdim = _clamp_sheet( ( mx + sign * n[0] * _DIM_OFFSET_MM, my + sign * n[1] * _DIM_OFFSET_MM, ) ) half = abs(along) / 2.0 u_dir = (d[0], d[1]) if along >= 0 else (-d[0], -d[1]) # Progressive stacking (same rules, pushing along n). for _ in range(8): if half <= 0.5: break e1 = (cdim[0] - u_dir[0] * half, cdim[1] - u_dir[1] * half) e2 = (cdim[0] + u_dir[0] * half, cdim[1] + u_dir[1] * half) pushed = False for (ux0, uy0, qx0, qy0, tmin0, tmax0) in placed_dim_lines: if abs(u_dir[0] * ux0 + u_dir[1] * uy0) < 0.98: continue # not parallel ta1 = (e1[0] - qx0) * ux0 + (e1[1] - qy0) * uy0 ta2 = (e2[0] - qx0) * ux0 + (e2[1] - qy0) * uy0 if max(ta1, ta2) < tmin0 or min(ta1, ta2) > tmax0: continue # spans do not overlap sep = (cdim[0] - qx0) * n[0] + (cdim[1] - qy0) * n[1] if abs(sep) < _DIM_STANDOFF_MM: prev = cdim cdim = ( cdim[0] + sign * n[0] * (_DIM_STANDOFF_MM - abs(sep)), cdim[1] + sign * n[1] * (_DIM_STANDOFF_MM - abs(sep)), ) cdim = _clamp_sheet(cdim) if cdim == prev: # Pushed against the sheet edge — stop stacking. break pushed = True break if not pushed: break e1 = (cdim[0] - u_dir[0] * half, cdim[1] - u_dir[1] * half) e2 = (cdim[0] + u_dir[0] * half, cdim[1] + u_dir[1] * half) ext_dir = (sign * n[0], sign * n[1]) # ── Shared rendering of the positioned dimension line ───── if half > 0.5: u = ((e2[0] - e1[0]) / (2.0 * half), (e2[1] - e1[1]) / (2.0 * half)) s1 = (e1[0] - cdim[0]) * u[0] + (e1[1] - cdim[1]) * u[1] s2 = (e2[0] - cdim[0]) * u[0] + (e2[1] - cdim[1]) * u[1] placed_dim_lines.append( (u[0], u[1], cdim[0], cdim[1], min(s1, s2), max(s1, s2)) ) # Extension lines: anchor → 2mm past the dimension line. for anchor, foot in ((p1, e1), (p2, e2)): end = ( foot[0] + ext_dir[0] * _DIM_EXT_OVERSHOOT_MM, foot[1] + ext_dir[1] * _DIM_EXT_OVERSHOOT_MM, ) prims.append( DrawingPrimitive( kind="line", points=(anchor, end), style="dimension", candidate_key=c.key, ) ) # Dimension line, broken for the centred label. label_w = len(c.label) * _DIM_FONT_W gap = label_w / 2.0 + 1.5 if half > gap + 2.0: prims.append( DrawingPrimitive( kind="line", points=(e1, (cdim[0] - u[0] * gap, cdim[1] - u[1] * gap)), style="dimension", candidate_key=c.key, ) ) prims.append( DrawingPrimitive( kind="line", points=((cdim[0] + u[0] * gap, cdim[1] + u[1] * gap), e2), style="dimension", candidate_key=c.key, ) ) # Arrowheads at both ends, pointing inward. if half > _ARROW_MM + 1.0: _add_arrow(e1, u) _add_arrow(e2, (-u[0], -u[1])) _add_text(cdim[0], cdim[1], c.label, c.key) elif c.kind == "diameter": p1, p2 = c.anchor_points[0], c.anchor_points[1] cx, cy = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0 r = math.hypot(p2[0] - p1[0], p2[1] - p1[1]) / 2.0 placed = False for flip in (0.0, math.pi): ux = math.cos(math.pi / 4.0 + flip) uy = math.sin(math.pi / 4.0 + flip) lead_start = (cx + ux * r, cy + uy * r) lead_end = ( cx + ux * (r + _LEADER_LEAD_MM), cy + uy * (r + _LEADER_LEAD_MM), ) tail_end = (lead_end[0] + ux * _LEADER_TAIL_MM, lead_end[1]) label_w = len(c.label) * _DIM_FONT_W tx = lead_end[0] + 1.5 if flip == 0.0 else lead_end[0] - 1.5 - label_w ty = lead_end[1] - _DIM_FONT_H / 2.0 if _would_overlap(tx, ty, label_w, _DIM_FONT_H): continue prims.append( DrawingPrimitive( kind="line", points=(lead_start, tail_end), style="dimension", candidate_key=c.key, ) ) prims.append( DrawingPrimitive( kind="text", points=((tx, ty),), style="dimension", text=c.label, candidate_key=c.key, center=(tx + label_w / 2.0, ty + _DIM_FONT_H / 2.0), ) ) _add_zone(tx, ty, label_w, _DIM_FONT_H) placed = True break if not placed: # No free diagonal: emit the text at the 45° leader end # without a zone check rather than dropping it. ux = math.cos(math.pi / 4.0) lead_end = ( cx + ux * (r + _LEADER_LEAD_MM), cy + ux * (r + _LEADER_LEAD_MM), ) prims.append( DrawingPrimitive( kind="text", points=((lead_end[0] + 1.5, lead_end[1] - _DIM_FONT_H / 2.0),), style="dimension", text=c.label, candidate_key=c.key, ) ) elif c.kind == "angle" and len(c.anchor_points) >= 3: vertex, a1, a2 = c.anchor_points[0], c.anchor_points[1], c.anchor_points[2] a1d = math.atan2(a1[1] - vertex[1], a1[0] - vertex[0]) a2d = math.atan2(a2[1] - vertex[1], a2[0] - vertex[0]) delta = (a2d - a1d + math.pi) % (2.0 * math.pi) - math.pi prev: Optional[Tuple[float, float]] = None steps = 8 for i in range(steps + 1): t = a1d + delta * i / steps pt = ( vertex[0] + math.cos(t) * _ANGLE_ARC_MM, vertex[1] + math.sin(t) * _ANGLE_ARC_MM, ) if prev is not None: prims.append( DrawingPrimitive( kind="line", points=(prev, pt), style="dimension", candidate_key=c.key ) ) prev = pt bis = a1d + delta / 2.0 _add_text( vertex[0] + math.cos(bis) * 9.0, vertex[1] + math.sin(bis) * 9.0, c.label, c.key, ) return prims _MANUAL_DIMENSION_KINDS = ("length", "diameter", "angle") def build_manual_candidates( drawing: TechnicalDrawing, view_transforms: Dict[str, Tuple[float, float, float]], ) -> Tuple[List[DrawingCandidate], List[str], List[str]]: """Convert user-placed annotations into renderable dimension candidates. Manual annotations store their feature geometry in view-plane model coordinates (see ``DrawingAnnotation.anchors``); here it is re-projected through the current view transforms so the dimensions re-lay out correctly whenever views, slots, or the shared scale change. Returns ``(candidates, resolved_ids, unresolved_ids)``. """ candidates: List[DrawingCandidate] = [] resolved: List[str] = [] unresolved: List[str] = [] for ann in drawing.annotations: if not ann.visible or ann.dimension_kind not in _MANUAL_DIMENSION_KINDS: continue if len(ann.anchors) < 2: unresolved.append(ann.id) continue transform = view_transforms.get(ann.view_id) if transform is None: unresolved.append(ann.id) continue scale, offset_x, offset_y = transform def to_sheet(pt: Tuple[float, float]) -> Tuple[float, float]: return (pt[0] * scale + offset_x, pt[1] * scale + offset_y) a0, a1 = ann.anchors[0], ann.anchors[1] if ann.dimension_kind == "angle": if len(ann.anchors) < 3: unresolved.append(ann.id) continue vertex, arm1, arm2 = ann.anchors v1x, v1y = arm1[0] - vertex[0], arm1[1] - vertex[1] v2x, v2y = arm2[0] - vertex[0], arm2[1] - vertex[1] m1 = math.hypot(v1x, v1y) m2 = math.hypot(v2x, v2y) if m1 < 1e-9 or m2 < 1e-9: unresolved.append(ann.id) continue cos_a = (v1x * v2x + v1y * v2y) / (m1 * m2) value = math.degrees(math.acos(max(-1.0, min(1.0, cos_a)))) if value < 0.5 or value > 179.5: unresolved.append(ann.id) continue candidates.append( DrawingCandidate( key=f"manual:{ann.id}", view_id=ann.view_id, kind="angle", references=(ann.id,), value=value, anchor_points=(to_sheet(vertex), to_sheet(arm1), to_sheet(arm2)), label=f"{value:.{_DISPLAY_PRECISION}f}°", ) ) resolved.append(ann.id) continue value = math.dist(a0, a1) if value < 1e-6: unresolved.append(ann.id) continue if ann.dimension_kind == "length": if ann.direction: dx, dy = float(ann.direction[0]), float(ann.direction[1]) if math.hypot(dx, dy) < 1e-9: dx, dy = a1[0] - a0[0], a1[1] - a0[1] else: dx, dy = a1[0] - a0[0], a1[1] - a0[1] # value >= 1e-6 guarantees the fallback vector is non-zero. mag = math.hypot(dx, dy) direction: Tuple[float, float] = (dx / mag, dy / mag) label = f"{value:.{_DISPLAY_PRECISION}f}" else: # diameter direction = () label = f"Ø{value:.{_DISPLAY_PRECISION}f}" candidates.append( DrawingCandidate( key=f"manual:{ann.id}", view_id=ann.view_id, kind=ann.dimension_kind, references=(ann.id,), value=value, anchor_points=(to_sheet(a0), to_sheet(a1)), label=label, direction=direction, ) ) resolved.append(ann.id) return candidates, resolved, unresolved def generate_drawing( drawing: TechnicalDrawing, project: Project, 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), ) def _vid_of(view: DrawingView) -> str: return view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id) # Project every view once (HLR is the expensive step). projections: Dict[ str, Tuple[ List[Tuple[Tuple[float, float], Tuple[float, float], str, str]], List[str], ], ] = {} for view in drawing.views: projections[_vid_of(view)] = _project_view(parts, view) # Layout: slots + shared ortho scale derived from the actual # projected sizes. bboxes = { _vid_of(view): _edges_bounds(projections[_vid_of(view)][0]) for view in drawing.views } view_slots, common_scale = _layout_views_on_sheet(drawing.views, bboxes) ortho_vids = { _vid_of(v) for v in drawing.views if v.kind in _STANDARD_VIEWS and v.kind != "isometric" } # Per-view model→sheet transforms, filled in by _assemble_view. transforms: Dict[str, Tuple[float, float, float]] = {} for view in drawing.views: vid = _vid_of(view) slot = view_slots.get(vid) edges, view_warnings = projections[vid] scale_override = ( common_scale * view.scale if vid in ortho_vids and common_scale else None ) prims, cands, vwarns = _assemble_view( edges, view_warnings, view, slot, scale_override, transforms ) all_primitives.extend(prims) all_candidates.extend(cands) warnings.extend(vwarns) # User-placed dimensions: convert the model-space annotations into # sheet-space candidates so they re-lay out with the current views. manual_cands, manual_resolved, manual_unresolved = build_manual_candidates( drawing, transforms ) manual_ids = {c.references[0] for c in manual_cands if c.references} resolved_ids: List[str] = list(manual_resolved) unresolved_ids: List[str] = list(manual_unresolved) # Place dimensions per view: the auto selection (only while the user # has auto dimensions enabled) plus the manual dimensions, so the # stacking/overlap logic treats them alike. Isometric views are never # dimensioned on real drawings. for view in drawing.views: if view.kind == "isometric": continue vid = _vid_of(view) slot = view_slots.get(vid) center = ( (slot[0] + slot[2] / 2.0, slot[1] + slot[3] / 2.0) if slot else (_A3_WIDTH_MM / 2.0, _A3_HEIGHT_MM / 2.0) ) selected: List[DrawingCandidate] = [] if drawing.auto_dimensions: selected = _select_dimensions_for_placement(all_candidates, vid) selected.extend(c for c in manual_cands if c.view_id == vid) all_primitives.extend(_generate_dimension_primitives(selected, center)) # Match legacy (reference-based) annotations to candidates. Manual # dimensions were already rendered above as full dimension primitives. candidate_by_key: Dict[str, DrawingCandidate] = {c.key: c for c in all_candidates} for ann in drawing.annotations: if not ann.visible: continue if ann.id in manual_ids: continue if ann.kind == "note": resolved_ids.append(ann.id) continue 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), view_transforms=transforms, ) 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 = 52.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) + 3 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") # Font size in device px so the text height is a constant sheet mm. font.setPixelSize(max(6, int(_DIM_TEXT_H_MM * 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()