- 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
+15 -12
View File
@@ -4,9 +4,12 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- tech draw draft">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- tech draw draft v2">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/technical_drawing.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/technical_drawing.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/technical_drawing_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/technical_drawing_widget.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
@@ -119,14 +122,6 @@
<option name="presentableId" value="Default" />
<updated>1703867682707</updated>
</task>
<task id="LOCAL-00002" summary="- Basic oop sketch widget implement">
<option name="closed" value="true" />
<created>1729958532384</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1729958532384</updated>
</task>
<task id="LOCAL-00003" summary="- Sketch projection partly works again :)">
<option name="closed" value="true" />
<created>1735563255455</created>
@@ -511,7 +506,15 @@
<option name="project" value="LOCAL" />
<updated>1786888483688</updated>
</task>
<option name="localTasksCounter" value="51" />
<task id="LOCAL-00051" summary="- tech draw draft v2">
<option name="closed" value="true" />
<created>1786910497589</created>
<option name="number" value="00051" />
<option name="presentableId" value="LOCAL-00051" />
<option name="project" value="LOCAL" />
<updated>1786910497589</updated>
</task>
<option name="localTasksCounter" value="52" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -532,7 +535,6 @@
<ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots>
<MESSAGE value="- Drawing bodys depending on the selected compo&#10;- Cut working&#10;- Edit sketch working" />
<MESSAGE value="- delete sketch working&#10;- added mid point snap&#10;- added hovering line with distance" />
<MESSAGE value="- Added new buttons and settings" />
<MESSAGE value="- Added construction lines switching&#10;- Moved callbacks into sketchwidget from main.&#10;- Changed reset on right click" />
@@ -557,6 +559,7 @@
<MESSAGE value="- arc improvements, fillets, operations, bodys" />
<MESSAGE value="- Operation highlighting, body highlighting" />
<MESSAGE value="- tech draw draft" />
<option name="LAST_COMMIT_MESSAGE" value="- tech draw draft" />
<MESSAGE value="- tech draw draft v2" />
<option name="LAST_COMMIT_MESSAGE" value="- tech draw draft v2" />
</component>
</project>
+126
View File
@@ -43,9 +43,12 @@ from fluency.models.data_model import (
Body,
Component,
Connector,
DrawingAnnotation,
DrawingView,
Feature,
Project,
Sketch,
TechnicalDrawing,
Workplane,
)
from fluency.geometry_occ.kernel import OCCGeometryObject, OCGeometryKernel
@@ -600,6 +603,122 @@ def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
return asm
def _drawing_view_to_dict(v: DrawingView) -> Dict[str, Any]:
return {
"id": v.id,
"kind": v.kind,
"name": v.name,
"direction": _coerce_listlike(v.direction),
"up_vector": _coerce_listlike(v.up_vector),
"show_hidden_lines": bool(v.show_hidden_lines),
"show_centerlines": bool(v.show_centerlines),
"scale": _to_float(v.scale, 1.0),
"sheet_origin": _coerce_listlike(v.sheet_origin),
}
def _drawing_view_from_dict(data: Dict[str, Any]) -> DrawingView:
view = DrawingView(
id=_saved_id(data),
kind=data.get("kind", "front"),
name=data.get("name"),
direction=_to_3tuple(data.get("direction")) or None,
up_vector=_to_3tuple(data.get("up_vector")) or None,
show_hidden_lines=bool(data.get("show_hidden_lines", False)),
show_centerlines=bool(data.get("show_centerlines", False)),
scale=_to_float(data.get("scale"), 1.0),
)
origin = data.get("sheet_origin")
if isinstance(origin, (list, tuple)) and len(origin) >= 2:
view.sheet_origin = (_to_float(origin[0]), _to_float(origin[1]))
return view
def _drawing_annotation_to_dict(a: DrawingAnnotation) -> Dict[str, Any]:
return {
"id": a.id,
"kind": a.kind,
"text": a.text,
"visible": bool(a.visible),
"references": list(a.references),
"sheet_position": _coerce_listlike(a.sheet_position),
"view_id": a.view_id,
"anchors": [list(pt) for pt in a.anchors],
"dimension_kind": a.dimension_kind,
"direction": _coerce_listlike(a.direction),
}
def _drawing_annotation_from_dict(data: Dict[str, Any]) -> DrawingAnnotation:
ann = DrawingAnnotation(
id=_saved_id(data),
kind=data.get("kind", "dimension"),
text=data.get("text"),
visible=bool(data.get("visible", True)),
references=list(data.get("references") or []),
view_id=data.get("view_id", ""),
dimension_kind=data.get("dimension_kind", ""),
)
pos = data.get("sheet_position")
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
ann.sheet_position = (_to_float(pos[0]), _to_float(pos[1]))
anchors = data.get("anchors") or []
for pt in anchors:
if isinstance(pt, (list, tuple)) and len(pt) >= 2:
ann.anchors.append((_to_float(pt[0]), _to_float(pt[1])))
direction = data.get("direction")
if isinstance(direction, (list, tuple)) and len(direction) >= 2:
ann.direction = (_to_float(direction[0]), _to_float(direction[1]))
return ann
def _technical_drawing_to_dict(d: TechnicalDrawing) -> Dict[str, Any]:
return {
"id": d.id,
"name": d.name,
"source_kind": d.source_kind,
"source_id": d.source_id,
"views": [_drawing_view_to_dict(v) for v in d.views],
"annotations": [_drawing_annotation_to_dict(a) for a in d.annotations],
"title": d.title,
"part_number": d.part_number,
"material": d.material,
"revision": d.revision,
"notes": d.notes,
"sheet_size": d.sheet_size,
"units": d.units,
"auto_dimensions": bool(d.auto_dimensions),
"auto_views": bool(d.auto_views),
"created_at": d.created_at.isoformat() if d.created_at else None,
"modified_at": d.modified_at.isoformat() if d.modified_at else None,
}
def _technical_drawing_from_dict(data: Dict[str, Any]) -> TechnicalDrawing:
drawing = TechnicalDrawing(
id=_saved_id(data),
name=data.get("name", "Untitled Drawing"),
source_kind=data.get("source_kind", "component"),
source_id=data.get("source_id", ""),
title=data.get("title", ""),
part_number=data.get("part_number", ""),
material=data.get("material", ""),
revision=data.get("revision", ""),
notes=data.get("notes", ""),
sheet_size=data.get("sheet_size", "A3"),
units=data.get("units", "mm"),
auto_dimensions=bool(data.get("auto_dimensions", False)),
auto_views=bool(data.get("auto_views", True)),
)
drawing.created_at = _parse_iso(data.get("created_at"))
drawing.modified_at = _parse_iso(data.get("modified_at"))
for v_data in data.get("views") or []:
drawing.views.append(_drawing_view_from_dict(v_data))
for a_data in data.get("annotations") or []:
drawing.annotations.append(_drawing_annotation_from_dict(a_data))
return drawing
def _project_to_dict(
project: Project,
view_state: Optional[Dict[str, Any]] = None,
@@ -612,6 +731,7 @@ def _project_to_dict(
"active_assembly": project.active_assembly,
"components": {cid: _component_to_dict(c) for cid, c in project.components.items()},
"assemblies": {aid: _assembly_to_dict(a) for aid, a in project.assemblies.items()},
"drawings": [_technical_drawing_to_dict(d) for d in project.drawings],
"created_at": project.created_at.isoformat() if project.created_at else None,
"modified_at": project.modified_at.isoformat() if project.modified_at else None,
"view_state": view_state or {},
@@ -886,6 +1006,12 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
for aid, a_data in (manifest.get("assemblies") or {}).items():
project.assemblies[aid] = _assembly_from_dict(a_data)
for d_data in manifest.get("drawings") or []:
try:
project.drawings.append(_technical_drawing_from_dict(d_data))
except Exception as exc:
logger.warning("Skipping corrupt drawing in archive: %s", exc)
# After all components are loaded, re-wire connector partner ids so
# they point to the freshly-loaded AssemblyComponents. (The dict
# round-trip preserves the raw strings; we just make sure the partner
+39 -2
View File
@@ -724,6 +724,10 @@ class Project:
assemblies: Dict[str, Assembly] = field(default_factory=dict)
active_assembly: Optional[str] = None
# Technical drawings keyed to their source component/assembly.
# Manual dimensions and view options added in the drawing workbench
# are persisted here so they survive save/load.
drawings: List["TechnicalDrawing"] = field(default_factory=list)
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
created_at: datetime = field(default_factory=datetime.now)
@@ -800,6 +804,21 @@ class Project:
"""Look up a component by id across all project components."""
return self.components.get(component_id)
# ── Drawing helpers ──
def get_drawing_for(self, source_kind: str, source_id: str) -> Optional["TechnicalDrawing"]:
"""Return the drawing bound to *(source_kind, source_id)*, if any."""
for drawing in self.drawings:
if drawing.source_kind == source_kind and drawing.source_id == source_id:
return drawing
return None
def add_drawing(self, drawing: "TechnicalDrawing") -> "TechnicalDrawing":
"""Register *drawing* with the project and return it."""
self.drawings.append(drawing)
self.modified_at = datetime.now()
return drawing
def export_step(self, filepath: str) -> bool:
"""Export all visible bodies to STEP."""
@@ -917,6 +936,23 @@ class DrawingAnnotation:
# Associated view id (empty means global/note block).
view_id: str = ""
# Manual-dimension geometry in view-plane model coordinates (model
# units in the view's projection plane). Populated for user-placed
# dimensions, empty for reference-based annotations:
# "length": (point_on_edge1, point_on_edge2) — closest points
# "diameter": (left, right) — antipodal points across the centre
# "angle": (vertex, arm1_point, arm2_point)
anchors: List[Tuple[float, float]] = field(default_factory=list)
# Sub-kind of the manual dimension: "length" | "diameter" | "angle".
# Empty for non-dimension annotations.
dimension_kind: str = ""
# Unit vector along the measured distance in view-plane coordinates
# (length dimensions only). The model→sheet transform is a uniform
# scale + translation, so the direction is valid in sheet space too.
direction: Optional[Tuple[float, float]] = None
@dataclass
class TechnicalDrawing:
@@ -943,8 +979,9 @@ class TechnicalDrawing:
sheet_size: str = "A3"
units: str = "mm" # mm, in
# Auto-generation flags for future use.
auto_dimensions: bool = True
# Auto-generation flags. Auto dimensions are opt-in: the drawing
# workbench shows them only while the user has the toggle enabled.
auto_dimensions: bool = False
auto_views: bool = True
created_at: datetime = field(default_factory=datetime.now)
+222 -20
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 modelsheet 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,13 +1101,21 @@ 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
# 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
# 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
@@ -1143,6 +1168,62 @@ def _generate_dimension_primitives(
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]
@@ -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: 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,
)
+15 -58
View File
@@ -1917,7 +1917,9 @@ class MainWindow(QMainWindow):
self._drawing_tab = TechnicalDrawingWidget()
self._ui.InputTab.addTab(self._drawing_tab, "Drawing")
self._drawing_tab.set_project(self._project, self._kernel)
self._refresh_drawing_source_catalog()
# Dimension tools and view toggles in the workbench mutate the
# project's drawings — treat that as an unsaved change.
self._drawing_tab.drawing_changed.connect(self._mark_dirty)
# Component buttons (dynamically generated per component, not in UI).
# Wrapped in a QScrollArea so many components can scroll horizontally.
@@ -2522,7 +2524,6 @@ class MainWindow(QMainWindow):
logger.info(f"Created component: {comp.name}")
# No sketch in the fresh component — drop the world triad.
self._sync_sketch_gizmo()
self._refresh_drawing_source_catalog()
def _delete_component(self):
idx = self._get_active_component_index()
@@ -2541,7 +2542,6 @@ class MainWindow(QMainWindow):
self._refresh_lists()
logger.info("Deleted component")
self._refresh_drawing_source_catalog()
def _on_component_button_clicked(self):
idx = self._get_active_component_index()
@@ -2551,7 +2551,6 @@ class MainWindow(QMainWindow):
self._assembly_view_active = False
self._refresh_lists()
self._redraw_bodies()
self._refresh_drawing_source_catalog()
# Propagate the new selection to the drawing tab.
if self._current_component:
try:
@@ -2587,14 +2586,6 @@ class MainWindow(QMainWindow):
self._body_list.addItem(item)
self._refresh_operations_list()
def _refresh_drawing_source_catalog(self) -> None:
"""Update the drawing tab's list of available components/assemblies."""
component_ids = list(self._project.components.keys())
assembly_ids = list(self._project.assemblies.keys()) if self._project.assemblies else []
try:
self._drawing_tab.set_source_catalog(component_ids, assembly_ids)
except Exception as e:
logger.warning(f"Failed to refresh drawing source catalog: {e}")
# ── Body operations list (per-body feature history) ───────────────────
@@ -7331,10 +7322,9 @@ class MainWindow(QMainWindow):
self._create_initial_component()
# Re-point the drawing tab at the new project — its combo and
# project reference still belong to the previous project.
# Re-point the drawing tab at the new project — its project
# reference still belongs to the previous project.
self._drawing_tab.set_project(self._project, self._kernel)
self._refresh_drawing_source_catalog()
if self._current_component:
self._drawing_tab.set_active_component(self._current_component)
finally:
@@ -7684,7 +7674,6 @@ class MainWindow(QMainWindow):
# its source selection — it still holds the references captured
# at setup, which made Generate draw from the previous project.
self._drawing_tab.set_project(self._project, self._kernel)
self._refresh_drawing_source_catalog()
if self._current_component:
self._drawing_tab.set_active_component(self._current_component)
finally:
@@ -7902,52 +7891,20 @@ class MainWindow(QMainWindow):
self._render_tab.set_assembly(assembly_parts, camera=viewport_camera)
def _load_drawing_tab_source(self) -> None:
"""Auto-populate the drawing tab with the current component or assembly."""
from fluency.models.data_model import TechnicalDrawing, DrawingView
from fluency.technical_drawing import generate_drawing, _STANDARD_VIEWS
source_kind = "component"
source_id = ""
"""Point the drawing tab at the current component (or first assembly).
The drawing itself is owned by the project per source, so the
workbench re-adopts the stored drawing preserving its views,
manually placed dimensions, and the auto-dimensions toggle.
"""
# Prefer _current_component (set by component button clicks).
comp = self._current_component or self._project.get_active_component()
if comp and any(b.visible and b.geometry for b in comp.bodies.values()):
source_kind = "component"
source_id = comp.id
elif self._project.assemblies:
asm_id = next(iter(self._project.assemblies.keys()))
source_kind = "assembly"
source_id = asm_id
if not source_id:
return
# Build default drawing with front, top, right, isometric views.
views = []
for kind in ("front", "top", "right", "isometric"):
dir_, up_ = _STANDARD_VIEWS[kind]
vname = kind.capitalize() if kind != "isometric" else "Isometric"
views.append(DrawingView(kind=kind, name=vname, direction=dir_, up_vector=up_))
drawing = TechnicalDrawing(
name=f"Drawing of {comp.name if comp else 'Assembly'}",
source_kind=source_kind,
source_id=source_id,
views=views,
title=comp.name if comp else "Assembly Drawing",
part_number="",
material="",
revision="A",
)
self._drawing_tab.set_drawing(drawing)
# Auto-generate.
try:
result = generate_drawing(drawing, self._project, self._kernel)
self._drawing_tab.set_render_result(result)
except Exception as e:
logger.warning(f"Failed to auto-generate drawing: {e}")
self._drawing_tab.set_active_component(comp)
else:
# No drawable component — let the widget fall back to the
# project's assemblies (or show "no source").
self._drawing_tab.generate()
def _on_tab_changed(self, index: int) -> None:
"""When the user switches to Render or Drawing tab, auto-load selected geometry."""
widget = self._ui.InputTab.widget(index)
File diff suppressed because it is too large Load Diff