Files
fluencyCAD/src/fluency/technical_drawing.py
T
2026-08-17 18:35:18 +02:00

1785 lines
67 KiB
Python

"""
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()