- 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" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <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/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/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" /> <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> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
@@ -119,14 +122,6 @@
<option name="presentableId" value="Default" /> <option name="presentableId" value="Default" />
<updated>1703867682707</updated> <updated>1703867682707</updated>
</task> </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 :)"> <task id="LOCAL-00003" summary="- Sketch projection partly works again :)">
<option name="closed" value="true" /> <option name="closed" value="true" />
<created>1735563255455</created> <created>1735563255455</created>
@@ -511,7 +506,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1786888483688</updated> <updated>1786888483688</updated>
</task> </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 /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -532,7 +535,6 @@
<ignored-roots> <ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" /> <path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots> </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="- delete sketch working&#10;- added mid point snap&#10;- added hovering line with distance" />
<MESSAGE value="- Added new buttons and settings" /> <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" /> <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="- arc improvements, fillets, operations, bodys" />
<MESSAGE value="- Operation highlighting, body highlighting" /> <MESSAGE value="- Operation highlighting, body highlighting" />
<MESSAGE value="- tech draw draft" /> <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> </component>
</project> </project>
+126
View File
@@ -43,9 +43,12 @@ from fluency.models.data_model import (
Body, Body,
Component, Component,
Connector, Connector,
DrawingAnnotation,
DrawingView,
Feature, Feature,
Project, Project,
Sketch, Sketch,
TechnicalDrawing,
Workplane, Workplane,
) )
from fluency.geometry_occ.kernel import OCCGeometryObject, OCGeometryKernel from fluency.geometry_occ.kernel import OCCGeometryObject, OCGeometryKernel
@@ -600,6 +603,122 @@ def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
return asm 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( def _project_to_dict(
project: Project, project: Project,
view_state: Optional[Dict[str, Any]] = None, view_state: Optional[Dict[str, Any]] = None,
@@ -612,6 +731,7 @@ def _project_to_dict(
"active_assembly": project.active_assembly, "active_assembly": project.active_assembly,
"components": {cid: _component_to_dict(c) for cid, c in project.components.items()}, "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()}, "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, "created_at": project.created_at.isoformat() if project.created_at else None,
"modified_at": project.modified_at.isoformat() if project.modified_at else None, "modified_at": project.modified_at.isoformat() if project.modified_at else None,
"view_state": view_state or {}, "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(): for aid, a_data in (manifest.get("assemblies") or {}).items():
project.assemblies[aid] = _assembly_from_dict(a_data) 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 # After all components are loaded, re-wire connector partner ids so
# they point to the freshly-loaded AssemblyComponents. (The dict # they point to the freshly-loaded AssemblyComponents. (The dict
# round-trip preserves the raw strings; we just make sure the partner # 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) assemblies: Dict[str, Assembly] = field(default_factory=dict)
active_assembly: Optional[str] = None 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) kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
created_at: datetime = field(default_factory=datetime.now) created_at: datetime = field(default_factory=datetime.now)
@@ -800,6 +804,21 @@ class Project:
"""Look up a component by id across all project components.""" """Look up a component by id across all project components."""
return self.components.get(component_id) 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: def export_step(self, filepath: str) -> bool:
"""Export all visible bodies to STEP.""" """Export all visible bodies to STEP."""
@@ -917,6 +936,23 @@ class DrawingAnnotation:
# Associated view id (empty means global/note block). # Associated view id (empty means global/note block).
view_id: str = "" 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 @dataclass
class TechnicalDrawing: class TechnicalDrawing:
@@ -943,8 +979,9 @@ class TechnicalDrawing:
sheet_size: str = "A3" sheet_size: str = "A3"
units: str = "mm" # mm, in units: str = "mm" # mm, in
# Auto-generation flags for future use. # Auto-generation flags. Auto dimensions are opt-in: the drawing
auto_dimensions: bool = True # workbench shows them only while the user has the toggle enabled.
auto_dimensions: bool = False
auto_views: bool = True auto_views: bool = True
created_at: datetime = field(default_factory=datetime.now) created_at: datetime = field(default_factory=datetime.now)
+268 -66
View File
@@ -14,7 +14,7 @@ Exact public API:
from __future__ import annotations from __future__ import annotations
import math import math
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Tuple from typing import Any, Dict, List, Optional, Sequence, Tuple
import numpy as np import numpy as np
@@ -68,6 +68,9 @@ class DrawingPrimitive:
center: Optional[Tuple[float, float]] = None center: Optional[Tuple[float, float]] = None
radius: Optional[float] = None radius: Optional[float] = None
dash_pattern: Tuple[float, ...] = () 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) @dataclass(frozen=True)
@@ -80,6 +83,9 @@ class DrawingRenderResult:
unresolved_annotation_ids: Tuple[str, ...] unresolved_annotation_ids: Tuple[str, ...]
source_fingerprint: str source_fingerprint: str
warnings: Tuple[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 ─────────────────────────────────────────────────────────── # ── View presets ───────────────────────────────────────────────────────────
@@ -319,6 +325,7 @@ def _assemble_view(
view: DrawingView, view: DrawingView,
slot: Optional[Tuple[float, float, float, float]] = None, slot: Optional[Tuple[float, float, float, float]] = None,
scale_override: Optional[float] = None, scale_override: Optional[float] = None,
transforms: Optional[Dict[str, Tuple[float, float, float]]] = None,
) -> Tuple[Tuple[DrawingPrimitive, ...], Tuple[DrawingCandidate, ...], Tuple[str, ...]]: ) -> Tuple[Tuple[DrawingPrimitive, ...], Tuple[DrawingCandidate, ...], Tuple[str, ...]]:
"""Fit projected edges into *slot* and emit primitives + candidates. """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 bottom-left, +y up). When omitted the projection is fitted to the
whole sheet. *scale_override* forces a specific modelsheet scale whole sheet. *scale_override* forces a specific modelsheet scale
(used to keep all orthographic views at one shared 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) warnings = list(warnings)
primitives: List[DrawingPrimitive] = [] primitives: List[DrawingPrimitive] = []
@@ -366,6 +375,11 @@ def _assemble_view(
offset_x = left + (avail_w - geom_w * scale) / 2.0 - min_x * 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 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]: def _to_sheet(x: float, y: float) -> Tuple[float, float]:
return (x * scale + offset_x, y * scale + offset_y) return (x * scale + offset_x, y * scale + offset_y)
@@ -383,6 +397,7 @@ def _assemble_view(
style=style, style=style,
center=_to_sheet(cx, cy), center=_to_sheet(cx, cy),
radius=radius * scale, radius=radius * scale,
view_id=view_id,
) )
) )
else: else:
@@ -391,12 +406,11 @@ def _assemble_view(
kind="line", kind="line",
points=(_to_sheet(*p1), _to_sheet(*p2)), points=(_to_sheet(*p1), _to_sheet(*p2)),
style=style, style=style,
view_id=view_id,
) )
) )
# ── Dimension candidate extraction ────────────────────────────── # ── 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 # 1. Overall extents (bounding-box width/height), anchored at real
# bbox corners so extension lines can start at feature extremes. # 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 Linear/extent candidates become extension lines + an offset dimension
line with arrowheads + centred text (dimension line broken for the line with arrowheads + centred text (dimension line broken for the
label). Diameters become a 45° leader with a horizontal tail. Angles label). Anchors on one feature line yield a dimension line
become a small vertex arc + text. 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). # 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_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) 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 mx, my = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0
# Half the anchor spread along the measurement direction: the # Decompose the anchor pair: separation along the measurement
# dimension line lands this far (plus the offset) beyond the # direction d and perpendicular to it. cross ≈ 0 means the
# far feature line. # anchors are the closest points on two parallel edges (a
spread = abs((p2[0] - p1[0]) * d[0] + (p2[1] - p1[1]) * d[1]) / 2.0 # distance dimension); otherwise they sit on one feature line.
offset = spread + _DIM_OFFSET_MM wdx, wdy = p2[0] - p1[0], p2[1] - p1[1]
# Place the dimension line on the side of the anchor pair away along = wdx * d[0] + wdy * d[1]
# from the view centre (outside the feature). cross = abs(wdx * d[1] - wdy * d[0])
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 if cross > 0.5:
# already-placed parallel lines whose foot spans overlap, so # ── Feature-line case ─────────────────────────────────
# parallel dimensions stay readable (standard CAD behaviour). # The dimension line is perpendicular to d, offset away
for _ in range(8): # 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] 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] t2 = (cdim[0] - p2[0]) * d[0] + (cdim[1] - p2[1]) * d[1]
e1 = (p1[0] + d[0] * t1, p1[1] + d[1] * t1) e1 = (p1[0] + d[0] * t1, p1[1] + d[1] * t1)
e2 = (p2[0] + d[0] * t2, p2[1] + d[1] * t2) e2 = (p2[0] + d[0] * t2, p2[1] + d[1] * t2)
half = math.hypot(e2[0] - e1[0], e2[1] - e1[1]) / 2.0 half = math.hypot(e2[0] - e1[0], e2[1] - e1[1]) / 2.0
if half <= 0.5: ext_dir = (sign * d[0], sign * d[1])
break else:
u = ( # ── Inter-edge distance case ──────────────────────────
(e2[0] - e1[0]) / (2.0 * half), # The measured distance runs along d; the dimension line
(e2[1] - e1[1]) / (2.0 * half), # 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 cdim = _clamp_sheet(
for (ux0, uy0, qx0, qy0, tmin0, tmax0) in placed_dim_lines: (
if abs(u[0] * ux0 + u[1] * uy0) < 0.98: mx + sign * n[0] * _DIM_OFFSET_MM,
continue # not parallel my + sign * n[1] * _DIM_OFFSET_MM,
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: half = abs(along) / 2.0
continue # spans do not overlap u_dir = (d[0], d[1]) if along >= 0 else (-d[0], -d[1])
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. # Progressive stacking (same rules, pushing along n).
t1 = (cdim[0] - p1[0]) * d[0] + (cdim[1] - p1[1]) * d[1] for _ in range(8):
t2 = (cdim[0] - p2[0]) * d[0] + (cdim[1] - p2[1]) * d[1] if half <= 0.5:
e1 = (p1[0] + d[0] * t1, p1[1] + d[1] * t1) break
e2 = (p2[0] + d[0] * t2, p2[1] + d[1] * t2) e1 = (cdim[0] - u_dir[0] * half, cdim[1] - u_dir[1] * half)
half = math.hypot(e2[0] - e1[0], e2[1] - e1[1]) / 2.0 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: if half > 0.5:
u = ((e2[0] - e1[0]) / (2.0 * half), (e2[1] - e1[1]) / (2.0 * half)) 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] 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)) (u[0], u[1], cdim[0], cdim[1], min(s1, s2), max(s1, s2))
) )
# Extension lines: anchor → 2mm past the dimension line. # 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 = ( end = (
anchor[0] + d[0] * (t + sign * _DIM_EXT_OVERSHOOT_MM), foot[0] + ext_dir[0] * _DIM_EXT_OVERSHOOT_MM,
anchor[1] + d[1] * (t + sign * _DIM_EXT_OVERSHOOT_MM), foot[1] + ext_dir[1] * _DIM_EXT_OVERSHOOT_MM,
) )
prims.append( prims.append(
DrawingPrimitive( DrawingPrimitive(
@@ -1279,6 +1360,108 @@ def _generate_dimension_primitives(
return prims 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( def generate_drawing(
drawing: TechnicalDrawing, drawing: TechnicalDrawing,
project: Project, project: Project,
@@ -1335,6 +1518,9 @@ def generate_drawing(
if v.kind in _STANDARD_VIEWS and v.kind != "isometric" 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: for view in drawing.views:
vid = _vid_of(view) vid = _vid_of(view)
slot = view_slots.get(vid) 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 common_scale * view.scale if vid in ortho_vids and common_scale else None
) )
prims, cands, vwarns = _assemble_view( 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_primitives.extend(prims)
all_candidates.extend(cands) all_candidates.extend(cands)
warnings.extend(vwarns) warnings.extend(vwarns)
# Auto-place dimensions from candidates (isometric views are never # User-placed dimensions: convert the model-space annotations into
# dimensioned on real drawings). # 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: for view in drawing.views:
if view.kind == "isometric": if view.kind == "isometric":
continue continue
@@ -1361,17 +1558,21 @@ def generate_drawing(
if slot if slot
else (_A3_WIDTH_MM / 2.0, _A3_HEIGHT_MM / 2.0) 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)) all_primitives.extend(_generate_dimension_primitives(selected, center))
# Match annotations to candidates. # Match legacy (reference-based) annotations to candidates. Manual
resolved_ids: List[str] = [] # dimensions were already rendered above as full dimension primitives.
unresolved_ids: List[str] = []
candidate_by_key: Dict[str, DrawingCandidate] = {c.key: c for c in all_candidates} candidate_by_key: Dict[str, DrawingCandidate] = {c.key: c for c in all_candidates}
for ann in drawing.annotations: for ann in drawing.annotations:
if not ann.visible: if not ann.visible:
continue continue
if ann.id in manual_ids:
continue
if ann.kind == "note": if ann.kind == "note":
resolved_ids.append(ann.id) resolved_ids.append(ann.id)
continue continue
@@ -1405,6 +1606,7 @@ def generate_drawing(
drawing.source_kind, drawing.source_id drawing.source_kind, drawing.source_id
), ),
warnings=tuple(warnings), warnings=tuple(warnings),
view_transforms=transforms,
) )
+15 -58
View File
@@ -1917,7 +1917,9 @@ class MainWindow(QMainWindow):
self._drawing_tab = TechnicalDrawingWidget() self._drawing_tab = TechnicalDrawingWidget()
self._ui.InputTab.addTab(self._drawing_tab, "Drawing") self._ui.InputTab.addTab(self._drawing_tab, "Drawing")
self._drawing_tab.set_project(self._project, self._kernel) 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). # Component buttons (dynamically generated per component, not in UI).
# Wrapped in a QScrollArea so many components can scroll horizontally. # Wrapped in a QScrollArea so many components can scroll horizontally.
@@ -2522,7 +2524,6 @@ class MainWindow(QMainWindow):
logger.info(f"Created component: {comp.name}") logger.info(f"Created component: {comp.name}")
# No sketch in the fresh component — drop the world triad. # No sketch in the fresh component — drop the world triad.
self._sync_sketch_gizmo() self._sync_sketch_gizmo()
self._refresh_drawing_source_catalog()
def _delete_component(self): def _delete_component(self):
idx = self._get_active_component_index() idx = self._get_active_component_index()
@@ -2541,7 +2542,6 @@ class MainWindow(QMainWindow):
self._refresh_lists() self._refresh_lists()
logger.info("Deleted component") logger.info("Deleted component")
self._refresh_drawing_source_catalog()
def _on_component_button_clicked(self): def _on_component_button_clicked(self):
idx = self._get_active_component_index() idx = self._get_active_component_index()
@@ -2551,7 +2551,6 @@ class MainWindow(QMainWindow):
self._assembly_view_active = False self._assembly_view_active = False
self._refresh_lists() self._refresh_lists()
self._redraw_bodies() self._redraw_bodies()
self._refresh_drawing_source_catalog()
# Propagate the new selection to the drawing tab. # Propagate the new selection to the drawing tab.
if self._current_component: if self._current_component:
try: try:
@@ -2587,14 +2586,6 @@ class MainWindow(QMainWindow):
self._body_list.addItem(item) self._body_list.addItem(item)
self._refresh_operations_list() 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) ─────────────────── # ── Body operations list (per-body feature history) ───────────────────
@@ -7331,10 +7322,9 @@ class MainWindow(QMainWindow):
self._create_initial_component() self._create_initial_component()
# Re-point the drawing tab at the new project — its combo and # Re-point the drawing tab at the new project — its project
# project reference still belong to the previous project. # reference still belongs to the previous project.
self._drawing_tab.set_project(self._project, self._kernel) self._drawing_tab.set_project(self._project, self._kernel)
self._refresh_drawing_source_catalog()
if self._current_component: if self._current_component:
self._drawing_tab.set_active_component(self._current_component) self._drawing_tab.set_active_component(self._current_component)
finally: finally:
@@ -7684,7 +7674,6 @@ class MainWindow(QMainWindow):
# its source selection — it still holds the references captured # its source selection — it still holds the references captured
# at setup, which made Generate draw from the previous project. # at setup, which made Generate draw from the previous project.
self._drawing_tab.set_project(self._project, self._kernel) self._drawing_tab.set_project(self._project, self._kernel)
self._refresh_drawing_source_catalog()
if self._current_component: if self._current_component:
self._drawing_tab.set_active_component(self._current_component) self._drawing_tab.set_active_component(self._current_component)
finally: finally:
@@ -7902,52 +7891,20 @@ class MainWindow(QMainWindow):
self._render_tab.set_assembly(assembly_parts, camera=viewport_camera) self._render_tab.set_assembly(assembly_parts, camera=viewport_camera)
def _load_drawing_tab_source(self) -> None: def _load_drawing_tab_source(self) -> None:
"""Auto-populate the drawing tab with the current component or assembly.""" """Point the drawing tab at the current component (or first assembly).
from fluency.models.data_model import TechnicalDrawing, DrawingView
from fluency.technical_drawing import generate_drawing, _STANDARD_VIEWS
source_kind = "component"
source_id = ""
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). # Prefer _current_component (set by component button clicks).
comp = self._current_component or self._project.get_active_component() 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()): if comp and any(b.visible and b.geometry for b in comp.bodies.values()):
source_kind = "component" self._drawing_tab.set_active_component(comp)
source_id = comp.id else:
elif self._project.assemblies: # No drawable component — let the widget fall back to the
asm_id = next(iter(self._project.assemblies.keys())) # project's assemblies (or show "no source").
source_kind = "assembly" self._drawing_tab.generate()
source_id = asm_id
if not source_id:
return
# Build default drawing with front, top, right, isometric views.
views = []
for kind in ("front", "top", "right", "isometric"):
dir_, up_ = _STANDARD_VIEWS[kind]
vname = kind.capitalize() if kind != "isometric" else "Isometric"
views.append(DrawingView(kind=kind, name=vname, direction=dir_, up_vector=up_))
drawing = TechnicalDrawing(
name=f"Drawing of {comp.name if comp else 'Assembly'}",
source_kind=source_kind,
source_id=source_id,
views=views,
title=comp.name if comp else "Assembly Drawing",
part_number="",
material="",
revision="A",
)
self._drawing_tab.set_drawing(drawing)
# Auto-generate.
try:
result = generate_drawing(drawing, self._project, self._kernel)
self._drawing_tab.set_render_result(result)
except Exception as e:
logger.warning(f"Failed to auto-generate drawing: {e}")
def _on_tab_changed(self, index: int) -> None: def _on_tab_changed(self, index: int) -> None:
"""When the user switches to Render or Drawing tab, auto-load selected geometry.""" """When the user switches to Render or Drawing tab, auto-load selected geometry."""
widget = self._ui.InputTab.widget(index) widget = self._ui.InputTab.widget(index)
File diff suppressed because it is too large Load Diff