- tech draw draft v2

This commit is contained in:
bklronin
2026-08-17 18:35:18 +02:00
parent 108ad2d5b5
commit 37e5335446
6 changed files with 1117 additions and 577 deletions
+268 -66
View File
@@ -14,7 +14,7 @@ Exact public API:
from __future__ import annotations
import math
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Tuple
import numpy as np
@@ -68,6 +68,9 @@ class DrawingPrimitive:
center: Optional[Tuple[float, float]] = None
radius: Optional[float] = None
dash_pattern: Tuple[float, ...] = ()
# View this primitive belongs to (geometry primitives); used for
# hit-testing in the drawing workbench.
view_id: Optional[str] = None
@dataclass(frozen=True)
@@ -80,6 +83,9 @@ class DrawingRenderResult:
unresolved_annotation_ids: Tuple[str, ...]
source_fingerprint: str
warnings: Tuple[str, ...]
# Per-view model→sheet transform: view_id → (scale, offset_x, offset_y)
# with sheet(x, y) = (x*scale + offset_x, y*scale + offset_y).
view_transforms: Dict[str, Tuple[float, float, float]] = field(default_factory=dict)
# ── View presets ───────────────────────────────────────────────────────────
@@ -319,6 +325,7 @@ def _assemble_view(
view: DrawingView,
slot: Optional[Tuple[float, float, float, float]] = None,
scale_override: Optional[float] = None,
transforms: Optional[Dict[str, Tuple[float, float, float]]] = None,
) -> Tuple[Tuple[DrawingPrimitive, ...], Tuple[DrawingCandidate, ...], Tuple[str, ...]]:
"""Fit projected edges into *slot* and emit primitives + candidates.
@@ -326,6 +333,8 @@ def _assemble_view(
bottom-left, +y up). When omitted the projection is fitted to the
whole sheet. *scale_override* forces a specific model→sheet scale
(used to keep all orthographic views at one shared scale).
When *transforms* is given, the resolved
``(scale, offset_x, offset_y)`` is recorded under the view id.
"""
warnings = list(warnings)
primitives: List[DrawingPrimitive] = []
@@ -366,6 +375,11 @@ def _assemble_view(
offset_x = left + (avail_w - geom_w * scale) / 2.0 - min_x * scale
offset_y = bottom + (avail_h - geom_h * scale) / 2.0 - min_y * scale
# Use kind as view_id for readability (UUID is opaque to users).
view_id = view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id)
if transforms is not None:
transforms[view_id] = (scale, offset_x, offset_y)
def _to_sheet(x: float, y: float) -> Tuple[float, float]:
return (x * scale + offset_x, y * scale + offset_y)
@@ -383,6 +397,7 @@ def _assemble_view(
style=style,
center=_to_sheet(cx, cy),
radius=radius * scale,
view_id=view_id,
)
)
else:
@@ -391,12 +406,11 @@ def _assemble_view(
kind="line",
points=(_to_sheet(*p1), _to_sheet(*p2)),
style=style,
view_id=view_id,
)
)
# ── Dimension candidate extraction ──────────────────────────────
# Use kind as view_id for readability (UUID is opaque to users).
view_id = view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id)
# 1. Overall extents (bounding-box width/height), anchored at real
# bbox corners so extension lines can start at feature extremes.
@@ -1011,8 +1025,11 @@ def _generate_dimension_primitives(
Linear/extent candidates become extension lines + an offset dimension
line with arrowheads + centred text (dimension line broken for the
label). Diameters become a 45° leader with a horizontal tail. Angles
become a small vertex arc + text.
label). Anchors on one feature line yield a dimension line
perpendicular to the measurement direction; anchors that are the
closest points of two parallel edges (distance dimensions) yield a
dimension line parallel to it. Diameters become a 45° leader with a
horizontal tail. Angles become a small vertex arc + text.
"""
# Keep dimension lines inside the sheet (with a small margin).
_sheet_min_x, _sheet_max_x = 6.0, _A3_WIDTH_MM - 6.0
@@ -1084,65 +1101,129 @@ def _generate_dimension_primitives(
d = (vx / vl, vy / vl) if vl > 1e-9 else (1.0, 0.0)
mx, my = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0
# Half the anchor spread along the measurement direction: the
# dimension line lands this far (plus the offset) beyond the
# far feature line.
spread = abs((p2[0] - p1[0]) * d[0] + (p2[1] - p1[1]) * d[1]) / 2.0
offset = spread + _DIM_OFFSET_MM
# Place the dimension line on the side of the anchor pair away
# from the view centre (outside the feature).
sign = (
-1.0
if (view_center[0] - mx) * d[0] + (view_center[1] - my) * d[1] > 0
else 1.0
)
cdim = _clamp_sheet((mx + sign * d[0] * offset, my + sign * d[1] * offset))
# Decompose the anchor pair: separation along the measurement
# direction d and perpendicular to it. cross ≈ 0 means the
# anchors are the closest points on two parallel edges (a
# distance dimension); otherwise they sit on one feature line.
wdx, wdy = p2[0] - p1[0], p2[1] - p1[1]
along = wdx * d[0] + wdy * d[1]
cross = abs(wdx * d[1] - wdy * d[0])
# Progressive stacking: push the dimension line away from
# already-placed parallel lines whose foot spans overlap, so
# parallel dimensions stay readable (standard CAD behaviour).
for _ in range(8):
if cross > 0.5:
# ── Feature-line case ─────────────────────────────────
# The dimension line is perpendicular to d, offset away
# from the view centre (half the anchor spread plus the
# standard offset, so it lands beyond the far feature).
spread = abs(along) / 2.0
offset = spread + _DIM_OFFSET_MM
sign = (
-1.0
if (view_center[0] - mx) * d[0] + (view_center[1] - my) * d[1] > 0
else 1.0
)
cdim = _clamp_sheet((mx + sign * d[0] * offset, my + sign * d[1] * offset))
# Progressive stacking: push the dimension line away from
# already-placed parallel lines whose foot spans overlap, so
# parallel dimensions stay readable (standard CAD behaviour).
for _ in range(8):
t1 = (cdim[0] - p1[0]) * d[0] + (cdim[1] - p1[1]) * d[1]
t2 = (cdim[0] - p2[0]) * d[0] + (cdim[1] - p2[1]) * d[1]
e1 = (p1[0] + d[0] * t1, p1[1] + d[1] * t1)
e2 = (p2[0] + d[0] * t2, p2[1] + d[1] * t2)
half = math.hypot(e2[0] - e1[0], e2[1] - e1[1]) / 2.0
if half <= 0.5:
break
u = (
(e2[0] - e1[0]) / (2.0 * half),
(e2[1] - e1[1]) / (2.0 * half),
)
pushed = False
for (ux0, uy0, qx0, qy0, tmin0, tmax0) in placed_dim_lines:
if abs(u[0] * ux0 + u[1] * uy0) < 0.98:
continue # not parallel
ta1 = (e1[0] - qx0) * ux0 + (e1[1] - qy0) * uy0
ta2 = (e2[0] - qx0) * ux0 + (e2[1] - qy0) * uy0
if max(ta1, ta2) < tmin0 or min(ta1, ta2) > tmax0:
continue # spans do not overlap
sep = (cdim[0] - qx0) * d[0] + (cdim[1] - qy0) * d[1]
if abs(sep) < _DIM_STANDOFF_MM:
prev = cdim
cdim = (
cdim[0] + sign * d[0] * (_DIM_STANDOFF_MM - abs(sep)),
cdim[1] + sign * d[1] * (_DIM_STANDOFF_MM - abs(sep)),
)
cdim = _clamp_sheet(cdim)
if cdim == prev:
# Pushed against the sheet edge — stop stacking.
break
pushed = True
break
if not pushed:
break
# Final feet of the (possibly stacked) dimension line.
t1 = (cdim[0] - p1[0]) * d[0] + (cdim[1] - p1[1]) * d[1]
t2 = (cdim[0] - p2[0]) * d[0] + (cdim[1] - p2[1]) * d[1]
e1 = (p1[0] + d[0] * t1, p1[1] + d[1] * t1)
e2 = (p2[0] + d[0] * t2, p2[1] + d[1] * t2)
half = math.hypot(e2[0] - e1[0], e2[1] - e1[1]) / 2.0
if half <= 0.5:
break
u = (
(e2[0] - e1[0]) / (2.0 * half),
(e2[1] - e1[1]) / (2.0 * half),
ext_dir = (sign * d[0], sign * d[1])
else:
# ── Inter-edge distance case ──────────────────────────
# The measured distance runs along d; the dimension line
# is parallel to d, offset from the picked line on the
# side away from the view centre.
n = (-d[1], d[0])
sign = (
-1.0
if (view_center[0] - mx) * n[0] + (view_center[1] - my) * n[1] > 0
else 1.0
)
pushed = False
for (ux0, uy0, qx0, qy0, tmin0, tmax0) in placed_dim_lines:
if abs(u[0] * ux0 + u[1] * uy0) < 0.98:
continue # not parallel
ta1 = (e1[0] - qx0) * ux0 + (e1[1] - qy0) * uy0
ta2 = (e2[0] - qx0) * ux0 + (e2[1] - qy0) * uy0
if max(ta1, ta2) < tmin0 or min(ta1, ta2) > tmax0:
continue # spans do not overlap
sep = (cdim[0] - qx0) * d[0] + (cdim[1] - qy0) * d[1]
if abs(sep) < _DIM_STANDOFF_MM:
prev = cdim
cdim = (
cdim[0] + sign * d[0] * (_DIM_STANDOFF_MM - abs(sep)),
cdim[1] + sign * d[1] * (_DIM_STANDOFF_MM - abs(sep)),
)
cdim = _clamp_sheet(cdim)
if cdim == prev:
# Pushed against the sheet edge — stop stacking.
break
pushed = True
break
if not pushed:
break
cdim = _clamp_sheet(
(
mx + sign * n[0] * _DIM_OFFSET_MM,
my + sign * n[1] * _DIM_OFFSET_MM,
)
)
half = abs(along) / 2.0
u_dir = (d[0], d[1]) if along >= 0 else (-d[0], -d[1])
# Final feet of the (possibly stacked) dimension line.
t1 = (cdim[0] - p1[0]) * d[0] + (cdim[1] - p1[1]) * d[1]
t2 = (cdim[0] - p2[0]) * d[0] + (cdim[1] - p2[1]) * d[1]
e1 = (p1[0] + d[0] * t1, p1[1] + d[1] * t1)
e2 = (p2[0] + d[0] * t2, p2[1] + d[1] * t2)
half = math.hypot(e2[0] - e1[0], e2[1] - e1[1]) / 2.0
# Progressive stacking (same rules, pushing along n).
for _ in range(8):
if half <= 0.5:
break
e1 = (cdim[0] - u_dir[0] * half, cdim[1] - u_dir[1] * half)
e2 = (cdim[0] + u_dir[0] * half, cdim[1] + u_dir[1] * half)
pushed = False
for (ux0, uy0, qx0, qy0, tmin0, tmax0) in placed_dim_lines:
if abs(u_dir[0] * ux0 + u_dir[1] * uy0) < 0.98:
continue # not parallel
ta1 = (e1[0] - qx0) * ux0 + (e1[1] - qy0) * uy0
ta2 = (e2[0] - qx0) * ux0 + (e2[1] - qy0) * uy0
if max(ta1, ta2) < tmin0 or min(ta1, ta2) > tmax0:
continue # spans do not overlap
sep = (cdim[0] - qx0) * n[0] + (cdim[1] - qy0) * n[1]
if abs(sep) < _DIM_STANDOFF_MM:
prev = cdim
cdim = (
cdim[0] + sign * n[0] * (_DIM_STANDOFF_MM - abs(sep)),
cdim[1] + sign * n[1] * (_DIM_STANDOFF_MM - abs(sep)),
)
cdim = _clamp_sheet(cdim)
if cdim == prev:
# Pushed against the sheet edge — stop stacking.
break
pushed = True
break
if not pushed:
break
e1 = (cdim[0] - u_dir[0] * half, cdim[1] - u_dir[1] * half)
e2 = (cdim[0] + u_dir[0] * half, cdim[1] + u_dir[1] * half)
ext_dir = (sign * n[0], sign * n[1])
# ── Shared rendering of the positioned dimension line ─────
if half > 0.5:
u = ((e2[0] - e1[0]) / (2.0 * half), (e2[1] - e1[1]) / (2.0 * half))
s1 = (e1[0] - cdim[0]) * u[0] + (e1[1] - cdim[1]) * u[1]
@@ -1151,10 +1232,10 @@ def _generate_dimension_primitives(
(u[0], u[1], cdim[0], cdim[1], min(s1, s2), max(s1, s2))
)
# Extension lines: anchor → 2mm past the dimension line.
for anchor, t in ((p1, t1), (p2, t2)):
for anchor, foot in ((p1, e1), (p2, e2)):
end = (
anchor[0] + d[0] * (t + sign * _DIM_EXT_OVERSHOOT_MM),
anchor[1] + d[1] * (t + sign * _DIM_EXT_OVERSHOOT_MM),
foot[0] + ext_dir[0] * _DIM_EXT_OVERSHOOT_MM,
foot[1] + ext_dir[1] * _DIM_EXT_OVERSHOOT_MM,
)
prims.append(
DrawingPrimitive(
@@ -1279,6 +1360,108 @@ def _generate_dimension_primitives(
return prims
_MANUAL_DIMENSION_KINDS = ("length", "diameter", "angle")
def build_manual_candidates(
drawing: TechnicalDrawing,
view_transforms: Dict[str, Tuple[float, float, float]],
) -> Tuple[List[DrawingCandidate], List[str], List[str]]:
"""Convert user-placed annotations into renderable dimension candidates.
Manual annotations store their feature geometry in view-plane model
coordinates (see ``DrawingAnnotation.anchors``); here it is re-projected
through the current view transforms so the dimensions re-lay out
correctly whenever views, slots, or the shared scale change. Returns
``(candidates, resolved_ids, unresolved_ids)``.
"""
candidates: List[DrawingCandidate] = []
resolved: List[str] = []
unresolved: List[str] = []
for ann in drawing.annotations:
if not ann.visible or ann.dimension_kind not in _MANUAL_DIMENSION_KINDS:
continue
if len(ann.anchors) < 2:
unresolved.append(ann.id)
continue
transform = view_transforms.get(ann.view_id)
if transform is None:
unresolved.append(ann.id)
continue
scale, offset_x, offset_y = transform
def to_sheet(pt: Tuple[float, float]) -> Tuple[float, float]:
return (pt[0] * scale + offset_x, pt[1] * scale + offset_y)
a0, a1 = ann.anchors[0], ann.anchors[1]
if ann.dimension_kind == "angle":
if len(ann.anchors) < 3:
unresolved.append(ann.id)
continue
vertex, arm1, arm2 = ann.anchors
v1x, v1y = arm1[0] - vertex[0], arm1[1] - vertex[1]
v2x, v2y = arm2[0] - vertex[0], arm2[1] - vertex[1]
m1 = math.hypot(v1x, v1y)
m2 = math.hypot(v2x, v2y)
if m1 < 1e-9 or m2 < 1e-9:
unresolved.append(ann.id)
continue
cos_a = (v1x * v2x + v1y * v2y) / (m1 * m2)
value = math.degrees(math.acos(max(-1.0, min(1.0, cos_a))))
if value < 0.5 or value > 179.5:
unresolved.append(ann.id)
continue
candidates.append(
DrawingCandidate(
key=f"manual:{ann.id}",
view_id=ann.view_id,
kind="angle",
references=(ann.id,),
value=value,
anchor_points=(to_sheet(vertex), to_sheet(arm1), to_sheet(arm2)),
label=f"{value:.{_DISPLAY_PRECISION}f}°",
)
)
resolved.append(ann.id)
continue
value = math.dist(a0, a1)
if value < 1e-6:
unresolved.append(ann.id)
continue
if ann.dimension_kind == "length":
if ann.direction:
dx, dy = float(ann.direction[0]), float(ann.direction[1])
if math.hypot(dx, dy) < 1e-9:
dx, dy = a1[0] - a0[0], a1[1] - a0[1]
else:
dx, dy = a1[0] - a0[0], a1[1] - a0[1]
# value >= 1e-6 guarantees the fallback vector is non-zero.
mag = math.hypot(dx, dy)
direction: Tuple[float, float] = (dx / mag, dy / mag)
label = f"{value:.{_DISPLAY_PRECISION}f}"
else: # diameter
direction = ()
label = f"Ø{value:.{_DISPLAY_PRECISION}f}"
candidates.append(
DrawingCandidate(
key=f"manual:{ann.id}",
view_id=ann.view_id,
kind=ann.dimension_kind,
references=(ann.id,),
value=value,
anchor_points=(to_sheet(a0), to_sheet(a1)),
label=label,
direction=direction,
)
)
resolved.append(ann.id)
return candidates, resolved, unresolved
def generate_drawing(
drawing: TechnicalDrawing,
project: Project,
@@ -1335,6 +1518,9 @@ def generate_drawing(
if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
}
# Per-view model→sheet transforms, filled in by _assemble_view.
transforms: Dict[str, Tuple[float, float, float]] = {}
for view in drawing.views:
vid = _vid_of(view)
slot = view_slots.get(vid)
@@ -1343,14 +1529,25 @@ def generate_drawing(
common_scale * view.scale if vid in ortho_vids and common_scale else None
)
prims, cands, vwarns = _assemble_view(
edges, view_warnings, view, slot, scale_override
edges, view_warnings, view, slot, scale_override, transforms
)
all_primitives.extend(prims)
all_candidates.extend(cands)
warnings.extend(vwarns)
# Auto-place dimensions from candidates (isometric views are never
# dimensioned on real drawings).
# User-placed dimensions: convert the model-space annotations into
# sheet-space candidates so they re-lay out with the current views.
manual_cands, manual_resolved, manual_unresolved = build_manual_candidates(
drawing, transforms
)
manual_ids = {c.references[0] for c in manual_cands if c.references}
resolved_ids: List[str] = list(manual_resolved)
unresolved_ids: List[str] = list(manual_unresolved)
# Place dimensions per view: the auto selection (only while the user
# has auto dimensions enabled) plus the manual dimensions, so the
# stacking/overlap logic treats them alike. Isometric views are never
# dimensioned on real drawings.
for view in drawing.views:
if view.kind == "isometric":
continue
@@ -1361,17 +1558,21 @@ def generate_drawing(
if slot
else (_A3_WIDTH_MM / 2.0, _A3_HEIGHT_MM / 2.0)
)
selected = _select_dimensions_for_placement(all_candidates, vid)
selected: List[DrawingCandidate] = []
if drawing.auto_dimensions:
selected = _select_dimensions_for_placement(all_candidates, vid)
selected.extend(c for c in manual_cands if c.view_id == vid)
all_primitives.extend(_generate_dimension_primitives(selected, center))
# Match annotations to candidates.
resolved_ids: List[str] = []
unresolved_ids: List[str] = []
# Match legacy (reference-based) annotations to candidates. Manual
# dimensions were already rendered above as full dimension primitives.
candidate_by_key: Dict[str, DrawingCandidate] = {c.key: c for c in all_candidates}
for ann in drawing.annotations:
if not ann.visible:
continue
if ann.id in manual_ids:
continue
if ann.kind == "note":
resolved_ids.append(ann.id)
continue
@@ -1405,6 +1606,7 @@ def generate_drawing(
drawing.source_kind, drawing.source_id
),
warnings=tuple(warnings),
view_transforms=transforms,
)