- tech draw draft v2
This commit is contained in:
Generated
+15
-12
@@ -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 - Cut working - Edit sketch working" />
|
||||
<MESSAGE value="- delete sketch working - added mid point snap - added hovering line with distance" />
|
||||
<MESSAGE value="- Added new buttons and settings" />
|
||||
<MESSAGE value="- Added construction lines switching - Moved callbacks into sketchwidget from main. - 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user