12 Commits

Author SHA1 Message Date
bklronin 9abeb6266a - Assembly instaiated operations
- assembly forward proagation
2026-08-19 20:14:14 +02:00
bklronin 6b6f7de5ab Fiexed highlighting of operations 2026-08-19 00:11:24 +02:00
bklronin b184ade967 - added iso sheet for tech draw 2026-08-18 16:12:09 +02:00
bklronin 67b73c13b8 - tech drawing and render improv 2026-08-18 15:06:51 +02:00
bklronin 813ddc3596 - tech draw draft v2 2026-08-17 21:39:20 +02:00
bklronin 37e5335446 - tech draw draft v2 2026-08-17 18:35:18 +02:00
bklronin 108ad2d5b5 - tech draw draft v2 2026-08-16 22:01:36 +02:00
bklronin 1eee203ba8 - tech draw draft 2026-08-16 15:54:41 +02:00
bklronin a00129603c - Operation highlighting, body highlighting 2026-08-08 13:49:59 +02:00
bklronin 3d63f033f2 - Operation highlighting, body highlighting 2026-08-08 13:46:12 +02:00
bklronin b29bc11b42 - Operation highlighting, body highlighting 2026-08-08 11:15:49 +02:00
bklronin 79ad85594e - arc improvements, fillets, operations, bodys 2026-08-07 19:55:15 +02:00
27 changed files with 10189 additions and 673 deletions
+39 -1
View File
@@ -35,4 +35,42 @@ uv.lock
# IDE
.vscode/
*.swp
*.swo
*.swo
/src/fluency/Tesfiles/bordo_adapter3.fluency
/CONSTRAINT_STATUS_FINAL.md
/CONSTRAINT_STATUS_IMPLEMENTATION.md
/src/fluency/rendering/first.png
/littlebrother.md
/src/fluency/Tesfiles/multiboidy.fluency
/src/fluency/rendering/nromal_test.png
/package.json
/package-lock.json
/src/fluency/Screenshot 2026-06-28 at 17.57.52.png
/src/fluency/rendering/Screenshot 2026-07-12 at 16.54.14.png
/src/fluency/Screenshot 2026-07-26 at 20.23.42.png
/Screenshot 2026-08-05 at 10.14.15.png
/Screenshot 2026-08-05 at 11.15.33.png
/Screenshot 2026-08-05 at 11.22.42.png
/SURFACE_MODIFIER_PLAN.md
/test.step
/src/fluency/test333.step
/src/fluency/tests/test_arc_attached_to_rectangle.py
/src/fluency/tests/test_array_pattern.py
/src/fluency/tests/test_chamfer.py
/src/fluency/tests/test_circle_diameter_constraint.py
/tests/test_distance_constraint_picking.py
/tests/test_extrude_geometry.py
/src/fluency/tests/test_feature_replay.py
/src/fluency/tests/test_fillet.py
/tests/test_mirror.py
/test_modifier.py
/src/fluency/tests/test_projection_constraints.py
/tests/test_re_extrude.py
/scripts/test_render_zoom.py
/test_thread.py
/test_thread_hole.step
/test_thread_m3.step
/test_thread_m5_hole.step
/test_thread_shaft.step
/test_thread_tilted.step
/src/fluency/testpart.step
+128 -101
View File
@@ -4,15 +4,17 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- arc improvements, fillets, operations, bodys">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="Fiexed highlighting of operations">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/gui_ui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" 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/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.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/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_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" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -49,47 +51,48 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"Python.2dtest.executor": "Run",
"Python.3d_windows.executor": "Run",
"Python.Unnamed.executor": "Run",
"Python.base.executor": "Run",
"Python.data_model.executor": "Run",
"Python.debug_dragging.executor": "Run",
"Python.draw_widget2d.executor": "Run",
"Python.draw_widget_solve.executor": "Run",
"Python.fluency.executor": "Run",
"Python.fluencyb.executor": "Run",
"Python.gl_widget.executor": "Run",
"Python.gui_ui.executor": "Run",
"Python.kernel.executor": "Run",
"Python.main.executor": "Run",
"Python.main_window.executor": "Run",
"Python.meshtest.executor": "Run",
"Python.occ_renderer.executor": "Run",
"Python.occ_to_mesh.executor": "Run",
"Python.render_backend.executor": "Run",
"Python.side_fluency.executor": "Run",
"Python.simple_mesh.executor": "Run",
"Python.sketch.executor": "Run",
"Python.vtk_widget.executor": "Run",
"Python.vulkan.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"codeWithMe.voiceChat.enabledByDefault": "false",
"git-widget-placeholder": "feature/occ-migration",
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency/Tesfiles",
"node.js.detected.package.eslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
&quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
&quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
&quot;Python.base.executor&quot;: &quot;Run&quot;,
&quot;Python.data_model.executor&quot;: &quot;Run&quot;,
&quot;Python.debug_dragging.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
&quot;Python.fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
&quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.gui_ui.executor&quot;: &quot;Run&quot;,
&quot;Python.kernel.executor&quot;: &quot;Run&quot;,
&quot;Python.main.executor&quot;: &quot;Run&quot;,
&quot;Python.main_window.executor&quot;: &quot;Run&quot;,
&quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.render_backend.executor&quot;: &quot;Run&quot;,
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.sketch.executor&quot;: &quot;Run&quot;,
&quot;Python.technical_drawing_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
}
}]]></component>
}</component>
<component name="QodanaReportsService">
<option name="descriptions">
<ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" />
@@ -97,9 +100,9 @@
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency/Tesfiles" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/src/fluency/Tesfiles" />
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/drawing_modules" />
</key>
@@ -123,54 +126,6 @@
<option name="presentableId" value="Default" />
<updated>1703867682707</updated>
</task>
<task id="LOCAL-00001" summary="init">
<option name="closed" value="true" />
<created>1703951701948</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1703951701948</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>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1735563255455</updated>
</task>
<task id="LOCAL-00004" summary="- Sketch projection partly works again :)">
<option name="closed" value="true" />
<created>1735585968733</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1735585968733</updated>
</task>
<task id="LOCAL-00005" summary="- Added new componnt controls">
<option name="closed" value="true" />
<created>1735601610504</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1735601610504</updated>
</task>
<task id="LOCAL-00006" summary="- Added new componnt controls">
<option name="closed" value="true" />
<created>1735601786207</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1735601786207</updated>
</task>
<task id="LOCAL-00007" summary="- changing compos for sketches works">
<option name="closed" value="true" />
<created>1735652081552</created>
@@ -491,7 +446,79 @@
<option name="project" value="LOCAL" />
<updated>1785948828454</updated>
</task>
<option name="localTasksCounter" value="47" />
<task id="LOCAL-00047" summary="- arc improvements, fillets, operations, bodys">
<option name="closed" value="true" />
<created>1786110295218</created>
<option name="number" value="00047" />
<option name="presentableId" value="LOCAL-00047" />
<option name="project" value="LOCAL" />
<updated>1786110295218</updated>
</task>
<task id="LOCAL-00048" summary="- arc improvements, fillets, operations, bodys">
<option name="closed" value="true" />
<created>1786125317688</created>
<option name="number" value="00048" />
<option name="presentableId" value="LOCAL-00048" />
<option name="project" value="LOCAL" />
<updated>1786125317688</updated>
</task>
<task id="LOCAL-00049" summary="- Operation highlighting, body highlighting">
<option name="closed" value="true" />
<created>1786180549405</created>
<option name="number" value="00049" />
<option name="presentableId" value="LOCAL-00049" />
<option name="project" value="LOCAL" />
<updated>1786180549405</updated>
</task>
<task id="LOCAL-00050" summary="- tech draw draft">
<option name="closed" value="true" />
<created>1786888483687</created>
<option name="number" value="00050" />
<option name="presentableId" value="LOCAL-00050" />
<option name="project" value="LOCAL" />
<updated>1786888483688</updated>
</task>
<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>
<task id="LOCAL-00052" summary="- tech draw draft v2">
<option name="closed" value="true" />
<created>1786984519780</created>
<option name="number" value="00052" />
<option name="presentableId" value="LOCAL-00052" />
<option name="project" value="LOCAL" />
<updated>1786984519781</updated>
</task>
<task id="LOCAL-00053" summary="- tech draw draft v2">
<option name="closed" value="true" />
<created>1786995561375</created>
<option name="number" value="00053" />
<option name="presentableId" value="LOCAL-00053" />
<option name="project" value="LOCAL" />
<updated>1786995561376</updated>
</task>
<task id="LOCAL-00054" summary="- tech drawing and render improv">
<option name="closed" value="true" />
<created>1787058413911</created>
<option name="number" value="00054" />
<option name="presentableId" value="LOCAL-00054" />
<option name="project" value="LOCAL" />
<updated>1787058413911</updated>
</task>
<task id="LOCAL-00055" summary="Fiexed highlighting of operations">
<option name="closed" value="true" />
<created>1787091089061</created>
<option name="number" value="00055" />
<option name="presentableId" value="LOCAL-00055" />
<option name="project" value="LOCAL" />
<updated>1787091089061</updated>
</task>
<option name="localTasksCounter" value="56" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -512,11 +539,6 @@
<ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots>
<MESSAGE value="- changing compos for sketches works" />
<MESSAGE value="- changing compos including sketches and bodies" />
<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" />
<MESSAGE value="- Added contrain displayed next to line&#10;- Slight change to point check from solver." />
<MESSAGE value="- Added enabling of midpsnap and prepared others&#10;- Show dimesnion on hover" />
@@ -537,6 +559,11 @@
<MESSAGE value="- Render improvements, camera plane, update" />
<MESSAGE value="- added &quot;measurement lines&quot;" />
<MESSAGE value="- arc improvements, fillets, operations, bodys" />
<option name="LAST_COMMIT_MESSAGE" value="- arc improvements, fillets, operations, bodys" />
<MESSAGE value="- Operation highlighting, body highlighting" />
<MESSAGE value="- tech draw draft" />
<MESSAGE value="- tech draw draft v2" />
<MESSAGE value="- tech drawing and render improv" />
<MESSAGE value="Fiexed highlighting of operations" />
<option name="LAST_COMMIT_MESSAGE" value="Fiexed highlighting of operations" />
</component>
</project>
+376
View File
@@ -0,0 +1,376 @@
# ── Sheet layout regions ────────────────────────────────────────────────────
_LAYOUT_MARGIN_MM = 10.0
_VIEW_GAP_MM = 12.0
# Title block box (see _title_block_primitives): 180 × 52 at the bottom-right
# corner with a 5 mm sheet margin. Views must clear it (plus clearance).
_TB_LEFT_MM = _A3_WIDTH_MM - 180.0 - 5.0
_TB_TOP_MM = 5.0 + 52.0
_TB_CLEARANCE_MM = 5.0
# Sheet interior (border margin) as (x0, y0, x1, y1) in sheet mm.
_SHEET_INNER = (
_LAYOUT_MARGIN_MM,
_LAYOUT_MARGIN_MM,
_A3_WIDTH_MM - _LAYOUT_MARGIN_MM,
_A3_HEIGHT_MM - _LAYOUT_MARGIN_MM,
)
# Regions the orthographic layout may occupy, as (x0, y0, w, h):
# - UPPER: full sheet width above the title block
# - LEFT: full sheet height in the left strip beside the title block
_REGION_UPPER = (
_LAYOUT_MARGIN_MM,
_TB_TOP_MM + _TB_CLEARANCE_MM,
_A3_WIDTH_MM - 2 * _LAYOUT_MARGIN_MM,
_A3_HEIGHT_MM - _LAYOUT_MARGIN_MM - (_TB_TOP_MM + _TB_CLEARANCE_MM),
)
_REGION_LEFT = (
_LAYOUT_MARGIN_MM,
_LAYOUT_MARGIN_MM,
_TB_LEFT_MM - _TB_CLEARANCE_MM - _LAYOUT_MARGIN_MM,
_A3_HEIGHT_MM - 2 * _LAYOUT_MARGIN_MM,
)
_MID_ORDER = ("left", "front", "right", "back")
_COL_ORDER = ("top", "front", "bottom") # sheet top → bottom
_GRID_ORDER = ("front", "right", "back", "top", "left", "bottom")
_RectList = List[Tuple[str, float, float, float, float]]
def _place_cross(
dims: Dict[str, Tuple[float, float]], gap: float
) -> _RectList:
"""Classic third-angle cross: mid views run left→right (left, front,
right, back), col views stack top→bottom (top, front, bottom), with the
anchor view (front, or the first present one) at the intersection.
*dims* maps view id → ``(w, h)`` in sheet units. Returns local
``(vid, x, y, w, h)`` rects (origin arbitrary — the caller centres the
union on the sheet).
"""
mid = [k for k in _MID_ORDER if k in dims]
col = [k for k in _COL_ORDER if k in dims]
if not mid and not col:
return []
rects: Dict[str, Tuple[float, float, float, float]] = {}
if col:
# Stack bottom → top.
y = 0.0
for k in reversed(col):
w, h = dims[k]
rects[k] = (0.0, y, w, h)
y += h + gap
col_h = y - gap
cx = max(dims[k][0] for k in col) / 2.0
for k in col:
_x, yy, w, h = rects[k]
rects[k] = (cx - w / 2.0, yy, w, h)
else:
col_h = 0.0
cx = 0.0
anchor = "front" if "front" in dims else (mid[0] if mid else col[0])
if anchor in rects:
ax, ay, aw, _ah = rects[anchor]
anchor_cy = ay + _ah / 2.0
else:
aw, ah = dims[anchor]
ax = cx - aw / 2.0
ay = col_h / 2.0 - ah / 2.0
rects[anchor] = (ax, ay, aw, ah)
anchor_cy = col_h / 2.0
ia = mid.index(anchor) if anchor in mid else -1
x = ax
for k in reversed(mid[:ia]):
w, h = dims[k]
x -= w + gap
rects[k] = (x, anchor_cy - h / 2.0, w, h)
x = ax + aw
for k in mid[ia + 1 :]:
w, h = dims[k]
x += gap
rects[k] = (x, anchor_cy - h / 2.0, w, h)
x += w
return [(k, *r) for k, r in rects.items()]
def _place_swapped(dims: Dict[str, Tuple[float, float]], gap: float) -> _RectList:
"""Cross with the view families swapped: the mid views stack vertically
(left, front, right, back from the top) and the col views run
horizontally (bottom, front, top from the left) — the classic cross
turned a quarter turn, for sheets where that orientation fits more.
"""
mid = [k for k in _MID_ORDER if k in dims]
col = [k for k in _COL_ORDER if k in dims]
if not mid or not col:
return []
rects: Dict[str, Tuple[float, float, float, float]] = {}
cx = max(dims[k][0] for k in mid) / 2.0
y = 0.0
for k in mid: # top → bottom
w, h = dims[k]
rects[k] = (cx - w / 2.0, y, w, h)
y += h + gap
anchor = "front" if "front" in dims else mid[0]
anchor_cy = rects[anchor][1] + dims[anchor][1] / 2.0
row: Dict[str, Tuple[float, float, float, float]] = {}
x = 0.0
x_anchor = 0.0
for k in reversed(col): # bottom, front, top → left to right
w, h = dims[k]
if k == anchor:
x_anchor = x
row[k] = (x, anchor_cy - h / 2.0, w, h)
x += w + gap
shift = rects[anchor][0] - x_anchor
for k, r in row.items():
if k == anchor:
continue
x0, y0, w, h = r
rects[k] = (x0 + shift, y0, w, h)
return [(k, *r) for k, r in rects.items()]
def _place_grid(
dims: Dict[str, Tuple[float, float]], gap: float, rows: int
) -> _RectList:
"""Wrap the present standard views into a grid of *rows* rows, filled
bottom→top and left→right (so the primary views sit near the bottom,
like in the cross)."""
order = [k for k in _GRID_ORDER if k in dims]
if not order:
return []
cols = max(1, -(-len(order) // rows))
rects: _RectList = []
y = 0.0
for r in range(rows):
chunk = order[r * cols : (r + 1) * cols]
if not chunk:
break
x = 0.0
row_h = 0.0
for k in chunk:
w, h = dims[k]
rects.append((k, x, y, w, h))
x += w + gap
row_h = max(row_h, h)
y += row_h + gap
return rects
def _fit_scale(
place: Callable[
[Dict[str, Tuple[float, float]], float], _RectList
],
dims_m: Dict[str, Tuple[float, float]],
region: Tuple[float, float, float, float],
) -> float:
"""Largest shared scale at which *dims_m* (model units) laid out by
*place* fits the ``(x0, y0, w, h)`` *region* of the sheet.
The union size grows monotonically with the scale, so a bisection
converges to the tight fit.
"""
_rx0, _ry0, rw, rh = region
def fits(s: float) -> bool:
rects = place(
{k: (w * s, h * s) for k, (w, h) in dims_m.items()}, _VIEW_GAP_MM
)
if not rects:
return True
minx = min(r[1] for r in rects)
miny = min(r[2] for r in rects)
maxx = max(r[1] + r[3] for r in rects)
maxy = max(r[2] + r[4] for r in rects)
return (maxx - minx) <= rw + 1e-9 and (maxy - miny) <= rh + 1e-9
s_lo, s_hi = 0.0, 1.0
if fits(s_hi):
s_lo = s_hi
while s_hi < 1.0e6 and fits(s_hi * 2.0):
s_hi *= 2.0
for _ in range(60):
mid = 0.5 * (s_lo + s_hi)
if fits(mid):
s_lo = mid
else:
s_hi = mid
return s_lo
def _free_rects(
used_rects: Sequence[Tuple[float, float, float, float]]
) -> List[Tuple[float, float, float, float]]:
"""Axis-aligned free rects ``(x0, y0, w, h)`` around *used_rects*,
clearing the sheet border and the title block zone."""
ix0, iy0, ix1, iy1 = _SHEET_INNER
if used_rects:
ux0 = min(r[0] for r in used_rects)
uy0 = min(r[1] for r in used_rects)
ux1 = max(r[0] + r[2] for r in used_rects)
uy1 = max(r[1] + r[3] for r in used_rects)
cands = [
(ux1 + _VIEW_GAP_MM, iy0, ix1, iy1), # right of the used block
(ix0, iy0, ux0 - _VIEW_GAP_MM, iy1), # left
(ix0, uy1 + _VIEW_GAP_MM, ix1, iy1), # above
(ix0, iy0, ix1, uy0 - _VIEW_GAP_MM), # below
]
else:
cands = [(ix0, iy0, ix1, iy1)]
tb = (
_TB_LEFT_MM - _TB_CLEARANCE_MM,
0.0,
_A3_WIDTH_MM - (_TB_LEFT_MM - _TB_CLEARANCE_MM),
_TB_TOP_MM + _TB_CLEARANCE_MM,
)
out: List[Tuple[float, float, float, float]] = []
for x0, y0, x1, y1 in cands:
x0, y0 = max(x0, ix0), max(y0, iy0)
x1, y1 = min(x1, ix1), min(y1, iy1)
if x1 - x0 < 1.0 or y1 - y0 < 1.0:
continue
if not (x1 <= tb[0] or tb[2] <= x0 or y1 <= tb[1] or tb[3] <= y0):
# Overlaps the title block zone — keep the parts above/left of it.
subs = [
(x0, max(y0, tb[3]), x1, y1),
(x0, y0, min(x1, tb[0]), y1),
]
else:
subs = [(x0, y0, x1, y1)]
for sx0, sy0, sx1, sy1 in subs:
if sx1 - sx0 > 1.0 and sy1 - sy0 > 1.0:
out.append((sx0, sy0, sx1 - sx0, sy1 - sy0))
return out
def _layout_views_on_sheet(
views: Sequence[DrawingView],
bboxes: Dict[str, Tuple[float, float, float, float]],
) -> Tuple[
Dict[str, Tuple[float, float, float, float]],
Optional[float],
Dict[str, float],
]:
"""Compute a slot rectangle and sheet rotation for each view.
*bboxes* maps view_id → ``(min_x, min_y, max_x, max_y)`` in model
units (from :func:`_edges_bounds`). Returns ``(slots, common_scale,
rotations)``: slots are ``(left, bottom, width, height)`` in sheet mm
(origin at the sheet's bottom-left corner, +y up), common_scale is the
shared model→sheet scale of the standard orthographic views, and
rotations maps view_id → sheet rotation in degrees (0 or 90).
The sheet is filled, not just used: every candidate arrangement
(classic third-angle cross, the cross with the view families swapped,
and 1/2/3-row grids) is combined with every per-view 90° rotation
assignment, and the candidate giving the largest shared scale is used.
Candidates within 0.5% of the best scale prefer the one with fewer
rotated views, then the more conventional arrangement, so layouts stay
stable and standard whenever they are already the best fit. All
orthographic views share one scale so the projections stay mutually
consistent. Isometric and custom views take the largest remaining
free rect (clearing the title block).
"""
slots: Dict[str, Tuple[float, float, float, float]] = {}
rotations: Dict[str, float] = {}
common_scale: Optional[float] = None
ortho = [
v for v in views if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
]
used_rects: List[Tuple[float, float, float, float]] = []
if ortho:
dims0: Dict[str, Tuple[float, float]] = {}
for v in ortho:
b = bboxes.get(v.kind)
vs = max(v.scale, 1e-9)
if b is None:
dims0[v.kind] = (1.0 * vs, 1.0 * vs)
else:
dims0[v.kind] = (
max(b[2] - b[0], 1e-6) * vs,
max(b[3] - b[1], 1e-6) * vs,
)
keys = list(dims0)
arrangements: Tuple[
Tuple[str, int, Callable[[Dict[str, Tuple[float, float]], float], _RectList]]
] = (
("cross", 0, _place_cross),
("swapped", 1, _place_swapped),
("grid1", 2, lambda d, g: _place_grid(d, g, 1)),
("grid2", 3, lambda d, g: _place_grid(d, g, 2)),
("grid3", 4, lambda d, g: _place_grid(d, g, 3)),
)
cands: List[
Tuple[float, int, int, Tuple[float, float, float, float],
Dict[str, Tuple[float, float]],
Callable[[Dict[str, Tuple[float, float]], float], _RectList],
List[bool]]
] = []
for mask in range(1 << len(keys)):
rotated = [bool(mask & (1 << i)) for i in range(len(keys))]
dims_m = {
k: (
dims0[k][1] if rotated[i] else dims0[k][0],
dims0[k][0] if rotated[i] else dims0[k][1],
)
for i, k in enumerate(keys)
}
for _name, rank, place in arrangements:
s_up = _fit_scale(place, dims_m, _REGION_UPPER)
s_left = _fit_scale(place, dims_m, _REGION_LEFT)
if s_up >= s_left:
s, region = s_up, _REGION_UPPER
else:
s, region = s_left, _REGION_LEFT
if s <= 0.0:
continue
cands.append((s, sum(rotated), rank, region, dims_m, place, rotated))
if cands:
best_s = max(c[0] for c in cands)
s, _nrot, _rank, region, dims_m, place, rotated = min(
(c for c in cands if c[0] >= best_s * 0.995),
key=lambda c: (c[1], c[2], -c[0]),
)
rx0, _ry0, rw, rh = region
ds = {k: (w * s, h * s) for k, (w, h) in dims_m.items()}
rects = place(ds, _VIEW_GAP_MM)
minx = min(r[1] for r in rects)
miny = min(r[2] for r in rects)
maxx = max(r[1] + r[3] for r in rects)
maxy = max(r[2] + r[4] for r in rects)
ox = rx0 + (rw - (maxx - minx)) / 2.0
oy = _ry0 + (rh - (maxy - miny)) / 2.0
for vid, x, y, w, h in rects:
slots[vid] = (x - minx + ox, y - miny + oy, w, h)
rotations[vid] = 90.0 if rotated[keys.index(vid)] else 0.0
common_scale = s
used_rects = [(ox, oy, maxx - minx, maxy - miny)]
# Isometric and custom views: the largest remaining free rect each,
# clearing the title block.
extra = [
v for v in views
if v.kind == "isometric" or v.kind not in _STANDARD_VIEWS
]
assigned = list(used_rects)
for i, v in enumerate(extra):
vid = v.kind if v.kind in _STANDARD_VIEWS else (v.name or v.id)
free = _free_rects(assigned)
if free:
slot = max(free, key=lambda r: r[2] * r[3])
else:
# No free rect left — park in the bottom-left corner stack.
slot = (_LAYOUT_MARGIN_MM, _LAYOUT_MARGIN_MM + i * 60.0, 120.0, 50.0)
slots[vid] = slot
assigned.append(slot)
return slots, common_scale, rotations
+50
View File
@@ -0,0 +1,50 @@
import os, sys
os.environ["QT_QPA_PLATFORM"] = "offscreen"
sys.path.insert(0, "/Volumes/Data_drive/Programming/fluency/src")
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QPixmap, QPainter, QColor
from PySide6.QtCore import QRectF
import math
app = QApplication.instance() or QApplication([])
from fluency.geometry.base import Point2D
from fluency.geometry_occ.kernel import OCGeometryKernel
from fluency.models.data_model import Body, Component, Project, DrawingView, TechnicalDrawing
from fluency.technical_drawing import generate_drawing, render_drawing, _A3_WIDTH_MM, _A3_HEIGHT_MM
kernel = OCGeometryKernel()
# Long thin bar: 120 x 25 x 30 (matches the "wide bar" screenshot case).
points = [Point2D(0, 0), Point2D(120, 0), Point2D(120, 25), Point2D(0, 25)]
box = kernel.extrude(kernel.create_polygon(points), 30.0)
body = Body(name="Bar", geometry=box)
comp = Component(name="BarComp")
comp.bodies[body.id] = body
project = Project()
project.components[comp.id] = comp
project.active_component = comp.id
W = 2400
H = int(W * _A3_HEIGHT_MM / _A3_WIDTH_MM)
pm = QPixmap(W, H)
pm.fill(QColor(255, 255, 255))
for name, kinds in [
("four", ["front", "top", "right", "back"]),
("six", ["front", "top", "right", "left", "back", "bottom"]),
("sixiso", ["front", "top", "right", "left", "back", "bottom", "isometric"]),
]:
drawing = TechnicalDrawing(
source_kind="component", source_id=comp.id,
views=[DrawingView(kind=k) for k in kinds],
auto_dimensions=True, title=name,
)
result = generate_drawing(drawing, project, kernel)
p = QPainter(pm)
render_drawing(p, result, QRectF(0, 0, W, H))
p.end()
out = f"/tmp/drawing_{name}.png"
pm.save(out)
print(name, "saved", out, "prims", len(result.primitives), "scale",
round(result.view_transforms.get("front", (None,))[0] or 0, 3))
+159
View File
@@ -0,0 +1,159 @@
"""Smoke test: layout optimizer fills the page, no overlaps, title block clear."""
import math
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, "/Volumes/Data_drive/Programming/fluency/src")
from fluency.models.data_model import DrawingView
from fluency.technical_drawing import (
_layout_views_on_sheet,
build_manual_candidates,
)
A3W, A3H = 420.0, 297.0
TB = (235.0, 0.0, 420.0, 62.0) # title block zone incl. clearance
def b(x, y, w, h):
return (x, y, x + w, y + h)
def overlaps(r1, r2, clear=0.0):
x0, y0, w, h = r1
x1, y1, w2, h2 = r2
return not (x0 + w <= x1 + clear or x1 + w2 <= x0 + clear
or y0 + h <= y1 + clear or y1 + h2 <= y0 + clear)
def tb_overlap(r, clear=5.0):
x0, y0, w, h = r
x1, y1, w2, h2 = TB
return not (x0 + w <= x1 + clear or x1 + w2 <= x0 + clear
or y0 + h <= y1 + clear or y1 + h2 <= y0 + clear)
def union_rect(rects):
x0 = min(r[0] for r in rects)
y0 = min(r[1] for r in rects)
x1 = max(r[0] + r[2] for r in rects)
y1 = max(r[1] + r[3] for r in rects)
return (x0, y0, x1 - x0, y1 - y0)
def check(name, kinds, boxes, expect_rot=None):
views = [DrawingView(kind=k) for k in kinds]
bboxes = {k: boxes[k] for k in kinds}
slots, scale, rots = _layout_views_on_sheet(views, bboxes)
print(f"--- {name}: scale={scale:.4f} rots={rots}")
# all slots within sheet
for k, s in slots.items():
assert 10 - 1e-6 <= s[0] and 10 - 1e-6 <= s[1], f"{k} outside sheet {s}"
assert s[0] + s[2] <= A3W - 10 + 1e-6, f"{k} beyond right {s}"
assert s[1] + s[3] <= A3H - 10 + 1e-6, f"{k} beyond top {s}"
# no overlaps between slots
ks = list(slots)
for i in range(len(ks)):
for j in range(i + 1, len(ks)):
assert not overlaps(slots[ks[i]], slots[ks[j]], 11.9), \
f"{ks[i]} overlaps {ks[j]}: {slots[ks[i]]} / {slots[ks[j]]}"
# title block clear
for k, s in slots.items():
assert not tb_overlap(s), f"{k} intrudes title block {s}"
# fill report
u = union_rect(list(slots.values()))
area = u[2] * u[3]
print(f" union: x0={u[0]:.1f} y0={u[1]:.1f} w={u[2]:.1f} h={u[3]:.1f} "
f"area={area:.0f}mm^2 ({100*area/(A3W*A3H):.0f}% of sheet)")
for k in ks:
print(f" {k}: {tuple(round(v,1) for v in slots[k])}")
if expect_rot is not None:
assert rots == expect_rot, f"expected {expect_rot}, got {rots}"
return slots, scale, rots
# 1. Two square views (front+top): should fill the page, no rotation.
check("two square", ["front", "top"],
{"front": b(0, 0, 40, 40), "top": b(0, 0, 40, 20)})
# 2. Wide bar, 4 views (old screenshot case): front+back wide, top+right.
check("wide bar 4", ["front", "top", "right", "back"],
{"front": b(0, 0, 120, 25), "top": b(0, 0, 25, 40),
"right": b(0, 0, 25, 40), "back": b(0, 0, 120, 25)})
# 3. Six views of a long thin part: rotation should kick in.
check("thin part 6", ["front", "top", "right", "left", "back", "bottom"],
{"front": b(0, 0, 200, 30), "top": b(0, 0, 30, 50),
"right": b(0, 0, 50, 30), "left": b(0, 0, 50, 30),
"back": b(0, 0, 200, 30), "bottom": b(0, 0, 30, 50)})
# 4. Single front view: fills the whole page.
check("single", ["front"], {"front": b(0, 0, 10, 20)})
# 5. All 6 + isometric + custom.
check("everything",
["front", "top", "right", "left", "back", "bottom", "isometric"],
{"front": b(0, 0, 80, 40), "top": b(0, 0, 80, 30),
"right": b(0, 0, 30, 40), "left": b(0, 0, 30, 40),
"back": b(0, 0, 80, 40), "bottom": b(0, 0, 80, 30),
"isometric": b(0, 0, 60, 60)})
# ── Inverse-transform roundtrip ─────────────────────────────────────────
# A 90°-rotated view: forward via _assemble_view's recorded 6-tuple,
# inverse via the widget's formula.
t = (2.0, 150.0, 80.0, 90.0, 10.0, 5.0) # s, o_x, o_y, deg, cx, cy
scale, ox, oy, deg, cx, cy = t
th = math.radians(deg)
cos_t, sin_t = math.cos(th), math.sin(th)
def fwd(p):
dx, dy = p[0] - cx, p[1] - cy
return ((dx * cos_t - dy * sin_t) * scale + ox,
(dx * sin_t + dy * cos_t) * scale + oy)
def inv(p):
sx, sy = (p[0] - ox) / scale, (p[1] - oy) / scale
return (sx * cos_t + sy * sin_t + cx, -sx * sin_t + sy * cos_t + cy)
for p in [(0, 0), (10, 5), (3, -7), (42.5, 11.25)]:
rt = inv(fwd(p))
assert abs(rt[0] - p[0]) < 1e-9 and abs(rt[1] - p[1]) < 1e-9, (p, rt)
print("inverse roundtrip OK")
# Legacy 3-tuple still works through build_manual_candidates.
from fluency.models.data_model import DrawingAnnotation, TechnicalDrawing
d = TechnicalDrawing(source_kind="component", source_id="c")
ann = DrawingAnnotation(kind="dimension", dimension_kind="length",
view_id="front", anchors=[(0.0, 0.0), (0.0, 12.5)],
direction=(0.0, 1.0))
d.annotations.append(ann)
cands, res, unres = build_manual_candidates(d, {"front": (2.0, 10.0, 20.0)})
assert unres == [] and cands[0].anchor_points[1] == (10.0, 45.0)
print("legacy 3-tuple OK")
# 6-tuple manual: rotated length direction must rotate too.
ann2 = DrawingAnnotation(kind="dimension", dimension_kind="length",
view_id="front",
anchors=[(0.0, 0.0), (0.0, 10.0)],
direction=(0.0, 1.0))
d2 = TechnicalDrawing(source_kind="component", source_id="c")
d2.annotations.append(ann2)
# 90° rotation about centre c=(5,5), scale 2, o=(100,80)
cands, res, unres = build_manual_candidates(
d2, {"front": (2.0, 100.0, 80.0, 90.0, 5.0, 5.0)}
)
c = cands[0]
# anchors: (0,0)->rot90 about (5,5) = (5-(0-5)*0 - ... compute: dx=-5,dy=-5
# fwd: (dx*cos - dy*sin)*2+100 = (0 - (-5))*2+100 = 110 ; (dx*sin+dy*cos)*2+80 = (-5)*2+80=70
# (0,10): dx=-5, dy=5 -> (0-5)*2+100=90 ; (5*1+0)*2+80=70
assert c.anchor_points[0] == (110.0, 70.0), c.anchor_points
assert c.anchor_points[1] == (90.0, 70.0), c.anchor_points
# direction (0,1) rotated 90° CCW -> (-1, 0)
assert c.direction[0] == -1.0 and abs(c.direction[1]) < 1e-9, c.direction
print("6-tuple manual (rotated) OK")
print("ALL SMOKE CHECKS PASSED")
+238
View File
@@ -0,0 +1,238 @@
"""Headless test: connectors follow moved features across ALL assemblies.
Simulates the test-file scenario: a plate with a hole, mated hole-to-face
in two different assemblies. The hole is moved (rebuilt geometry) and the
body-update connector recalculation must:
1. re-locate the hole connector on every instance of the component
(both the active and a non-active assembly),
2. re-solve each mated pair so the partner parts follow,
3. never snap a planar connector onto the cylindrical hole (type match),
4. mark a connector invalid when its feature disappears.
5. auto-follow a FAR move when the candidate is unambiguous (single
feature of its class) — the demo case,
6. NOT auto-apply an ambiguous far candidate (another same-class feature
is nearer) — that needs a manual pick, simulated here.
7. backfill legacy connectors' entity_type from their auto-generated name.
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
import numpy as np
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
from PySide6.QtWidgets import QApplication
from fluency.ui.main_window import MainWindow
from fluency.models.data_model import Assembly, Body
from fluency.geometry_occ.kernel import OCCGeometryObject
app = QApplication.instance() or QApplication([])
w = MainWindow()
comp = w._current_component
def make_plate(hole_xy):
box = BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape()
ax = gp_Ax2(gp_Pnt(float(hole_xy[0]), float(hole_xy[1]), 0.0), gp_Dir(0, 0, 1))
cyl = BRepPrimAPI_MakeCylinder(ax, 3.0, 6.0).Shape()
return BRepAlgoAPI_Cut(box, cyl).Shape()
body = Body(name="plate")
comp.bodies[body.id] = body
body.geometry = OCCGeometryObject(make_plate((10.0, 5.0)))
partner = w._project.add_component()
pbody = Body(name="partner")
partner.bodies[pbody.id] = pbody
pbody.geometry = OCCGeometryObject(BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape())
def make_pair(asm):
ac1 = asm.add_component_instance(comp.id, name="A")
ac2 = asm.add_component_instance(partner.id, name="B")
ac1.position = np.zeros(3)
ac1.rotation = np.eye(3)
ac2.position = np.array([0.0, 0.0, 15.0])
ac2.rotation = np.eye(3)
ac1.geom_cache[body.id] = body.geometry
c1 = ac1.add_connector(
position=(10.0, 5.0, 2.5),
normal=(0.0, 0.0, 1.0),
x_dir=(1.0, 0.0, 0.0),
source_obj_id=f"asm_{ac1.id}_{body.id}",
name="Conn hole A",
entity_type="cylindrical_face",
)
c2 = ac2.add_connector(
position=(15.0, 15.0, 0.0),
normal=(0.0, 0.0, -1.0),
x_dir=(1.0, 0.0, 0.0),
source_obj_id=f"asm_{ac2.id}_{pbody.id}",
name="Conn face B",
entity_type="planar_face",
)
c1.is_grounded = True
c1.partner_ac_id = ac2.id
c1.partner_connector_id = c2.id
c2.partner_ac_id = ac1.id
c2.partner_connector_id = c1.id
aconn = asm.add_connection(ac1.id, ac2.id)
aconn.first_connector_id = c1.id
aconn.second_connector_id = c2.id
return ac1, ac2, c1, c2, aconn
asm1 = w._project.get_active_assembly()
asm2 = w._project.add_assembly(Assembly(name="second"))
pair1 = make_pair(asm1)
pair2 = make_pair(asm2)
assert w._project.active_assembly == asm1.id # asm2 is the NON-active one
# ── Move the hole: rebuild the plate with the hole at (25, 12) ──────────
new_geom = OCCGeometryObject(make_plate((25.0, 12.0)))
body.geometry = new_geom
for asm in (asm1, asm2):
for ac in asm.components.values():
if ac.component_id == comp.id:
ac.geom_cache[body.id] = new_geom
# ── The body-update auto path ───────────────────────────────────────────
w._recalculate_connectors()
for (ac1, ac2, c1, c2, aconn), label in ((pair1, "asm1"), (pair2, "asm2")):
# The hole connector followed the hole on every instance.
assert np.allclose(c1.position, (25.0, 12.0, 2.5), atol=1e-6), (label, c1.position)
# The mated pair re-aligned: both world connectors coincide.
w1 = ac1.position + ac1.rotation @ np.asarray(c1.position)
w2 = ac2.position + ac2.rotation @ np.asarray(c2.position)
assert np.allclose(w1, w2, atol=1e-6), (label, w1, w2)
print(f"{label}: connector at {np.round(c1.position, 3)}, "
f"partner moved to {np.round(ac2.position, 3)}")
# ── The 'Upd' button path: move the hole again, re-run the handler ─────
w._refresh_connection_list()
w._connection_list.setCurrentRow(0) # active assembly = asm1
geom3 = OCCGeometryObject(make_plate((35.0, 20.0)))
body.geometry = geom3
for ac in asm1.components.values():
if ac.component_id == comp.id:
ac.geom_cache[body.id] = geom3
w._on_update_connection_from_list()
ac1, ac2, c1, c2, aconn = pair1
assert np.allclose(c1.position, (35.0, 20.0, 2.5), atol=1e-6), c1.position
w1 = ac1.position + ac1.rotation @ np.asarray(c1.position)
w2 = ac2.position + ac2.rotation @ np.asarray(c2.position)
assert np.allclose(w1, w2, atol=1e-6), (w1, w2)
print("Upd button: connector at", np.round(c1.position, 3),
"partner at", np.round(ac2.position, 3))
# ── Type matching: a planar connector must not snap onto the hole ───────
ac1 = pair1[0]
c3 = ac1.add_connector(
position=(30.0, 20.0, 5.0),
normal=(0.0, 0.0, 1.0),
x_dir=(1.0, 0.0, 0.0),
source_obj_id=f"asm_{ac1.id}_{body.id}",
name="Conn face",
entity_type="planar_face",
)
res = w._redetect_connector_on_geometry(c3, ac1, comp)
assert res is not None, "planar connector candidate missing"
assert not c3.is_invalid, "pure relocator must not mutate the connector"
assert np.allclose(c3.position, (30.0, 20.0, 5.0), atol=1e-6), c3.position
assert np.allclose(res[1], (30.0, 20.0, 5.0), atol=1e-6), res[1]
print("planar connector stayed on the face:", np.round(res[1], 3))
# ── Feature removed: relocator finds nothing; auto path marks invalid ──
plain = OCCGeometryObject(BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape())
body.geometry = plain
ac1.geom_cache[body.id] = plain
res = w._redetect_connector_on_geometry(pair1[2], ac1, comp)
assert res is None, "hole connector should find no candidate on a plain box"
w._recalculate_connectors()
assert pair1[2].is_invalid, "auto path must mark the connector invalid"
print("removed feature -> connector marked invalid")
# ── Far move with a decoy: ambiguous candidate is NOT auto-applied ─────
# The real hole moved to (25, 12) — ~12.8mm from the stored (35, 20) — but
# a SECOND hole now sits at (28, 16), only ~8mm away. The nearest
# candidate is ambiguous (different feature), so the auto path must leave
# the connector alone and queue it for a manual pick.
def make_plate2(holes):
box = BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape()
for hx, hy in holes:
ax = gp_Ax2(gp_Pnt(hx, hy, 0.0), gp_Dir(0, 0, 1))
box = BRepAlgoAPI_Cut(box, BRepPrimAPI_MakeCylinder(ax, 3.0, 6.0).Shape()).Shape()
return box
# ── Stage 1: the user's demo — a SINGLE hole moved far away ─────────────
# 12.8mm from the stored position, but the only cylindrical face on the
# body → unambiguous → must be auto-applied (and the mate re-solved).
geom4 = OCCGeometryObject(make_plate2([(25.0, 12.0)]))
body.geometry = geom4
for asm in (asm1, asm2):
for ac in asm.components.values():
if ac.component_id == comp.id:
ac.geom_cache[body.id] = geom4
w._recalculate_connectors()
assert np.allclose(pair1[2].position, (25.0, 12.0, 2.5), atol=1e-6), \
"unique far candidate must be auto-applied (the demo case)"
assert not pair1[2].is_invalid
w1 = ac1.position + ac1.rotation @ np.asarray(pair1[2].position)
w2 = pair1[1].position + pair1[1].rotation @ np.asarray(pair1[3].position)
assert np.allclose(w1, w2, atol=1e-6), (w1, w2)
print("single far hole: auto-followed to", np.round(pair1[2].position, 3))
# ── Stage 2: far move with a decoy — ambiguous, NOT auto-applied ───────
# The real hole now sits at (32, 20) — 10.6mm from the stored (25, 12) —
# while a decoy hole at (21, 7) is only 6.4mm away. The nearest
# candidate is likely a DIFFERENT feature, so the auto path must leave
# the connector alone and queue it for a manual pick.
geom5 = OCCGeometryObject(make_plate2([(32.0, 20.0), (21.0, 7.0)]))
body.geometry = geom5
for asm in (asm1, asm2):
for ac in asm.components.values():
if ac.component_id == comp.id:
ac.geom_cache[body.id] = geom5
w._recalculate_connectors()
assert np.allclose(pair1[2].position, (25.0, 12.0, 2.5), atol=1e-6), \
"ambiguous far candidate must not be auto-applied"
assert not pair1[2].is_invalid, "ambiguous candidate is not a missing feature"
# Simulate the user clicking the REAL hole in the relocate pick flow:
w._relocate_pending = [(asm1, ac1, pair1[2])]
w._on_relocate_picked(
(32.0, 20.0, 2.5), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0),
"cylindrical_face", f"asm_{ac1.id}_{body.id}",
)
assert np.allclose(pair1[2].position, (32.0, 20.0, 2.5), atol=1e-6), pair1[2].position
assert not pair1[2].is_invalid, "manual pick must re-validate the connector"
assert w._relocate_pending is None, "pending queue must drain after the pick"
w1 = ac1.position + ac1.rotation @ np.asarray(pair1[2].position)
w2 = pair1[1].position + pair1[1].rotation @ np.asarray(pair1[3].position)
assert np.allclose(w1, w2, atol=1e-6), (w1, w2)
print("far move: manual pick re-homed connector at", np.round(pair1[2].position, 3),
"partner at", np.round(pair1[1].position, 3))
# ── Legacy backfill: empty entity_type recovered from the auto name ────
from fluency.models.data_model import Connector
legacy = Connector(
name="Conn cylindrical_face anchor",
position=(32.0, 20.0, 2.5),
source_obj_id=f"asm_{ac1.id}_{body.id}",
)
assert legacy.entity_type == "cylindrical_face", legacy.entity_type
res = w._redetect_connector_on_geometry(legacy, ac1, comp)
assert res is not None and res[0] < 1e-3, res
print("legacy name backfill: entity_type =", legacy.entity_type)
print("CONNECTOR_RELOCATE_OK")
+158
View File
@@ -0,0 +1,158 @@
"""Headless repro: load assemblytest.fluency, compare sketch circle centers
vs. saved body hole axes vs. connector positions, then run the real
body-update path and check where connectors land.
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
import numpy as np
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Cylinder
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS
from PySide6.QtWidgets import QApplication
from fluency.ui.main_window import MainWindow
def etype(e):
if isinstance(e, dict):
return e.get("type")
return getattr(e, "entity_type", None) or getattr(e, "type", None)
def egeom(e):
if isinstance(e, dict):
return e.get("geometry")
return getattr(e, "geometry", None)
app = QApplication.instance() or QApplication([])
w = MainWindow()
path = os.path.join(os.path.dirname(__file__), "assemblytest.fluency")
ok = w._open_project_file(path)
assert ok, "failed to open demo file"
proj = w._project
for comp in proj.components.values():
print(f"=== {comp.name} ({comp.id})")
for sk in comp.sketches.values():
occ = sk.occ_sketch
if occ is None:
print(" sketch with no occ_sketch:", sk.id)
continue
for e in occ._entities.values():
t = etype(e)
if t == "circle":
g = egeom(e)
cid = e.get("id") if isinstance(e, dict) else e.id
print(f" circle id={cid} center=({g[0][0]!r}, {g[0][1]!r}) r={g[1]!r}")
for body in comp.bodies.values():
if not body.geometry:
print(f" body {body.name}: no geometry")
continue
shape = w._kernel._get_shape(body.geometry)
print(f" body {body.name}: extrude len={body.extrude_length}")
expl = TopExp_Explorer(shape, TopAbs_FACE)
while expl.More():
face = TopoDS.Face_s(expl.Current())
try:
adaptor = BRepAdaptor_Surface(face)
if adaptor.GetType() == GeomAbs_Cylinder:
cyl = adaptor.Cylinder()
loc = cyl.Location()
d = cyl.Axis().Direction()
print(
f" cyl axis at ({loc.X()!r}, {loc.Y()!r}) "
f"dir=({d.X():.4f},{d.Y():.4f},{d.Z():.4f})"
)
except Exception:
pass
expl.Next()
asm = proj.get_active_assembly()
print("=== active assembly:", asm.name)
for ac in asm.components.values():
comp = proj.get_component_by_id(ac.component_id)
print(f" instance '{ac.name}' -> {comp.name}, pos={np.round(ac.position, 4)}")
for conn in ac.connectors.values():
print(
f" conn '{conn.name}' pos={np.round(conn.position, 6)} "
f"normal={np.round(conn.normal, 3)} et={conn.entity_type!r} "
f"invalid={conn.is_invalid}"
)
# ── Run the real update path: rebuild bodies from sketch, recalc connectors ──
# Activate the component with the holes (Component 1).
comp1 = None
for comp in proj.components.values():
if any(
etype(e) == "circle"
for sk in comp.sketches.values()
for e in (sk.occ_sketch._entities.values() if sk.occ_sketch else [])
):
comp1 = comp
break
assert comp1 is not None
w._current_component = comp1
print("=== running _update_bodies_from_sketch()")
w._update_bodies_from_sketch()
print("=== running _recalculate_connectors()")
w._recalculate_connectors()
print("=== after update")
for comp in proj.components.values():
for body in comp.bodies.values():
if not body.geometry:
continue
shape = w._kernel._get_shape(body.geometry)
expl = TopExp_Explorer(shape, TopAbs_FACE)
axes = []
while expl.More():
face = TopoDS.Face_s(expl.Current())
try:
adaptor = BRepAdaptor_Surface(face)
if adaptor.GetType() == GeomAbs_Cylinder:
loc = adaptor.Cylinder().Location()
axes.append((round(loc.X(), 9), round(loc.Y(), 9)))
except Exception:
pass
expl.Next()
print(f" {comp.name} body axes: {axes}")
for sk in comp.sketches.values():
for e in sk.occ_sketch._entities.values():
t = etype(e)
if t == "circle":
g = egeom(e)
print(f" {comp.name} circle: ({g[0][0]!r}, {g[0][1]!r})")
asm = proj.get_active_assembly()
for ac in asm.components.values():
for conn in ac.connectors.values():
print(
f" conn '{conn.name}' pos={np.round(conn.position, 6)} "
f"invalid={conn.is_invalid}"
)
# Partner alignment check: for each connection, world positions of the pair.
for aconn in asm.connections:
a1 = asm.components.get(aconn.first_ac_id)
a2 = asm.components.get(aconn.second_ac_id)
c1 = a1.connectors.get(aconn.first_connector_id)
c2 = a2.connectors.get(aconn.second_connector_id)
if c1 is None or c2 is None:
continue
w1 = a1.position + a1.rotation @ np.asarray(c1.position, dtype=float)
w2 = a2.position + a2.rotation @ np.asarray(c2.position, dtype=float)
print(
f" conn {aconn.id[:8]}: w1={np.round(w1, 6)} w2={np.round(w2, 6)} "
f"gap={float(np.linalg.norm(w1 - w2))!r}"
)
print("DEMO_REPRO_DONE")
+89
View File
@@ -0,0 +1,89 @@
"""Round-trip smoke test: instance-local sketches + modifiers survive save/load.
Builds a minimal project (component with a body, assembly with two instances,
one carrying an instance sketch + cut modifier + fillet modifier), saves to a
temp .fluency, reloads, and asserts:
1. the shared component is untouched by instance work,
2. the instance sketch + modifiers round-trip with sketch refs intact,
3. the plain instance has none.
"""
import os
import tempfile
import numpy as np
from fluency.models.data_model import (
Project, Component, Body, Sketch, Feature, Assembly, AssemblyComponent,
)
from fluency.io.project_io import save_project, load_project
def main():
project = Project(name="inst test")
comp = project.add_component()
comp.name = "BasePart"
body = comp.add_body(Body(name="MainBody"))
base_sketch = comp.add_sketch(Sketch(name="BaseSketch"))
body.features.append(
Feature(operation="extrude", sketch=base_sketch, length=10.0)
)
asm = project.add_assembly(Assembly(name="TestAsm"))
ac1 = asm.add_component_instance(comp.id, name="Instance A")
ac1.position = np.array([0.0, 0.0, 0.0])
ac2 = asm.add_component_instance(comp.id, name="Instance B")
ac2.position = np.array([50.0, 0.0, 0.0])
# Instance A: local sketch + cut modifier referencing it + fillet.
inst_sketch = ac1.add_instance_sketch()
inst_sketch.name = "InstCutSketch"
ac1.add_modifier(body.id, Feature(operation="cut", sketch=inst_sketch,
length=5.0, through_all=True))
ac1.add_modifier(body.id, Feature(operation="fillet", radius=1.0))
# ---- save / load ----
fd, path = tempfile.mkstemp(suffix=".fluency")
os.close(fd)
try:
save_project(project, path)
loaded, _view = load_project(path)
lcomp = loaded.components[comp.id]
lac1 = None
lac2 = None
for lasm in loaded.assemblies.values():
for ac in lasm.components.values():
if ac.name == "Instance A":
lac1 = ac
elif ac.name == "Instance B":
lac2 = ac
assert lac1 is not None and lac2 is not None, "instances missing"
# 1. component untouched
assert len(lcomp.sketches) == 1, "component sketch count changed"
assert len(lcomp.bodies[body.id].features) == 1, "feature chain changed"
assert not getattr(lcomp.bodies[body.id], "modifiers", None)
# 2. instance A round-trip
assert len(lac1.sketches) == 1, "instance sketch missing"
lsk_id, lsk = next(iter(lac1.sketches.items()))
assert lsk.name == "InstCutSketch"
mods = lac1.modifiers
lbody_id = next(iter(lcomp.bodies))
assert len(mods.get(lbody_id, [])) == 2, f"modifiers missing: {mods}"
cut, fil = mods[lbody_id][0], mods[lbody_id][1]
assert cut.operation == "cut" and fil.operation == "fillet"
assert cut.sketch is not None and cut.sketch.id == lsk_id, \
"cut sketch ref did not resolve to instance sketch"
assert cut.length == 5.0 and cut.through_all
# 3. plain instance clean
assert not lac2.sketches and not lac2.modifiers
print("ROUND_TRIP_OK")
finally:
os.unlink(path)
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
"""Headless smoke: the manual re-pick fallback path (prompt -> frame -> pick mode)."""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
import numpy as np
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
from PySide6.QtWidgets import QApplication, QMessageBox
from fluency.ui.main_window import MainWindow
from fluency.models.data_model import Body
from fluency.geometry_occ.kernel import OCCGeometryObject
app = QApplication.instance() or QApplication([])
w = MainWindow()
comp = w._current_component
body = Body(name="plate")
comp.bodies[body.id] = body
box = BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape()
ax = gp_Ax2(gp_Pnt(10.0, 5.0, 0.0), gp_Dir(0, 0, 1))
box = BRepAlgoAPI_Cut(box, BRepPrimAPI_MakeCylinder(ax, 3.0, 6.0).Shape()).Shape()
body.geometry = OCCGeometryObject(box)
partner = w._project.add_component()
pbody = Body(name="partner")
partner.bodies[pbody.id] = pbody
pbody.geometry = OCCGeometryObject(BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape())
asm = w._project.get_active_assembly()
ac1 = asm.add_component_instance(comp.id, name="A")
ac2 = asm.add_component_instance(partner.id, name="B")
ac1.geom_cache[body.id] = body.geometry
c1 = ac1.add_connector(
position=(10.0, 5.0, 2.5), normal=(0, 0, 1), x_dir=(1, 0, 0),
source_obj_id=f"asm_{ac1.id}_{body.id}",
name="Conn hole", entity_type="cylindrical_face",
)
c1.partner_ac_id = ac2.id
# Activate the assembly view so the prompt path is taken.
w._assembly_view_active = True
w._selected_assembly_component_id = ac1.id
# Force a Yes from the question dialog, and record that it actually fired.
asked = {}
def fake_question(parent, title, text, buttons, default):
asked["title"] = title
return QMessageBox.StandardButton.Yes
QMessageBox.question = staticmethod(fake_question)
def fake_warning(*a, **k):
return QMessageBox.StandardButton.Ok
QMessageBox.warning = staticmethod(fake_warning)
w._prompt_relocate_unresolved([(asm, ac1, c1)])
assert asked.get("title") == "Connector Position Needed", asked
assert w._relocate_pending == [(asm, ac1, c1)], w._relocate_pending
assert w._viewer_3d._connector_pick_mode, "pick mode must be active"
# A wrong-part click must not consume the pending entry.
w._on_relocate_picked((0, 0, 0), (0, 0, 1), (1, 0, 0), "planar_face", f"asm_{ac2.id}_{pbody.id}")
assert w._relocate_pending == [(asm, ac1, c1)], "wrong part must not consume the pick"
# The right click re-homes and drains.
w._on_relocate_picked((40.0, 30.0, 2.5), (0, 0, 1), (1, 0, 0), "cylindrical_face", f"asm_{ac1.id}_{body.id}")
assert w._relocate_pending is None
assert np.allclose(c1.position, (40.0, 30.0, 2.5)), c1.position
assert not w._viewer_3d._connector_pick_mode, "pick mode must be off after completion"
# Esc cancel mid-flight clears the state.
w._relocate_pending = [(asm, ac1, c1)]
w._start_relocate_pick_next()
w._on_connector_pick_cancelled()
assert w._relocate_pending is None
assert not w._viewer_3d._connector_pick_mode
print("RELOCATE_PICK_FALLBACK_OK")
+149
View File
@@ -0,0 +1,149 @@
"""Headless test: update_external_entities handles circle/arc dict entries.
Reproduces the crash where a re-projected face contains a circular edge
(e.g. an instance cut hole): _project_face_to_uv returns a mixed list of
polylines + curve dicts, and update_external_entities used to unpack the
dict entries as (u, v) tuples.
Covers:
1. mixed projection (polylines + circle dict) -> rebuild + rebind path,
no crash, no duplicate/orphan curve entities, user geometry re-anchored.
2. repeated update with the same mixed projection -> stable (idempotent).
3. polylines-only same-topology projection -> in-place path still works
(external ids preserved).
"""
import math
from fluency.geometry_occ.sketch import OCCSketch
RECT = [
[(0.0, 0.0), (10.0, 0.0)],
[(10.0, 0.0), (10.0, 10.0)],
[(10.0, 10.0), (0.0, 10.0)],
[(0.0, 10.0), (0.0, 0.0)],
]
def _counts(sk):
ents = list(sk._entities.values())
return {
"ext_points": sum(
1
for e in ents
if e.entity_type == "point" and getattr(e, "is_external", False)
),
"ext_lines": sum(
1
for e in ents
if e.entity_type == "line" and getattr(e, "is_external", False)
),
"circles": sum(1 for e in ents if e.entity_type == "circle"),
"arcs": sum(1 for e in ents if e.entity_type == "arc"),
"user_points": sum(
1
for e in ents
if e.entity_type == "point" and not getattr(e, "is_external", False)
),
}
def test_mixed_projection_rebuild():
sk = OCCSketch()
sk.add_external_polylines([list(p) for p in RECT])
center = sk.add_external_point(5.0, 5.0)
sk.add_circle(center, 2.0)
user = sk.add_point(5.0, 5.0)
assert sk.constrain_coincident(user, center)
assert sk.solve()
# Re-projection: same rectangle, circle moved + resized -> mixed list.
new_proj = [
list(p) for p in RECT
] + [
{"type": "circle", "center": [6.0, 6.0], "radius": 1.5},
]
old_ext_ids = set(sk._external_entity_ids)
assert sk.update_external_entities(new_proj), "rebuild + rebind solve failed"
c = _counts(sk)
assert c["circles"] == 1, f"duplicate circle entities: {c}"
assert c["ext_points"] == 5, f"ext point count wrong (expect 4 corners + 1 centre): {c}"
assert c["ext_lines"] == 4, f"ext line count wrong (expect 4): {c}"
assert c["user_points"] == 1
# The coincident rebind must anchor the user point to the NEW centre.
ux, uy = user.geometry
assert math.hypot(ux - 6.0, uy - 6.0) < 1e-6, f"user point at {(ux, uy)}"
# Rebuild path: fresh external ids.
assert not (old_ext_ids & sk._external_entity_ids)
# Idempotent second pass with the same projection.
assert sk.update_external_entities(list(new_proj)), "second pass failed"
c2 = _counts(sk)
assert c2 == c, f"counts changed on second pass: {c} -> {c2}"
ux, uy = user.geometry
assert math.hypot(ux - 6.0, uy - 6.0) < 1e-6
print("test_mixed_projection_rebuild OK")
def test_polylines_only_inplace():
sk = OCCSketch()
sk.add_external_polylines([list(p) for p in RECT])
corner = None
for eid in sk._external_entity_ids:
ent = sk._entities[eid]
if ent.entity_type == "point" and ent.geometry == (0.0, 0.0):
corner = ent
break
assert corner is not None
user = sk.add_point(0.0, 0.0)
assert sk.constrain_coincident(user, corner)
assert sk.solve()
# Same topology, slightly shifted rectangle -> in-place move.
moved = [[(u + 1.0, v + 2.0) for (u, v) in poly] for poly in RECT]
old_ext_ids = set(sk._external_entity_ids)
assert sk.update_external_entities(moved), "in-place solve failed"
assert sk._external_entity_ids == old_ext_ids, "in-place path must keep ids"
ux, uy = user.geometry
assert math.hypot(ux - 1.0, uy - 2.0) < 1e-6, f"user point at {(ux, uy)}"
print("test_polylines_only_inplace OK")
def test_arc_import_shares_corners():
"""_import_external_curves must merge arc endpoints with existing
polyline corner points (no floating duplicate endpoints)."""
sk = OCCSketch()
# Rectangle with the top-right corner filleted: the arc endpoints must
# land on the truncated-edge corner points, not create new ones.
r = 2.0
sk.add_external_polylines([
[(0.0, 0.0), (10.0, 0.0)],
[(10.0, 0.0), (10.0, 10.0 - r)],
[(10.0 - r, 10.0), (0.0, 10.0)],
[(0.0, 10.0), (0.0, 0.0)],
])
sk._import_external_curves(
[],
[
{
"type": "arc",
"center": [10.0 - r, 10.0 - r],
"start": [10.0, 10.0 - r],
"end": [10.0 - r, 10.0],
"radius": r,
},
],
)
c = _counts(sk)
# 5 corners + 1 arc centre, NO extra endpoint entities.
assert c["ext_points"] == 6, f"expected 6 ext points, got {c}"
assert c["arcs"] == 1, f"expected 1 arc, got {c}"
assert sk.solve()
print("test_arc_import_shares_corners OK")
if __name__ == "__main__":
test_mixed_projection_rebuild()
test_polylines_only_inplace()
test_arc_import_shares_corners()
print("UNDERLAY_CURVES_OK")
-147
View File
@@ -1,147 +0,0 @@
# WARP.md
This file provides guidance to WARP (warp.dev) when working with code in this repository.
## Project Overview
Fluency is a CAD (Computer Aided Design) application built with Python/PySide6 that provides parametric 3D modeling through a timeline-based project system. The application combines 2D sketching with constraint solving, 3D visualization using VTK, and SDF (Signed Distance Function) based mesh generation.
## Common Commands
### Development Environment Setup
```bash
# Activate virtual environment (if exists)
source .venv/bin/activate
# Install dependencies
pip install -r requirements.txt
```
### Running the Application
```bash
# Run the main application
python main.py
# Run with debugging
python -u main.py
```
### UI Development
```bash
# Convert Qt Designer UI file to Python code
pyside6-uic gui.ui > Gui.py -g python
```
### Building Executable
The project uses Nuitka for compilation (configured in `main.py` header):
```bash
# Build standalone executable
nuitka --standalone --plugin-enable=pyside6 --plugin-enable=numpy --macos-create-app-bundle main.py
```
### Testing
```bash
# Run mesh generation test
python meshtest.py
```
## Architecture Overview
### Core Components
#### Main Application (`main.py`)
- **MainWindow**: Central UI controller that manages all widgets and user interactions
- **Project System**: Hierarchical structure: `Project → Timeline → Component → Sketch/Body`
- **Signal-based Communication**: Qt signals coordinate between 2D sketching and 3D rendering
#### Project Hierarchy
```
Project
├── Timeline (list of Components)
└── Component
├── Sketches (dict)
├── Bodies (dict)
└── Connectors (for assembly)
```
#### Drawing Modules (`drawing_modules/`)
- **SketchWidget** (`draw_widget_solve.py`): 2D parametric sketching with SolverSpace constraint solving
- **VTKWidget** (`vtk_widget.py`): 3D visualization and mesh interaction using VTK
- **PyVistaWidget** (`vysta_widget.py`): Alternative 3D rendering backend
#### Mesh Generation (`mesh_modules/`)
- **VESTA** (`vesta_mesh.py`): Multi-threaded SDF-to-mesh conversion using marching cubes
- **Interactor Mesh** (`interactor_mesh.py`): Simplified edge-based meshes for 3D selection
- **Simple Mesh** (`simple_mesh.py`): Basic mesh utilities
### Data Flow Architecture
#### 2D to 3D Pipeline
1. **2D Sketching**: User draws in SketchWidget using Qt coordinate system
2. **Constraint Solving**: SolverSpace resolves geometric constraints
3. **SDF Generation**: Sketch converted to Signed Distance Functions for 3D operations
4. **Mesh Generation**: VESTA generates triangle meshes from SDF using marching cubes
5. **3D Rendering**: VTK displays both solid meshes and interactive edges
#### Signal Flow (from `doc/flow.md`)
- 2D QPoint → cartesian space → SolverSpace dict → constraint solving → display
- 3D mesh selection → projection to 2D → sketch widget integration
### Key Classes
#### Core Data Structures
- **Sketch**: 2D geometric data with origin, normal, points, and constraints
- **Body**: 3D mesh representation containing SDF objects and interactor meshes
- **Component**: Container grouping related sketches and bodies
- **Interactor**: Simplified edge-based mesh for 3D manipulation
#### Constraint Solving
The application uses `python_solvespace` for parametric constraint solving:
- Point-to-point constraints
- Distance constraints
- Horizontal/vertical line constraints
- Point-to-line constraints
### Technology Stack
- **GUI**: PySide6 (Qt for Python)
- **3D Graphics**: VTK for rendering, PyVista as alternative
- **Constraint Solving**: SolverSpace for parametric geometry
- **Mesh Generation**: SDF library with custom VESTA marching cubes implementation
- **Scientific Computing**: NumPy for mathematical operations
## Development Workflow
### Adding New Sketch Tools
1. Add UI button in `gui.ui`
2. Convert UI: `pyside6-uic gui.ui > Gui.py -g python`
3. Connect signal in `MainWindow.__init__()`
4. Implement tool logic in `SketchWidget`
### Adding New 3D Operations
1. Extend operation buttons in the Modify group
2. Implement operation logic using SDF functions
3. Update Body creation and timeline management
4. Handle interactor mesh generation for selection
### Debugging Tips
- Monitor solver results through `SolverSystem` status
- Use VTK's built-in debugging for rendering issues
- Check coordinate transformations between 2D sketch and 3D space
- Verify SDF function outputs before mesh generation
### File Structure
- `main.py`: Application entry point and main window
- `Gui.py`: Auto-generated UI code (do not edit directly)
- `gui.ui`: Qt Designer UI definition file
- `drawing_modules/`: 2D and 3D rendering widgets
- `mesh_modules/`: Mesh generation and processing
- `doc/`: Architecture and command documentation
## Dependencies
Primary external libraries:
- `PySide6`: Qt GUI framework
- `vtk`: 3D visualization toolkit
- `python-solvespace`: Constraint solving
- `sdf`: Signed Distance Function operations
- `numpy`: Numerical computations
- `scikit-image`: Marching cubes algorithm
- `names`: Random name generation for sketches
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

+75 -18
View File
@@ -16,8 +16,35 @@ from fluency.geometry.base import (
Point3D,
)
logger = logging.getLogger(__name__)
def _curve_is_linear(occ_edge: Any) -> bool:
"""Return True if *occ_edge* has a linear or chamferable curve type.
``BRepFilletAPI_MakeChamfer`` and ``BRepFilletAPI_MakeFillet`` crash
(segfault) on circular/elliptical curves. We pre-filter those out.
"""
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_CurveType
try:
ad = BRepAdaptor_Curve(occ_edge)
ct = ad.GetType()
except Exception:
# If we can't classify, assume it's safe (will be caught later).
return True
# Chamfer/fillet only support linear curves reliably.
return ct in (
GeomAbs_CurveType.GeomAbs_Line,
GeomAbs_CurveType.GeomAbs_BSplineCurve,
GeomAbs_CurveType.GeomAbs_BezierCurve,
GeomAbs_CurveType.GeomAbs_OffsetCurve,
GeomAbs_CurveType.GeomAbs_Parabola,
GeomAbs_CurveType.GeomAbs_Hyperbola,
)
class OCCGeometryObject(GeometryObject):
"""Geometry object wrapper for OpenCASCADE shapes."""
@@ -446,52 +473,82 @@ class OCGeometryKernel(GeometryKernel):
def fillet(
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply fillet to edges."""
shape = self._get_shape(body)
"""Apply fillet to edges. Skips edges that cannot be filleted."""
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
fillet = BRepFilletAPI_MakeFillet(shape)
shape: Any = self._get_shape(body)
if shape is None:
return OCCGeometryObject(None, {"type": "fillet"})
if edges:
for edge in edges:
fillet.Add(radius, edge)
# Collect candidate edges
if edges is not None:
candidates = list(edges)
else:
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.TopoDS import TopoDS
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
candidates = []
while explorer.More():
fillet.Add(radius, TopoDS.Edge_s(explorer.Current()))
e = TopoDS.Edge_s(explorer.Current())
if _curve_is_linear(e):
candidates.append(e)
explorer.Next()
fillet.Build()
return OCCGeometryObject(fillet.Shape(), {"type": "fillet"})
# Add all edges, then Build once — avoids OCC internal crashes
fl = BRepFilletAPI_MakeFillet(shape)
for edge in candidates:
try:
fl.Add(radius, edge)
except Exception:
pass # skip edges that fail to add
fl.Build()
if fl.IsDone():
return OCCGeometryObject(fl.Shape(), {"type": "fillet"})
# If Build failed, return original shape (no fillet applied)
return OCCGeometryObject(shape, {"type": "fillet"})
def chamfer(
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply chamfer to edges."""
shape = self._get_shape(body)
"""Apply chamfer to edges. Skips edges that cannot be chamfered."""
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
chamfer = BRepFilletAPI_MakeChamfer(shape)
shape: Any = self._get_shape(body)
if shape is None:
return OCCGeometryObject(None, {"type": "chamfer"})
if edges:
for edge in edges:
chamfer.Add(size, edge)
# Collect candidate edges
if edges is not None:
candidates = list(edges)
else:
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.TopoDS import TopoDS
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
candidates = []
while explorer.More():
chamfer.Add(size, TopoDS.Edge_s(explorer.Current()))
e = TopoDS.Edge_s(explorer.Current())
if _curve_is_linear(e):
candidates.append(e)
explorer.Next()
chamfer.Build()
return OCCGeometryObject(chamfer.Shape(), {"type": "chamfer"})
# Add all edges, then Build once — avoids OCC internal crashes
mc = BRepFilletAPI_MakeChamfer(shape)
for edge in candidates:
try:
mc.Add(size, edge)
except Exception:
pass # skip edges that fail to add
mc.Build()
if mc.IsDone():
return OCCGeometryObject(mc.Shape(), {"type": "chamfer"})
# If Build failed, return original shape (no chamfer applied)
return OCCGeometryObject(shape, {"type": "chamfer"})
def shell(
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
+200 -32
View File
@@ -327,9 +327,16 @@ class OCCSketch(SketchInterface):
start_point: SketchEntity,
end_point: SketchEntity,
sweep: Optional[float] = None,
register: bool = True,
) -> OCCSketchEntity:
"""Add an arc (added to solver + tracked).
*register* False keeps the arc tracked-only (no solver entity, no
handle) — for reference geometry whose three reference points are
all already fixed, e.g. external underlay arcs: registering the
arc on top of three dragged points over-constrains the solver
(SolveSpace reports the system as inconsistent).
The arc is registered with SolveSpace so its three reference points
are linked: start, end, and centre. SolveSpace's arc entity
implicitly enforces ``distance(start, centre) = distance(end, centre)``,
@@ -362,7 +369,11 @@ class OCCSketch(SketchInterface):
if center_entity is None or start_entity is None or end_entity is None:
raise ValueError("Arc points not found in sketch")
if center_entity.handle is None or start_entity.handle is None or end_entity.handle is None:
if register and (
center_entity.handle is None
or start_entity.handle is None
or end_entity.handle is None
):
raise ValueError("Arc endpoints must already be in the solver")
cx, cy = center_entity.geometry
@@ -386,17 +397,20 @@ class OCCSketch(SketchInterface):
# whenever the workplane orientation changes. The normal is
# invalidated by ``clear`` / ``_rebuild_solver`` /
# ``set_workplane`` (the workplane reference changes).
if self._wp_normal_handle is None:
self._wp_normal_handle = self._make_arc_normal_3d()
nm: Any = self._wp_normal_handle
assert nm is not None # _make_arc_normal_3d always returns a handle
arc_handle = self._solver.add_arc(
nm,
center_entity.handle,
start_entity.handle,
end_entity.handle,
self._wp,
)
if register:
if self._wp_normal_handle is None:
self._wp_normal_handle = self._make_arc_normal_3d()
nm: Any = self._wp_normal_handle
assert nm is not None # _make_arc_normal_3d always returns a handle
arc_handle = self._solver.add_arc(
nm,
center_entity.handle,
start_entity.handle,
end_entity.handle,
self._wp,
)
else:
arc_handle = None
entity = OCCSketchEntity(
entity_id=entity_id,
@@ -418,6 +432,9 @@ class OCCSketch(SketchInterface):
"end": end_point.id,
"radius": radius,
"sweep": sweep,
# Tracked-only arcs (register=False) have no solver entity and
# are skipped by ``_rebuild_solver``.
"in_solver": register,
# ``original_sweep`` captures the angular span the user drew
# the arc with. When the host geometry (e.g. a rectangle
# the arc is attached to) resizes, ``_sync_solved_positions``
@@ -587,6 +604,70 @@ class OCCSketch(SketchInterface):
pass
return all_points, all_lines
def _import_external_curves(
self,
circles: List[Dict[str, Any]],
arcs: List[Dict[str, Any]],
) -> None:
"""Import projected circle/arc dicts as external underlay entities.
Mirrors the widget's initial underlay import: a circle becomes a
fixed external centre point plus a tracked circle entity; an arc
becomes a fixed external centre point, endpoint entities (shared
with the polyline corners already imported, so the arc connects
to the adjacent lines), and an arc entity. Must be called after
``add_external_polylines`` and before ``_rebuild_solver`` — the
rebuild re-registers arcs in the fresh solver and re-fixes every
external point.
"""
for c in circles:
try:
center_uv = (float(c["center"][0]), float(c["center"][1]))
center_pt = self.add_external_point(center_uv[0], center_uv[1])
self.add_circle(center_pt, float(c["radius"]))
except Exception as exc:
logger.debug("external circle import failed: %s", exc)
# Generous tolerance: arc endpoints come from a fresh projection of
# the face and must land on the existing corner points despite float
# drift (same rule as the widget's initial import).
merge_tol = 1e-3
def find_pt(u: float, v: float) -> Optional[OCCSketchEntity]:
best: Optional[OCCSketchEntity] = None
best_d = merge_tol
for eid in self._external_entity_ids:
ent = self._entities.get(eid)
if ent is None or ent.entity_type != "point" or ent.geometry is None:
continue
d = math.hypot(ent.geometry[0] - u, ent.geometry[1] - v)
if d <= best_d:
best_d = d
best = ent
return best
for a in arcs:
try:
center_uv = (float(a["center"][0]), float(a["center"][1]))
start_uv = (float(a["start"][0]), float(a["start"][1]))
end_uv = (float(a["end"][0]), float(a["end"][1]))
center_pt = self.add_external_point(center_uv[0], center_uv[1])
start_pt = find_pt(start_uv[0], start_uv[1])
if start_pt is None:
start_pt = self.add_external_point(start_uv[0], start_uv[1])
end_pt = find_pt(end_uv[0], end_uv[1])
if end_pt is None:
end_pt = self.add_external_point(end_uv[0], end_uv[1])
# register=False: all three reference points are external
# (dragged/fixed) — registering the arc on top would
# over-constrain the solver (inconsistent).
self.add_arc(
center_pt, float(a["radius"]), start_pt, end_pt,
sweep=None, register=False,
)
except Exception as exc:
logger.debug("external arc import failed: %s", exc)
def _drop_external_entities(self) -> set:
"""Remove external entities from local tracking + prune their constraints.
@@ -606,6 +687,21 @@ class OCCSketch(SketchInterface):
self._lines.pop(eid, None)
self._circles.pop(eid, None)
self._arcs.pop(eid, None)
# Underlay circle/arc entities are tracked (not tagged external):
# drop any whose reference points just went away, or a re-import
# would pile stale duplicates on top of the fresh ones.
for cid, (cent_id, _radius) in list(self._circles.items()):
if cent_id in removed:
del self._circles[cid]
self._entities.pop(cid, None)
for aid, arc_data in list(self._arcs.items()):
if (
arc_data.get("center") in removed
or arc_data.get("start") in removed
or arc_data.get("end") in removed
):
del self._arcs[aid]
self._entities.pop(aid, None)
# Also clean lines that USE an external point as an endpoint but
# somehow aren't themselves external (defensive — shouldn't happen
# via the public API, but rebuild_solver needs a clean graph).
@@ -637,7 +733,7 @@ class OCCSketch(SketchInterface):
"""Return the set of external (underlay) entity ids currently in the sketch."""
return set(self._external_entity_ids)
def update_external_entities(self, polylines: List[List[Tuple[float, float]]]) -> bool:
def update_external_entities(self, polylines: List[Any]) -> bool:
"""Re-project external (underlay) entities from updated source geometry.
Called when the 3D body the underlay was projected from has been
@@ -646,6 +742,11 @@ class OCCSketch(SketchInterface):
body so user geometry constrained to it propagates through the
solver.
*polylines* is the raw output of ``_project_face_to_uv``: a mixed
list of plain polylines (lists of ``(u, v)``) and curve dicts
(``{"type": "circle", ...}`` / ``{"type": "arc", ...}``) for
circular/arc face edges.
Two paths:
* **In-place update** (same topology): when the new projection has
@@ -663,6 +764,22 @@ class OCCSketch(SketchInterface):
Returns True when the underlay was updated and solved OK.
"""
# Flatten the new projection into unique corner positions + segments.
# The projection mixes plain polylines with curve dicts; polylines
# carry the corner/segment topology used below, curve dicts are
# only handled by the rebuild + rebind path.
polys: List[List[Tuple[float, float]]] = []
circles: List[Dict[str, Any]] = []
arcs: List[Dict[str, Any]] = []
for entry in polylines:
if isinstance(entry, dict):
etype = entry.get("type")
if etype == "circle":
circles.append(entry)
elif etype == "arc":
arcs.append(entry)
elif isinstance(entry, (list, tuple)):
polys.append(list(entry))
tol = self._EXTERNAL_MERGE_TOL
new_pts: List[Tuple[float, float]] = []
@@ -674,7 +791,7 @@ class OCCSketch(SketchInterface):
return len(new_pts) - 1
new_segs: List[Tuple[int, int]] = []
for poly in polylines:
for poly in polys:
if len(poly) < 2:
continue
idx = [new_index(float(u), float(v)) for (u, v) in poly]
@@ -694,7 +811,13 @@ class OCCSketch(SketchInterface):
lid for lid in sorted(self._lines.keys()) if lid in self._external_entity_ids
]
same_topology = len(new_pts) == len(old_ext_points) and len(new_segs) == len(old_ext_lines)
# Curve entries force the rebuild path: the in-place branch only
# moves point/line entities and cannot represent a circle or arc.
same_topology = (
not (circles or arcs)
and len(new_pts) == len(old_ext_points)
and len(new_segs) == len(old_ext_lines)
)
if same_topology and old_ext_points:
# Greedy one-to-one nearest matching old point -> new position.
@@ -769,7 +892,8 @@ class OCCSketch(SketchInterface):
# points and (per the add_point guard) does not auto-anchor a user
# point — which would conflict with the re-bound coincidents below.
self._drop_external_entities()
self.add_external_polylines(polylines)
self.add_external_polylines(polys)
self._import_external_curves(circles, arcs)
self._rebuild_solver()
self._rebuild_labels()
@@ -1125,6 +1249,8 @@ class OCCSketch(SketchInterface):
assert nm is not None
for aid in sorted(self._arcs.keys()):
arc_data = self._arcs[aid]
if not arc_data.get("in_solver", True):
continue # tracked-only (underlay) arc — no solver state
c_id = arc_data.get("center")
s_id = arc_data.get("start")
e_id = arc_data.get("end")
@@ -1949,7 +2075,7 @@ class OCCSketch(SketchInterface):
_SNAP_TOL: float = 1e-2 # world-unit tolerance for snapping line endpoints in loop detection
def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
def _line_segments(self) -> List[Tuple[float, float, float, float, int]]:
"""Current line segments as world-coordinate tuples (uses solved positions).
Returns both straight line segments AND tessellated arc segments so
@@ -1957,10 +2083,13 @@ class OCCSketch(SketchInterface):
and external entities are excluded — they're reference geometry and
must not affect the sketch profile.
Each segment is ``(x1, y1, x2, y2, entity_id)`` — the entity_id lets
callers trace which sketch entity produced each segment.
Tessellation density: roughly 12 segments per π radians of arc sweep,
which gives smooth-looking closed loops for face detection.
"""
segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
segs: List[Tuple[float, float, float, float, int]] = []
# ── Straight line segments ──
for line_id, (sid, eid2) in self._lines.items():
@@ -1974,8 +2103,9 @@ class OCCSketch(SketchInterface):
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
segs.append(
(
(float(s_ent.geometry[0]), float(s_ent.geometry[1])),
(float(e_ent.geometry[0]), float(e_ent.geometry[1])),
float(s_ent.geometry[0]), float(s_ent.geometry[1]),
float(e_ent.geometry[0]), float(e_ent.geometry[1]),
line_id,
)
)
@@ -2010,7 +2140,7 @@ class OCCSketch(SketchInterface):
a2 = start_angle + t2 * sweep
p1 = (cx + radius * math.cos(a1), cy + radius * math.sin(a1))
p2 = (cx + radius * math.cos(a2), cy + radius * math.sin(a2))
segs.append((p1, p2))
segs.append((p1[0], p1[1], p2[0], p2[1], arc_id))
return segs
@@ -2018,8 +2148,8 @@ class OCCSketch(SketchInterface):
"""Detect closed loops: polygon cycles from connected lines + each circle.
Each loop is one of:
{"type": "polygon", "points": [(x,y), ...]} (closed, last == first)
{"type": "circle", "center": (x,y), "radius": r}
{"type": "polygon", "points": [(x,y), ...], "entity_ids": [int, ...]} (closed, last == first)
{"type": "circle", "center": (x,y), "radius": r, "entity_ids": [int]}
Line endpoint coordinates are snapped to ``_SNAP_TOL`` so a closed
rectangle's four corners join into one cycle even after solver floating
point jitter. Only connected components where every node has degree 2
@@ -2035,11 +2165,13 @@ class OCCSketch(SketchInterface):
reprs: Dict[Any, Tuple[float, float]] = {} # key -> averaged world pt
edges: List[Tuple[Any, Any]] = []
for p1, p2 in segs:
k1, k2 = key(p1), key(p2)
reprs.setdefault(k1, p1)
reprs.setdefault(k2, p2)
edge_eids: Dict[Tuple[Any, Any], int] = {} # (k1,k2) -> entity_id
for x1, y1, x2, y2, eid in segs:
k1, k2 = key((x1, y1)), key((x2, y2))
reprs.setdefault(k1, (x1, y1))
reprs.setdefault(k2, (x2, y2))
edges.append((k1, k2))
edge_eids[(k1, k2) if k1 < k2 else (k2, k1)] = eid
# Undirected adjacency.
adj: Dict[Any, List[Any]] = {}
@@ -2066,8 +2198,9 @@ class OCCSketch(SketchInterface):
if nb not in comp_seen:
stack.append(nb)
if all(len(adj[n]) == 2 for n in comp) and len(comp) >= 3:
# Order the cycle by following each node's neighbor not yet visited.
# Order the cycle by following each node's neighbour not yet visited.
ordered: List[Any] = []
eids: List[int] = []
cur = comp[0]
prev = None
for _ in range(len(comp)):
@@ -2075,16 +2208,26 @@ class OCCSketch(SketchInterface):
nbrs = [nb for nb in adj[cur] if nb != prev]
if not nbrs:
break
ekey = (cur, nbrs[0]) if cur < nbrs[0] else (nbrs[0], cur)
if ekey in edge_eids:
eids.append(edge_eids[ekey])
prev = cur
cur = nbrs[0]
if len(ordered) == len(comp):
pts = [reprs[k] for k in ordered]
pts.append(pts[0])
loops.append({"type": "polygon", "points": pts})
loops.append(
{"type": "polygon", "points": pts, "entity_ids": sorted(set(eids))}
)
seen |= comp_seen
# Circles are closed loops of their own.
for cid, (center_id, r) in self._circles.items():
circle_ent = self._entities.get(cid)
if circle_ent is not None and circle_ent.is_construction:
continue
if cid in self._external_entity_ids:
continue
c_ent = self._entities.get(center_id)
if c_ent and c_ent.geometry and r > 0:
loops.append(
@@ -2092,6 +2235,7 @@ class OCCSketch(SketchInterface):
"type": "circle",
"center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])),
"radius": float(r),
"entity_ids": [cid],
}
)
return loops
@@ -2242,8 +2386,7 @@ class OCCSketch(SketchInterface):
that is the rectangle minus the circle — exactly the
"shape within a shape = closed without inner" behavior. A shape nested
inside a hole (depth 2) becomes its own solid face again.
Returns a list of ``{"outer": loop, "holes": [loop, ...], "depth": int}``.
Returns a list of ``{"outer": loop, "holes": [loop, ...], "depth": int, "entity_ids": [int, ...]}``.
"""
loops = self.get_closed_loops()
if not loops:
@@ -2267,7 +2410,11 @@ class OCCSketch(SketchInterface):
# directly nested: depth one greater, and outer contains inner.
if depths[j] == depths[i] + 1 and OCCSketch._loop_contains(inner, outer):
holes.append(inner)
faces.append({"outer": outer, "holes": holes, "depth": depths[i]})
# Face entity_ids = union of outer + hole loop entity_ids.
eids = set(outer.get("entity_ids", []))
for h in holes:
eids.update(h.get("entity_ids", []))
faces.append({"outer": outer, "holes": holes, "depth": depths[i], "entity_ids": sorted(eids)})
return faces
def find_face_at(self, x: float, y: float) -> Optional[Dict[str, Any]]:
@@ -2704,6 +2851,9 @@ class OCCSketch(SketchInterface):
"end": tuple(e_ent.geometry),
"radius": radius_val,
"sweep": sweep_val,
# Tracked-only underlay arcs must not be
# re-registered with the solver on load.
"in_solver": bool(arc_data.get("in_solver", True)),
}
entities_payload.append(
{
@@ -2920,12 +3070,30 @@ class OCCSketch(SketchInterface):
if c_id is None or s_id is None or e_id is None:
logger.warning("Skipping arc %s during load: endpoints not found", eid)
return
in_solver = bool(geom.get("in_solver", True))
if "in_solver" not in geom:
# Old files: an arc whose three reference points are
# all external is an underlay arc — re-registering it
# over-constrains the solver (inconsistent).
c_ent_r = entities_by_id.get(c_id)
s_ent_r = entities_by_id.get(s_id)
e_ent_r = entities_by_id.get(e_id)
if (
c_ent_r is not None
and s_ent_r is not None
and e_ent_r is not None
and getattr(c_ent_r, "is_external", False)
and getattr(s_ent_r, "is_external", False)
and getattr(e_ent_r, "is_external", False)
):
in_solver = False
ent = self.add_arc(
entities_by_id[c_id],
radius,
entities_by_id[s_id],
entities_by_id[e_id],
sweep=sweep,
register=in_solver,
)
else:
logger.warning("Unknown sketch entity type %r; skipping", etype)
+245 -8
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
@@ -226,6 +229,8 @@ def _feature_to_dict(feat: Feature) -> Dict[str, Any]:
"tangent_propagation": bool(feat.tangent_propagation),
"scope": feat.scope,
"edge_refs": list(feat.edge_refs),
"face_keys": json.dumps(feat.face_keys) if feat.face_keys is not None else None,
"face_keys_sketch_id": feat.face_keys_sketch_id,
"pattern_type": feat.pattern_type,
"count": feat.count,
"spacing": feat.spacing,
@@ -256,6 +261,8 @@ def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Fea
tangent_propagation=bool(data.get("tangent_propagation", False)),
scope=data.get("scope", "selected"),
edge_refs=list(data.get("edge_refs") or []),
face_keys=None,
face_keys_sketch_id=data.get("face_keys_sketch_id"),
pattern_type=data.get("pattern_type", "linear"),
count=int(data.get("count") or 2),
spacing=_to_float(data.get("spacing"), 10.0),
@@ -264,6 +271,16 @@ def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Fea
mirror_plane_normal=tuple(float(v) for v in (data.get("mirror_plane_normal") or (1, 0, 0))),
keep_original=bool(data.get("keep_original", True)),
)
# Deserialize face_keys from JSON if present.
fk_raw = data.get("face_keys")
if fk_raw and isinstance(fk_raw, str):
try:
feat.face_keys = json.loads(fk_raw)
except (json.JSONDecodeError, TypeError):
pass
elif isinstance(fk_raw, list):
feat.face_keys = fk_raw
sid = data.get("sketch_id")
if sid and sid in sketches:
feat.sketch = sketches[sid]
@@ -478,6 +495,8 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
"offset": _to_float(conn.offset, 0.0),
"assembly_component_id": conn.assembly_component_id,
"source_obj_id": conn.source_obj_id,
"entity_type": conn.entity_type,
"normal_flip": bool(conn.normal_flip),
"partner_ac_id": conn.partner_ac_id,
"partner_connector_id": conn.partner_connector_id,
"is_grounded": bool(conn.is_grounded),
@@ -498,6 +517,8 @@ def _connector_from_dict(data: Dict[str, Any]) -> Connector:
offset=_to_float(data.get("offset"), 0.0),
assembly_component_id=data.get("assembly_component_id", ""),
source_obj_id=data.get("source_obj_id", ""),
entity_type=data.get("entity_type", ""),
normal_flip=bool(data.get("normal_flip", False)),
)
conn.partner_ac_id = data.get("partner_ac_id")
conn.partner_connector_id = data.get("partner_connector_id")
@@ -516,12 +537,22 @@ def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
"position": _coerce_listlike(ac.position),
"rotation": _coerce_listlike(ac.rotation),
"connectors": {cid: _connector_to_dict(c) for cid, c in ac.connectors.items()},
# Instance-local sketches + per-body modifier ops (kept apart from
# the shared component so save/load never mutates the base model).
"sketches": {sid: _sketch_to_dict(sk) for sid, sk in ac.sketches.items()},
"modifiers": {
bid: [_feature_to_dict(f) for f in mods]
for bid, mods in ac.modifiers.items()
},
"created_at": ac.created_at.isoformat() if ac.created_at else None,
"modified_at": ac.modified_at.isoformat() if ac.modified_at else None,
}
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
def _assembly_component_from_dict(
data: Dict[str, Any],
component: Optional[Component] = None,
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
) -> AssemblyComponent:
ac = AssemblyComponent(
id=_saved_id(data),
component_id=data.get("component_id", ""),
@@ -533,6 +564,36 @@ def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
ac.modified_at = _parse_iso(data.get("modified_at"))
for cid, c_data in (data.get("connectors") or {}).items():
ac.connectors[cid] = _connector_from_dict(c_data)
# Instance-local sketches first, so modifier sketch references can
# resolve against them (they live in component-local coordinates).
for sid, sk_data in (data.get("sketches") or {}).items():
try:
ac.sketches[sid] = _sketch_from_dict(sk_data, sketch_geometry_loader)
except Exception:
logger.warning("Skipping corrupt instance sketch %s", sid)
# Modifiers resolve sketch refs against the owning component's sketches
# first, then this instance's own sketches.
registry: Dict[str, Sketch] = {}
if component is not None:
registry.update(component.sketches)
registry.update(ac.sketches)
for bid, f_list in (data.get("modifiers") or {}).items():
kept: List[Feature] = []
for f_data in f_list or []:
try:
feat = _feature_from_dict(f_data, registry)
except Exception:
logger.warning("Skipping corrupt instance modifier on body %s", bid)
continue
# A sketch-based op whose sketch failed to load can never
# replay — dropping it keeps the rest of the chain usable.
if feat.operation in ("extrude", "cut", "union", "revolve") and feat.sketch is None:
continue
kept.append(feat)
if kept:
ac.modifiers[bid] = kept
return ac
@@ -570,8 +631,11 @@ def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
"modified_at": asm.modified_at.isoformat() if asm.modified_at else None,
}
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
def _assembly_from_dict(
data: Dict[str, Any],
components: Optional[Dict[str, Component]] = None,
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
) -> Assembly:
asm = Assembly(
id=_saved_id(data),
name=data.get("name", "Untitled Assembly"),
@@ -580,12 +644,133 @@ def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
asm.created_at = _parse_iso(data.get("created_at"))
asm.modified_at = _parse_iso(data.get("modified_at"))
for cid, ac_data in (data.get("components") or {}).items():
asm.components[cid] = _assembly_component_from_dict(ac_data)
comp = (components or {}).get(ac_data.get("component_id", ""))
asm.components[cid] = _assembly_component_from_dict(
ac_data, component=comp, sketch_geometry_loader=sketch_geometry_loader
)
for c_data in data.get("connections") or []:
asm.connections.append(_assembly_connection_from_dict(c_data))
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", ""),
# The renderer only supports the A4 ISO 5457 sheet; normalize
# legacy drawings (stored as A3) instead of surfacing stale sizes.
sheet_size="A4",
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,
@@ -598,6 +783,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 {},
@@ -758,6 +944,40 @@ def save_project(
sketch_files.append((arcname, step_bytes))
manifest["components"][comp_id]["sketches"][sketch_id]["geometry_ref"] = arcname
# Instance-local sketches (assembly components) get the same sidecar
# treatment as component sketches; the manifest nodes are patched in
# place under the assembly's component entry.
for asm_id, asm in project.assemblies.items():
for ac_id, ac in asm.components.items():
for sketch_id, sketch in ac.sketches.items():
node = manifest["assemblies"][asm_id]["components"][ac_id]["sketches"].get(sketch_id)
if node is None:
continue
occ = sketch.occ_sketch.to_dict() if sketch.occ_sketch is not None else None
meta = {
"id": sketch.id,
"name": sketch.name,
"workplane_origin": _coerce_listlike(sketch.workplane_origin),
"workplane_normal": _coerce_listlike(sketch.workplane_normal),
"workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
"is_solved": bool(sketch.is_solved),
"is_fully_constrained": bool(sketch.is_fully_constrained),
"occ_sketch": occ,
}
meta_arc = f"sketches/{sketch_id}/meta.json"
sketch_meta_files.append((meta_arc, _to_json(meta).encode("utf-8")))
node["occ_sketch"] = None
node["occ_sketch_ref"] = meta_arc
if sketch.geometry is None:
continue
step_bytes = _write_step_for_body(kernel, sketch.geometry)
if step_bytes is None:
continue
arcname = f"sketches/{sketch_id}/solved.step"
sketch_files.append((arcname, step_bytes))
node["geometry_ref"] = arcname
# Write the ZIP. Use a temp file + rename so a partial write can't
# clobber an existing good file.
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".fluency")
@@ -823,8 +1043,9 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
# If a sketch's occ_sketch is referenced as a separate file, read
# it in now and patch the manifest so _sketch_from_dict sees it.
for comp_id, comp_data in (manifest.get("components") or {}).items():
for sk_id, sk_data in (comp_data.get("sketches") or {}).items():
# Applies to both component sketches and instance-local sketches.
def _patch_sketch_sidecars(sketches_dict: Dict[str, Any]) -> None:
for sk_id, sk_data in (sketches_dict or {}).items():
ref = sk_data.get("occ_sketch_ref")
if not ref:
continue
@@ -851,6 +1072,12 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
if k in meta:
sk_data[k] = meta[k]
for comp_id, comp_data in (manifest.get("components") or {}).items():
_patch_sketch_sidecars(comp_data.get("sketches"))
for aid, a_data in (manifest.get("assemblies") or {}).items():
for ac_id, ac_data in (a_data.get("components") or {}).items():
_patch_sketch_sidecars(ac_data.get("sketches"))
project = Project(
name=manifest.get("name", "Untitled Project"),
description=manifest.get("description", ""),
@@ -870,7 +1097,17 @@ 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)
project.assemblies[aid] = _assembly_from_dict(
a_data,
components=project.components,
sketch_geometry_loader=sketch_geometry_loader,
)
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
+199 -26
View File
@@ -275,6 +275,14 @@ class Feature:
scope: str = "selected"
edge_refs: List[str] = field(default_factory=list)
# FaceKey references for fillet/chamfer: stable face classification
# that survives sketch dimension changes. ``face_keys`` is a list of
# (FaceKey, FaceKey) pairs — one pair per user-picked face;
# ``face_keys_sketch_id`` tracks which sketch produced the body these
# faces belong to.
face_keys: Optional[List[Tuple[Dict[str, Any], Dict[str, Any]]]] = None
face_keys_sketch_id: Optional[str] = None
# "array" / "pattern" features only: repeat the running solid.
# ``pattern_type`` is "linear" or "circular"; ``count`` is the total
# number of items including the original. Linear arrays use
@@ -479,6 +487,16 @@ class Connector:
assembly_component_id: str = ""
# Which body/face this connector was placed on (renderer obj_id).
source_obj_id: str = ""
# Entity class the connector was picked on ("planar_face",
# "cylindrical_face", "edge", "vertex"). Used to re-locate the
# connector on rebuilt geometry: only features of the same class are
# considered, so a hole connector can never jump onto a flat face.
# Empty for legacy files (all classes are then searched).
entity_type: str = ""
# Flip chosen in the placement dialog (bolt enters from the opposite
# side). Re-applied when a mated pair is re-solved so the original
# mate pose is reproduced exactly.
normal_flip: bool = False
# --- Rigid-group pairing (set when two connectors are mated) ---
# The id of the partner AssemblyComponent this connector is mated to.
@@ -498,6 +516,16 @@ class Connector:
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def __post_init__(self) -> None:
# Legacy files predate the entity_type field: recover it from the
# auto-generated connector name ("Conn cylindrical_face anchor") so
# relocation can restrict its search to the same feature class.
if not self.entity_type:
for t in ("cylindrical_face", "planar_face", "edge", "vertex"):
if self.name in (f"Conn {t} anchor", f"Conn {t} mover"):
self.entity_type = t
break
@dataclass
class AssemblyComponent:
@@ -521,6 +549,20 @@ class AssemblyComponent:
# Connectors defined on this component instance.
connectors: Dict[str, Connector] = field(default_factory=dict)
# Instance-local (per-instantiation) state. Kept separate from the
# shared component so per-instance work never leaks back into the base
# model. ``sketches`` are instance-local sketches stored in
# component-local coordinates (so they stay valid when the instance is
# moved / rotated); ``modifiers`` maps body_id to an ordered list of
# Feature ops applied ON TOP of the live component body feature history
# when the instance geometry is rebuilt.
sketches: Dict[str, Sketch] = field(default_factory=dict)
modifiers: Dict[str, List[Feature]] = field(default_factory=dict)
# Runtime-only cache of rebuilt instance geometry (body_id -> geometry).
# Never serialized; invalidated on component updates and modifier edits.
geom_cache: Dict[str, Any] = field(default_factory=dict, repr=False)
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
@@ -531,6 +573,8 @@ class AssemblyComponent:
x_dir: Tuple[float, float, float],
source_obj_id: str = "",
name: Optional[str] = None,
entity_type: str = "",
normal_flip: bool = False,
) -> Connector:
"""Add a connector to this component instance."""
conn = Connector(
@@ -540,6 +584,8 @@ class AssemblyComponent:
x_dir=x_dir,
assembly_component_id=self.id,
source_obj_id=source_obj_id,
entity_type=entity_type,
normal_flip=normal_flip,
)
self.connectors[conn.id] = conn
self.modified_at = datetime.now()
@@ -553,6 +599,45 @@ class AssemblyComponent:
return True
return False
def add_instance_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
"""Add an instance-local sketch (component-local coordinates)."""
if sketch is None:
sketch = Sketch(name=f"Instance Sketch {len(self.sketches) + 1}")
self.sketches[sketch.id] = sketch
self.modified_at = datetime.now()
return sketch
def remove_instance_sketch(self, sketch_id: str) -> bool:
"""Remove an instance sketch and every modifier that references it."""
if sketch_id not in self.sketches:
return False
del self.sketches[sketch_id]
for body_id in list(self.modifiers.keys()):
kept = [
f for f in self.modifiers[body_id]
if not (f.sketch is not None and f.sketch.id == sketch_id)
]
if kept:
self.modifiers[body_id] = kept
else:
del self.modifiers[body_id]
self.geom_cache.pop(body_id, None)
self.modified_at = datetime.now()
return True
def add_modifier(self, body_id: str, feat: Feature) -> Feature:
"""Append a modifier op to *body_id*'s instance-local history."""
self.modifiers.setdefault(body_id, []).append(feat)
self.geom_cache.pop(body_id, None)
self.modified_at = datetime.now()
return feat
def invalidate_geom_cache(self, body_id: Optional[str] = None) -> None:
"""Drop cached rebuilt instance geometry (one body, or all)."""
if body_id is None:
self.geom_cache.clear()
else:
self.geom_cache.pop(body_id, None)
@dataclass
class AssemblyConnection:
@@ -716,6 +801,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)
@@ -792,36 +881,21 @@ class Project:
"""Look up a component by id across all project components."""
return self.components.get(component_id)
def add_component(self, component: Optional[Component] = None) -> Component:
"""Add a component to the project."""
if component is None:
component = Component(name=f"Component {len(self.components) + 1}")
self.components[component.id] = component
if self.active_component is None:
self.active_component = component.id
self.modified_at = datetime.now()
return component
# ── Drawing helpers ──
def remove_component(self, component_id: str) -> bool:
"""Remove a component from the project."""
if component_id in self.components:
del self.components[component_id]
if self.active_component == component_id:
self.active_component = next(iter(self.components.keys()), None)
self.modified_at = datetime.now()
return True
return False
def get_active_component(self) -> Optional[Component]:
"""Get the currently active component."""
if self.active_component and self.active_component in self.components:
return self.components[self.active_component]
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 set_active_component(self, component_id: Optional[str]) -> None:
"""Set the active component."""
self.active_component = component_id
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."""
@@ -890,3 +964,102 @@ class Project:
for comp in self.components.values():
sketches.extend(comp.sketches.values())
return sketches
def compute_source_fingerprint(
self, source_kind: str, source_id: str
) -> str:
"""Compute a simple fingerprint for a source reference."""
import hashlib
data = f"{source_kind}:{source_id}"
return hashlib.sha256(data.encode()).hexdigest()[:16]
# ── Technical Drawing models ───────────────────────────────────────────────
@dataclass
class DrawingView:
"""One projected view in a technical drawing."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
kind: str = "front" # front, back, top, bottom, right, left, isometric, custom
name: Optional[str] = None # human-readable label; defaults from kind
# View direction and up vector in world coords (for custom views).
# Ignored when kind is one of the standard presets.
direction: Optional[Tuple[float, float, float]] = None
up_vector: Optional[Tuple[float, float, float]] = None
show_hidden_lines: bool = False
show_centerlines: bool = False
scale: float = 1.0
# Sheet position (mm from sheet origin) — set by layout engine.
sheet_origin: Tuple[float, float] = (0.0, 0.0)
@dataclass
class DrawingAnnotation:
"""One annotation (dimension or note) on a technical drawing."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
kind: str = "dimension" # dimension, note, tolerance, surface_finish, weld_symbol
text: Optional[str] = None
visible: bool = True
# References to DrawingCandidate keys this annotation is bound to.
references: List[str] = field(default_factory=list)
# Sheet position (mm). For dimensions, anchor point; for notes, placement.
sheet_position: Tuple[float, float] = (0.0, 0.0)
# 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:
"""A complete technical drawing definition."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Drawing"
# Source geometry reference.
source_kind: str = "component" # component, assembly
source_id: str = ""
views: List[DrawingView] = field(default_factory=list)
annotations: List[DrawingAnnotation] = field(default_factory=list)
# Title block metadata.
title: str = ""
part_number: str = ""
material: str = ""
revision: str = ""
notes: str = ""
# Sheet size (A0..A4 or custom mm). Default A4 (ISO 5457 template).
sheet_size: str = "A4"
units: str = "mm" # mm, in
# 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)
modified_at: datetime = field(default_factory=datetime.now)
+209 -4
View File
@@ -157,17 +157,24 @@ class OCCRenderer(Renderer):
self._highlight_ais: Any = None
# Overlays for the fillet tool's two picked faces (one AIS per face).
self._faces_highlight_ais: List[Any] = []
# Hot-pink translucent overlay for operation-history highlighting.
self._op_highlight_ais: Any = None
# Temporary transparent preview AIS for the live extrude/cut dialog.
self._preview_ais: Any = None
# Smart entity picker gizmo objects (snap markers, axis lines, rings).
# Keyed by a synthetic id; values are raw AIS_InteractiveObject.
self._gizmo_objects: Dict[str, Any] = {}
# Persistent connector gizmo objects (first pick) not cleared by hover.
self._persistent_gizmo_objects: Dict[str, Any] = {}
# World-anchored sketch reference gizmo (a triad at the sketch
# midpoint): part kind ("center" / "axis_x" / … / "plane_xy" …) →
# dict {"ais": [AIS…], "color": rgb, "pick": descriptor}.
self._sketch_gizmo_parts: Dict[str, Any] = {}
# Part kind currently highlighted on hover (for restore-on-leave).
self._sketch_gizmo_highlighted: Optional[str] = None
# Cache for shape classification to avoid re-classifying same OCC sub-shapes
# during repeated probe/hover calls. Key = (id(shape), owner_obj_id).
self._classify_cache: dict = {}
def initialize(self, parent_widget: Any) -> bool:
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
@@ -333,10 +340,16 @@ class OCCRenderer(Renderer):
shape: Any,
color: Optional[Tuple[float, float, float]] = None,
name: Optional[str] = None,
auto_fit: bool = True,
) -> str:
"""Display an OCC ``TopoDS_Shape`` directly via ``AIS_Shape``.
Returns a unique object ID (or *name* if provided).
With *auto_fit* (default), the first object added to an empty
scene triggers a camera fit. Pass ``auto_fit=False`` when
rebuilding a scene under explicit camera control (e.g. the
assembly view), so the rebuild doesn't move the camera.
"""
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
@@ -396,7 +409,7 @@ class OCCRenderer(Renderer):
self._objects[obj_id] = robj
# Fit camera on first shape added.
if len(self._objects) == 1:
if auto_fit and len(self._objects) == 1:
try:
self.fit_camera()
except Exception:
@@ -543,6 +556,16 @@ class OCCRenderer(Renderer):
return False
return True
def set_color(
self, obj_id: str, color: Tuple[float, float, float]
) -> bool:
"""Set the colour of an object by ID. Returns True on success."""
obj = self._objects.get(obj_id)
if obj is None:
return False
self.set_object_color(obj, color)
return True
# ─── Live preview (extrude/cut preview) ──────────────────────────────
_PREVIEW_ID = "__extrude_preview__"
@@ -608,6 +631,7 @@ class OCCRenderer(Renderer):
return
self.clear_preview()
self.clear_face_highlight()
self.clear_operation_highlight()
self.clear_entity_gizmo()
# The sketch reference gizmo is scene-anchored — drop it with the rest.
self.remove_sketch_gizmo()
@@ -1385,6 +1409,60 @@ class OCCRenderer(Renderer):
logger.debug("clear_faces_highlight remove failed", exc_info=True)
self._faces_highlight_ais = []
# ─── Operation history highlight ─────────────────────────────────────
def highlight_operation_shape(
self, shape: Any, color: Tuple[float, float, float] = (1.0, 0.08, 0.58)
) -> None:
"""Overlay a translucent hot-pink *shape* on the 3D view.
Used to show the intermediate geometry at a selected operation in
the feature-history list. The overlay is an independent
``AIS_Shape`` with polygon offset so it draws on top of the
coincident body surface without z-fighting. Replaces any previous
operation highlight.
"""
if self._context is None:
return
self.clear_operation_highlight()
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
ais = AIS_Shape(shape)
try:
ais.SetMaterial(self._default_material())
except Exception:
logger.debug("op highlight material set failed", exc_info=True)
ais.SetColor(Quantity_Color(*color, Quantity_TOC_RGB))
ais.SetDisplayMode(1) # shaded
try:
ais.SetTransparency(0.2)
except Exception:
logger.debug("op highlight transparency set failed", exc_info=True)
try:
ais.SetSelectability(0)
except Exception:
logger.debug("op highlight selectability set failed", exc_info=True)
try:
ais.SetPolygonOffsets(3, 1.0, -0.5)
except Exception:
logger.debug("op highlight polygon offset failed", exc_info=True)
self._context.Display(ais, False)
self._op_highlight_ais = ais
if self._view is not None:
self._view.Redraw()
def clear_operation_highlight(self) -> None:
"""Remove the operation-history overlay, if any."""
if self._context is None or self._op_highlight_ais is None:
return
try:
self._context.Remove(self._op_highlight_ais, True)
except Exception:
logger.debug("clear_operation_highlight remove failed", exc_info=True)
self._op_highlight_ais = None
# ─── General entity picking (for assembly connectors / snaps) ───────────
def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]:
@@ -1447,6 +1525,10 @@ class OCCRenderer(Renderer):
"""
if shape is None:
return []
# Cache lookup
cache_key = (id(shape), owner_obj_id)
if cache_key in self._classify_cache:
return self._classify_cache[cache_key]
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
@@ -1508,7 +1590,7 @@ class OCCRenderer(Renderer):
# x_dir: viewport-aligned so connector gizmo matches screen.
x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
return [
res = [
{
"type": "planar_face",
"position": origin,
@@ -1518,6 +1600,8 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id,
}
]
self._classify_cache[cache_key] = res
return res
elif stype == GeomAbs_Cylinder:
cyl = adaptor.Cylinder()
@@ -1625,6 +1709,7 @@ class OCCRenderer(Renderer):
"radius": radius,
}
)
self._classify_cache[cache_key] = results
return results
# Try edge.
@@ -1668,7 +1753,7 @@ class OCCRenderer(Renderer):
x = x / xlen
x_dir = (float(x[0]), float(x[1]), float(x[2]))
return [
res = [
{
"type": "edge",
"position": position,
@@ -1678,6 +1763,8 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id,
}
]
self._classify_cache[cache_key] = res
return res
# Try vertex.
vertex = None
@@ -1685,7 +1772,7 @@ class OCCRenderer(Renderer):
vertex = TopoDS.Vertex_s(shape)
p = BRep_Tool.Pnt_s(vertex)
position = (p.X(), p.Y(), p.Z())
return [
res = [
{
"type": "vertex",
"position": position,
@@ -1695,9 +1782,12 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id,
}
]
self._classify_cache[cache_key] = res
return res
except Exception:
pass
self._classify_cache[cache_key] = []
return []
def probe_snap_candidates(
@@ -2400,6 +2490,114 @@ class OCCRenderer(Renderer):
if self._view is not None:
self._view.Update()
def show_persistent_entity_gizmo(
self,
entity_type: str,
position: Tuple[float, float, float],
normal: Optional[Tuple[float, float, float]] = None,
x_dir: Optional[Tuple[float, float, float]] = None,
radius: Optional[float] = None,
color: Tuple[float, float, float] = (0.0, 1.0, 0.0),
) -> None:
"""Display a persistent green gizmo for a confirmed first connector pick.
Unlike show_entity_gizmo, this does not clear the hover gizmo and stores
its AIS objects in _persistent_gizmo_objects so they survive hover updates.
"""
if self._context is None:
return
# Clear previous persistent gizmo
self.clear_persistent_entity_gizmo()
gizmo_scale = self._get_gizmo_scale(position)
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere
def _store(obj, key):
self._context.Display(obj, True)
self._persistent_gizmo_objects[key] = obj
def _make_sphere(p, c, size):
try:
s = BRepPrimAPI_MakeSphere(gp_Pnt(*p), size).Shape()
a = AIS_Shape(s)
a.SetColor(Quantity_Color(*c, Quantity_TOC_RGB))
a.SetDisplayMode(1)
_store(a, f"__pg_sphere_{id(a)}")
except Exception as exc:
logger.debug(f"persistent gizmo sphere failed: {exc}")
px, py, pz = position
_make_sphere(position, color, 5.6 * gizmo_scale)
axis_length = 30.0 * gizmo_scale
def _make_axis_line(origin, direction, length, line_color, label):
try:
dx, dy, dz = direction
norm = (dx*dx + dy*dy + dz*dz) ** 0.5
if norm < 1e-9:
return
ux, uy, uz = dx/norm, dy/norm, dz/norm
ex = origin[0] + ux * length
ey = origin[1] + uy * length
ez = origin[2] + uz * length
edge = BRepBuilderAPI_MakeEdge(gp_Pnt(*origin), gp_Pnt(ex, ey, ez)).Edge()
ais = AIS_Shape(edge)
ais.SetColor(Quantity_Color(*line_color, Quantity_TOC_RGB))
ais.SetDisplayMode(0)
_store(ais, f"__pg_{label}_{id(ais)}")
except Exception as exc:
logger.debug(f"persistent gizmo axis failed: {exc}")
if entity_type == "planar_face" and normal is not None:
_make_axis_line(position, normal, axis_length, (1.0, 1.0, 1.0), "normal")
if x_dir is not None:
_make_axis_line(position, x_dir, axis_length * 0.6, color, "xdir")
elif entity_type == "cylindrical_face" and normal is not None:
_make_axis_line(position, normal, axis_length * 1.4, (1.0, 1.0, 1.0), "axis_in")
_make_axis_line(position, (-normal[0], -normal[1], -normal[2]), axis_length * 0.4, (0.6, 0.6, 0.6), "axis_stub")
if x_dir is not None:
_make_axis_line(position, x_dir, radius or (axis_length * 0.5), color, "radial")
# ring
if radius is not None:
try:
center = gp_Pnt(px, py, pz)
ax2 = gp_Ax2(center, gp_Dir(*normal))
circ = gp_Circ(ax2, radius)
ring_edge = BRepBuilderAPI_MakeEdge(circ).Edge()
ring_ais = AIS_Shape(ring_edge)
ring_ais.SetColor(Quantity_Color(*color, Quantity_TOC_RGB))
ring_ais.SetDisplayMode(0)
_store(ring_ais, f"__pg_ring_{id(ring_ais)}")
except Exception as exc:
logger.debug(f"persistent gizmo ring failed: {exc}")
elif entity_type == "edge" and normal is not None:
_make_axis_line(position, normal, axis_length, color, "tangent")
elif entity_type == "vertex":
_make_axis_line(position, (1,0,0), axis_length * 0.5, (1.0,0.3,0.3), "cross_x")
_make_axis_line(position, (0,1,0), axis_length * 0.5, (0.3,1.0,0.3), "cross_y")
_make_axis_line(position, (0,0,1), axis_length * 0.5, (0.3,0.3,1.0), "cross_z")
if self._view is not None:
self._view.Update()
def clear_persistent_entity_gizmo(self) -> None:
"""Remove the persistent first-pick gizmo."""
if self._context is None:
return
for obj in list(self._persistent_gizmo_objects.values()):
try:
self._context.Erase(obj, True)
except Exception:
pass
self._persistent_gizmo_objects.clear()
if self._view is not None:
self._view.Update()
# ─── Selection mode control ───────────────────────────────────────────
#
# When connector gizmo mode is active, standard OCC face/edge/vertex
@@ -2636,6 +2834,13 @@ class OCCRenderer(Renderer):
(c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2
)
)
# Early exit if we already found a very close candidate — avoids unnecessary work.
if results:
best = results[0]
best_sp = best.get("screen", (x, y))
best_dist2 = (best_sp[0] - x) ** 2 + (best_sp[1] - y) ** 2
if best_dist2 <= 25: # within 5 px
return [best]
return results
def recognize_composite_features(
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
"""Tests for the A4 ISO 5457 sheet: layout regions, first-angle view
ordering, sheet furniture primitives, scale formatting, and the A4
normalisation of drawings loaded from .fluency files.
"""
import os
import sys
import unittest
# Allow running this file directly: ``python tests/test_iso5457_sheet.py``.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from fluency.models.data_model import DrawingView, TechnicalDrawing
from fluency.technical_drawing import (
_SHEET_HEIGHT_MM,
_SHEET_WIDTH_MM,
_format_drawing_scale,
_layout_views_on_sheet,
_sheet_frame_primitives,
)
def _view(kind):
return DrawingView(kind=kind, name=kind)
class TestSheetSize(unittest.TestCase):
"""The workbench renders a single A4 landscape sheet."""
def test_a4_dimensions(self):
self.assertEqual((_SHEET_WIDTH_MM, _SHEET_HEIGHT_MM), (297.0, 210.0))
def test_default_drawing_is_a4(self):
self.assertEqual(TechnicalDrawing().sheet_size, "A4")
class TestScaleFormatting(unittest.TestCase):
"""Title-block scale field formatting (``N : 1`` / ``1 : N``)."""
def test_none_and_one(self):
self.assertEqual(_format_drawing_scale(None), "1 : 1")
self.assertEqual(_format_drawing_scale(0.0), "1 : 1")
self.assertEqual(_format_drawing_scale(1.0), "1 : 1")
def test_enlarged(self):
self.assertEqual(_format_drawing_scale(2.0), "2 : 1")
self.assertEqual(_format_drawing_scale(1.5), "1.5 : 1")
def test_reduced(self):
self.assertEqual(_format_drawing_scale(0.5), "1 : 2")
self.assertEqual(_format_drawing_scale(0.33333), "1 : 3")
class TestFirstAngleLayout(unittest.TestCase):
"""View slots stay inside the ISO 5457 drawing-space frame, clear the
title-block reserve zone, and follow first-angle ordering (view from
the right left of front, view from the left right of front, top view
below front)."""
def _slots(self, kinds):
bboxes = {k: (0.0, 0.0, 40.0, 30.0) for k in kinds}
slots, scale, _rots = _layout_views_on_sheet(
[_view(k) for k in kinds], bboxes
)
return slots, scale
def test_slots_inside_frame(self):
slots, scale = self._slots(
("front", "top", "right", "left", "isometric")
)
self.assertIsNotNone(scale)
self.assertGreater(scale, 0)
for vid, (x, y, w, h) in slots.items():
# Drawing-space frame: 20..287 x 10..200.
self.assertGreaterEqual(x, 19.9, vid)
self.assertGreaterEqual(y, 9.9, vid)
self.assertLessEqual(x + w, 287.1, vid)
self.assertLessEqual(y + h, 200.1, vid)
# Title-block reserve zone: x >= 102, y <= 64.
self.assertTrue(
x + w <= 102.01 or y + h <= 0.01 or y >= 63.99,
(vid, (x, y, w, h)),
)
def test_first_angle_ordering(self):
slots, _ = self._slots(("front", "top", "right", "left"))
xf, wf = slots["front"][0], slots["front"][2]
yf = slots["front"][1]
# View from the right sits left of the front view.
self.assertLessEqual(
slots["right"][0] + slots["right"][2], xf + 0.01
)
# View from the left sits right of the front view.
self.assertGreaterEqual(slots["left"][0], xf + wf + 0.01)
# Top view sits below the front view.
self.assertLessEqual(slots["top"][1] + slots["top"][3], yf + 0.01)
def test_single_view_centered_upright(self):
slots, scale, rots = _layout_views_on_sheet(
[_view("front")], {"front": (0.0, 0.0, 40.0, 30.0)}
)
self.assertIsNotNone(scale)
self.assertEqual(rots.get("front", 0.0), 0.0)
self.assertGreater(scale, 0)
x, y, w, h = slots["front"]
self.assertGreaterEqual(x, 19.9)
self.assertGreaterEqual(y, 9.9)
class TestSheetFurniture(unittest.TestCase):
"""ISO 5457 furniture primitives mirror the reference SVG template."""
def _prims(self):
d = TechnicalDrawing(
title="Test Bracket",
part_number="PN-42",
material="Alu 6082",
revision="B",
)
return _sheet_frame_primitives(d, "1 : 2")
def test_all_geometry_inside_sheet(self):
for p in self._prims():
for (x, y) in p.points:
self.assertGreaterEqual(x, -0.01)
self.assertLessEqual(x, 297.01)
self.assertGreaterEqual(y, -0.01)
self.assertLessEqual(y, 210.01)
def test_drawing_space_frame(self):
# Frame rect 20,10 267x190: four edges present as line prims.
lines = [
(tuple(p.points[0]), tuple(p.points[1]))
for p in self._prims()
if p.kind == "line" and p.style == "frame"
]
for a, b in (
((20.0, 10.0), (287.0, 10.0)),
((287.0, 10.0), (287.0, 200.0)),
((287.0, 200.0), (20.0, 200.0)),
((20.0, 200.0), (20.0, 10.0)),
):
self.assertIn((a, b), lines)
def test_grid_reference_labels(self):
texts = [p.text for p in self._prims() if p.kind == "text" and p.text]
for t in ("1", "2", "3", "4", "5", "6", "A", "B", "C", "D", "A4"):
self.assertIn(t, texts)
def test_title_block_fields(self):
texts = [p.text for p in self._prims() if p.kind == "text" and p.text]
for expect in (
"Owner:",
"Drawing number:",
"Revision:",
"Issue date:",
"Sheet:",
"Language:",
"Title:",
"Approved by:",
"Created by:",
"Document type:",
"Part Material:",
"General tolerances:",
"Scale:",
"Test Bracket",
"PN-42",
"Alu 6082",
"B",
"1 : 2",
"EN",
"1 / 1",
"ISO 2768-m",
"Component Drawing",
):
self.assertIn(expect, texts)
def test_first_angle_projection_symbol(self):
# Two concentric circles at the symbol centre (273, 58) in sheet
# coords (SVG y-down 152 → 210 - 152 = 58).
circles = [
p
for p in self._prims()
if p.kind == "circle" and p.center == (273.0, 58.0)
]
self.assertEqual(len(circles), 2)
self.assertEqual(
sorted(c.radius for c in circles if c.radius), [2.5, 5.0]
)
class TestSheetSizeNormalisation(unittest.TestCase):
"""Drawings stored as A3 in .fluency files load as A4 — the renderer
only supports the A4 ISO 5457 sheet."""
def test_legacy_a3_loads_as_a4(self):
from fluency.io.project_io import _technical_drawing_from_dict
d = _technical_drawing_from_dict(
{
"id": "x",
"source_kind": "component",
"source_id": "c",
"sheet_size": "A3",
}
)
self.assertEqual(d.sheet_size, "A4")
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+477 -70
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import logging
import os
import warnings
from typing import Optional
import numpy as np
@@ -48,6 +49,24 @@ from fluency.rendering.render_backend import (
logger = logging.getLogger(__name__)
def _unlink_quiet(path: Optional[str]) -> None:
"""Unlink *path*, ignoring missing files and OS errors."""
if not path:
return
try:
if os.path.exists(path):
os.unlink(path)
except OSError:
pass
# Threads still running after a cancel could not finish in time. Kept
# referenced (never terminate()'d, reparented from their widget) at
# module level so they can safely outlive the widget/window that spawned
# them — destroying a still-running QThread is a Qt fatal error.
_RETIRED_THREADS: list = []
# ── Background render thread ────────────────────────────────────────
@@ -153,6 +172,77 @@ class _AssemblyRenderThread(QThread):
self.error.emit(str(e))
class _MeshThread(QThread):
"""Tessellates OCC shapes to PLY files off the GUI thread.
``BRepMesh_IncrementalMesh`` is a single blocking C++ call, so the
cancel flag is checked between parts (assemblies) and at completion;
a cancelled thread discards its result instead of emitting it, so it
cannot clobber the UI.
"""
mesh_ready = Signal(str) # single-shape: mesh path
assembly_ready = Signal(list, object, object) # parts, bounds, first_bounds
error = Signal(str)
def __init__(self, shapes, is_assembly: bool, parent=None):
super().__init__(parent)
# Single: (TopoDS_Shape,) | Assembly: [(TopoDS_Shape, mat_name), ...]
self._shapes = shapes
self._is_assembly = is_assembly
self._cancelled = False
def cancel(self):
self._cancelled = True
def run(self):
try:
if self._is_assembly:
self._run_assembly()
else:
mesh_path = occ_shape_to_ply(
self._shapes[0], linear_deflection=0.1, angular_deflection=0.15
)
if not self._cancelled:
self.mesh_ready.emit(mesh_path)
except Exception as e:
if not self._cancelled:
self.error.emit(str(e))
def _run_assembly(self):
from fluency.rendering.material_presets import get_preset
parts: list = []
all_mins: list = []
all_maxs: list = []
first_bounds = None
for shape, mat_name in self._shapes:
if self._cancelled:
return
try:
mesh_path = occ_shape_to_ply(
shape, linear_deflection=0.1, angular_deflection=0.15
)
material = get_preset(mat_name) if mat_name else get_preset("Brushed Steel")
parts.append((mesh_path, material))
bounds = occ_shape_bounds(shape)
all_mins.append(list(bounds[0]))
all_maxs.append(list(bounds[1]))
if first_bounds is None:
first_bounds = bounds
except Exception as e:
logger.warning(f"Failed to tessellate assembly part: {e}")
if self._cancelled:
return
combined = None
if all_mins and all_maxs:
combined = (
[min(a[i] for a in all_mins) for i in range(3)],
[max(a[i] for a in all_maxs) for i in range(3)],
)
self.assembly_ready.emit(parts, combined, first_bounds)
# ── Render window ───────────────────────────────────────────────────
@@ -777,17 +867,63 @@ class RenderWindow(QMainWindow):
self._status_badge.setStyleSheet("color: #a6e3a1; font-size: 11px; padding: 2px;")
def _cancel_active_thread(self):
"""Cancel whichever thread is currently running."""
"""Cancel whichever thread is currently running.
Deliberately avoids ``QThread.terminate()``: it kills the thread
mid-instruction inside Mitsuba/OCC C++ code and corrupts native
state (SIGSEGV). Threads are cancelled cooperatively and, if
still running, detached until they exit on their own.
"""
if self._active_mode == "preview" and self._preview_thread:
self._preview_thread.cancel()
self._preview_thread.terminate()
self._preview_thread.wait(2000)
self._stop_thread(self._preview_thread)
elif self._active_mode == "render" and self._render_thread:
self._render_thread.cancel()
self._render_thread.terminate()
self._render_thread.wait(2000)
self._stop_thread(self._render_thread)
self._active_mode = None
def _stop_thread(self, thread, block: bool = False):
"""Cancel *thread*; detach without ever using ``terminate()``.
``QThread.terminate()`` kills the thread mid-instruction inside
Mitsuba/OCC C++ code and corrupts native state (SIGSEGV). Instead
the cooperative cancel flag is set and, if the thread is still
running, its result signals are disconnected and it is kept
referenced (``_retired_threads``) until it exits on its own a
cancelled ``run()`` emits no results, so it cannot clobber the UI.
``block=True`` (shutdown paths only) additionally waits up to 3 s
so a thread does not outlive the application. Interactive paths
keep the default and never stall the GUI thread.
"""
if thread is None:
return
# Drop retired threads that have exited.
for t in list(_RETIRED_THREADS):
if not t.isRunning():
_RETIRED_THREADS.remove(t)
thread.cancel()
if thread.isRunning():
# Disconnect so a detached thread can't update the UI. Signals
# with no receiver only emit a RuntimeWarning on disconnect, so
# silence that specific case. Not every thread class defines
# every signal, so skip missing attributes.
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
for name in ("finished", "error", "progress",
"mesh_ready", "assembly_ready"):
sig = getattr(thread, name, None)
if sig is None:
continue
try:
sig.disconnect()
except (RuntimeError, TypeError):
pass
_RETIRED_THREADS.append(thread)
# Reparent so destroying the owning widget can't delete a
# still-running QThread (a Qt fatal error).
thread.setParent(None)
if block:
thread.wait(3000)
def _set_buttons_rendering(self, mode: str):
"""Disable buttons while rendering."""
self._preview_btn.setEnabled(False)
@@ -974,10 +1110,8 @@ class RenderWindow(QMainWindow):
# Kill both possible threads
for thread in (self._preview_thread, self._render_thread):
if thread and thread.isRunning():
thread.cancel()
thread.terminate()
thread.wait(2000)
# block=True: at window close a thread must not outlive the app.
self._stop_thread(thread, block=True)
# Clean up temp mesh file
if self._mesh_path and os.path.exists(self._mesh_path):
@@ -1009,17 +1143,31 @@ class RenderTabContent(QWidget):
self._backend = None
self._mesh_path: Optional[str] = None
# Framing: percentage of screen the part should occupy (10-100).
self._framing_percentage: float = 80.0
# Assembly support: list of (mesh_path, RenderMaterial)
self._assembly_parts: list = []
# Rendering threads & images
self._render_thread: Optional[_RenderThread] = None
self._preview_thread: Optional[_RenderThread] = None
# Background tessellation thread — meshing never blocks the GUI
self._mesh_thread: Optional[_MeshThread] = None
# Raw (TopoDS_Shape, mat_name) tuples awaiting background tessellation
self._assembly_pending: list = []
# Bumped on every load/clear/cleanup; mesh results carry the
# generation they belong to so stale results are discarded.
self._mesh_generation: int = 0
self._last_image: Optional[np.ndarray] = None
self._last_preview: Optional[np.ndarray] = None
self._camera: Optional[RenderCamera] = None
self._ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5)
self._active_mode: Optional[str] = None
self._framing_slider: QSlider | None = None
self._framing_label: QLabel | None = None
# Combined bbox for assembly rendering (used by _compute_framed_origin)
self._assembly_bounds: Optional[tuple] = None
self._auto_preview_timer: Optional[QTimer] = None
self._init_ui()
@@ -1031,22 +1179,39 @@ class RenderTabContent(QWidget):
def set_shape(self, shape, camera: Optional[RenderCamera] = None) -> None:
"""Load a new OCC TopoDS_Shape for rendering.
Returns immediately: tessellation runs in a background thread
(``_MeshThread``) so callers (e.g. component switching) never
block the GUI thread. The auto-preview is scheduled once the
mesh is ready.
*camera* if provided, overrides the stored camera. Pass the
viewport\'s render camera to match the 3D view framing.
"""
self._mesh_generation += 1
# Cancel any in-progress render so the new shape gets a fresh preview.
self._cancel_active_thread()
# Cancel any in-flight tessellation from a previous load.
self._stop_thread(self._mesh_thread)
self._mesh_thread = None
# Drop any previously loaded assembly state so the single-shape
# render path is used (prevents re-rendering a stale assembly).
self._assembly_parts = []
self._assembly_pending = []
self._assembly_bounds = None
# Reset the mesh path so a stale/failed tessellation cannot
# trigger an auto-preview of the previous shape's mesh.
self._mesh_path = None
self._shape = shape
if camera is not None:
self._camera = camera
self._camera = self._apply_framing(camera)
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
self._image_label.setText("Click Preview or Render to start")
self._image_label.setText("Tessellating…")
self._status_badge.setText("")
self._export_btn.setEnabled(False)
self._prepare_mesh()
self._populate_camera_controls()
# Trigger auto-preview when a new shape is loaded
self._schedule_auto_preview()
self._start_meshing()
def get_camera(self) -> Optional[RenderCamera]:
"""Return the current camera (from UI controls or initial)."""
@@ -1061,21 +1226,30 @@ class RenderTabContent(QWidget):
*parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples
where the second element is an optional material preset name.
Returns immediately; the parts are tessellated in a background
thread and the auto-preview is scheduled once they are ready.
"""
self._mesh_generation += 1
# Cancel any in-progress render so the new assembly gets a fresh preview.
self._cancel_active_thread()
self._stop_thread(self._mesh_thread)
self._mesh_thread = None
self._shape = None
self._mesh_path = None
self._assembly_parts = []
self._assembly_pending = list(parts)
self._assembly_bounds = None
if camera is not None:
self._camera = camera
self._camera = self._apply_framing(camera)
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
self._image_label.setText("Click Preview or Render to start")
self._image_label.setText("Tessellating…")
self._status_badge.setText("")
self._export_btn.setEnabled(False)
self._prepare_assembly_mesh(parts)
self._populate_camera_controls()
self._schedule_auto_preview()
self._start_meshing()
def set_camera(self, camera: RenderCamera) -> None:
"""Update the render camera from an external source (e.g. 3D viewport).
@@ -1086,7 +1260,7 @@ class RenderTabContent(QWidget):
"""
if camera is None:
return
self._camera = camera
self._camera = self._apply_framing(camera)
self._cam_fov_spin.blockSignals(True)
try:
self._cam_fov_spin.setValue(camera.fov)
@@ -1100,20 +1274,48 @@ class RenderTabContent(QWidget):
# For full renders or idle: schedule a preview if auto-preview is on.
self._schedule_auto_preview()
def clear(self) -> None:
"""Remove any loaded shape/assembly and reset the display."""
self._mesh_generation += 1
self._cancel_active_thread()
self._stop_thread(self._mesh_thread)
self._mesh_thread = None
self._shape = None
self._mesh_path = None
self._assembly_parts = []
self._assembly_pending = []
self._assembly_bounds = None
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
self._image_label.setText("Click Preview or Render to start")
self._status_badge.setText("")
self._export_btn.setEnabled(False)
def cleanup(self) -> None:
"""Stop threads and delete temp files. Call when the tab is hidden/closed."""
self._mesh_generation += 1
if self._auto_preview_timer and self._auto_preview_timer.isActive():
self._auto_preview_timer.stop()
for thread in (self._preview_thread, self._render_thread):
if thread and thread.isRunning():
thread.cancel()
thread.terminate()
thread.wait(2000)
if self._mesh_path and os.path.exists(self._mesh_path):
try:
os.unlink(self._mesh_path)
except OSError:
pass
# block=True: at tab exit / app shutdown a thread must not outlive
# the owning widget.
for thread in (self._mesh_thread, self._preview_thread, self._render_thread):
self._stop_thread(thread, block=True)
self._mesh_thread = None
self._preview_thread = None
self._render_thread = None
self._active_mode = None
# Delete temp PLY files (single shape plus all assembly parts).
paths = []
if self._mesh_path:
paths.append(self._mesh_path)
paths.extend(p for p, _ in self._assembly_parts)
for path in paths:
if path and os.path.exists(path):
try:
os.unlink(path)
except OSError:
pass
self._mesh_path = None
# ── UI Setup ───────────────────────────────────────────────────
@@ -1248,6 +1450,23 @@ class RenderTabContent(QWidget):
layout.addWidget(camera_gb)
# ── Framing ───────────────────────────────────────────────
framing_gb = QGroupBox("Framing")
framing_layout = QVBoxLayout(framing_gb)
framing_layout.setSpacing(4)
self._framing_slider = QSlider(Qt.Horizontal)
self._framing_slider.setRange(10, 100)
self._framing_slider.setValue(80)
self._framing_slider.valueChanged.connect(self._on_framing_changed)
framing_layout.addWidget(self._framing_slider)
self._framing_label = QLabel("80 %")
self._framing_label.setAlignment(Qt.AlignCenter)
framing_layout.addWidget(self._framing_label)
layout.addWidget(framing_gb)
# ── Lighting ────────────────────────────────────────────────
light_gb = QGroupBox("Lighting")
light_layout = QVBoxLayout(light_gb)
@@ -1452,44 +1671,72 @@ class RenderTabContent(QWidget):
self._preview_btn.setEnabled(False)
self._preview_btn.setToolTip("No render backend installed (pip install mitsuba)")
def _prepare_mesh(self):
if self._shape is None:
return
try:
self._mesh_path = occ_shape_to_ply(
self._shape, linear_deflection=0.1, angular_deflection=0.15
)
if self._camera is None:
mn, mx = occ_shape_bounds(self._shape)
self._camera = self._backend.default_camera_from_bounds(mn, mx)
logger.info(f"Prepared mesh: {self._mesh_path}")
except Exception as e:
logger.error(f"Failed to prepare mesh: {e}")
QMessageBox.warning(self, "Render Error", f"Failed to tessellate shape:\n{e}")
def _start_meshing(self):
"""Kick off background tessellation of the current shape/assembly.
def _prepare_assembly_mesh(self, parts: list):
"""Tessellate multiple shapes to separate PLY files.
*parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples.
Each material preset name is resolved via ``get_preset``.
The GUI thread is never blocked: the tab shows "Tessellating…"
until the mesh is ready, then the auto-preview is scheduled.
"""
from fluency.rendering.material_presets import get_preset
if self._shape is not None:
thread = _MeshThread((self._shape,), is_assembly=False, parent=self)
gen = self._mesh_generation
thread.mesh_ready.connect(lambda path, g=gen: self._on_mesh_ready(path, g))
thread.error.connect(lambda msg, g=gen: self._on_mesh_error(msg, g))
elif self._assembly_pending:
thread = _MeshThread(self._assembly_pending, is_assembly=True, parent=self)
gen = self._mesh_generation
thread.assembly_ready.connect(
lambda parts, bounds, first, g=gen: self._on_assembly_ready(parts, bounds, first, g)
)
thread.error.connect(lambda msg, g=gen: self._on_mesh_error(msg, g))
else:
self._image_label.setText("Click Preview or Render to start")
return
thread.start()
self._mesh_thread = thread
self._assembly_parts = []
first_bounds = None
for shape, mat_name in parts:
try:
mesh_path = occ_shape_to_ply(shape, linear_deflection=0.1, angular_deflection=0.15)
material = get_preset(mat_name) if mat_name else get_preset("Brushed Steel")
self._assembly_parts.append((mesh_path, material))
if first_bounds is None:
first_bounds = occ_shape_bounds(shape)
except Exception as e:
logger.warning(f"Failed to tessellate assembly part: {e}")
if first_bounds and self._camera is None:
def _on_mesh_ready(self, mesh_path: str, gen: int) -> None:
"""Background tessellation finished (single shape)."""
if gen != self._mesh_generation:
# The load was replaced mid-tessellation — discard the stale mesh.
_unlink_quiet(mesh_path)
return
self._mesh_path = mesh_path
if self._camera is None and self._backend is not None:
mn, mx = occ_shape_bounds(self._shape)
self._camera = self._backend.default_camera_from_bounds(mn, mx)
self._populate_camera_controls()
if self._active_mode is None:
self._image_label.setText("Click Preview or Render to start")
logger.info(f"Prepared mesh: {self._mesh_path}")
# Trigger auto-preview when a new shape is loaded
self._schedule_auto_preview()
def _on_assembly_ready(self, parts: list, bounds, first_bounds, gen: int) -> None:
"""Background tessellation finished (assembly)."""
if gen != self._mesh_generation:
for p, _ in parts:
_unlink_quiet(p)
return
self._assembly_parts = parts
self._assembly_bounds = bounds
if self._camera is None and self._backend is not None and first_bounds is not None:
mn, mx = first_bounds
self._camera = self._backend.default_camera_from_bounds(mn, mx)
self._populate_camera_controls()
if self._active_mode is None:
self._image_label.setText("Click Preview or Render to start")
logger.info(f"Prepared assembly: {len(self._assembly_parts)} parts")
# Trigger auto-preview when a new assembly is loaded
self._schedule_auto_preview()
def _on_mesh_error(self, msg: str, gen: int) -> None:
if gen != self._mesh_generation:
return
logger.error(f"Failed to tessellate shape: {msg}")
self._image_label.setText("Click Preview or Render to start")
self._status_badge.setText("")
QMessageBox.warning(self, "Render Error", f"Failed to tessellate shape:\n{msg}")
def _setup_auto_preview(self):
self._auto_preview_timer = QTimer(self)
@@ -1503,12 +1750,132 @@ class RenderTabContent(QWidget):
return
self._cam_fov_spin.setValue(self._camera.fov)
# ── Framing ──────────────────────────────────────────────────
def _on_framing_changed(self, value: int) -> None:
"""Slider moved — update label and re-frame if we have a camera."""
self._framing_percentage = float(value)
self._framing_label.setText(f"{value} %")
# Re-apply framing with current direction
if self._camera is not None:
self._camera = self._apply_framing(self._camera)
# Sync the FOV spinbox to match (important for preview consistency)
self._cam_fov_spin.blockSignals(True)
try:
self._cam_fov_spin.setValue(self._camera.fov)
finally:
self._cam_fov_spin.blockSignals(False)
self._schedule_auto_preview()
def _apply_framing(self, camera: RenderCamera) -> RenderCamera:
"""Apply framing to a camera, returning a new one with adjusted origin.
Keeps the direction and target from *camera*, adjusts distance
so the part fills _framing_percentage of the screen.
"""
eye_dir = np.array(camera.origin) - np.array(camera.target)
diag = float(np.linalg.norm(eye_dir))
if diag > 1e-9:
eye_dir /= diag
target = camera.target
new_origin = self._compute_framed_origin(
eye_dir, target, camera.fov,
)
return RenderCamera(
origin=tuple(new_origin),
target=target,
up=camera.up,
fov=camera.fov,
)
return camera
def _compute_framed_origin(
self, eye_dir: np.ndarray, target: tuple[float, float, float], fov: float
) -> np.ndarray:
"""Compute camera origin so the part fills _framing_percentage of screen.
The viewing direction (eye_dir) and target define the line of sight.
The bounding box is projected onto the view plane and the distance
is chosen so that its larger projected dimension occupies exactly
``_framing_percentage`` of the corresponding screen axis.
"""
# Get bounding box — from assembly or single shape.
if self._assembly_bounds is not None:
mn, mx = self._assembly_bounds
elif self._shape is not None:
mn, mx = occ_shape_bounds(self._shape)
else:
# Fallback: place camera at a large but safe distance.
return np.array(target, dtype=float) + eye_dir * 1000.0
mn_arr = np.asarray(mn, dtype=float)
mx_arr = np.asarray(mx, dtype=float)
diag = float(np.linalg.norm(mx_arr - mn_arr))
# View-plane basis vectors.
up_world = np.array([0.0, 0.0, 1.0], dtype=float)
right = np.cross(up_world, eye_dir)
right_norm = float(np.linalg.norm(right))
if right_norm < 1e-9:
# Eye dir is parallel to world up — pick arbitrary right.
right = np.array([1.0, 0.0, 0.0], dtype=float)
else:
right /= right_norm
screen_up = np.cross(eye_dir, right)
# Project bbox axes onto view plane.
dx = mx_arr[0] - mn_arr[0]
dy = mx_arr[1] - mn_arr[1]
dz = mx_arr[2] - mn_arr[2]
# Project all 8 bbox corners onto the view plane
# to get the actual bounding-box extent.
half_x = dx / 2.0
half_y = dy / 2.0
half_z = dz / 2.0
# Corner offsets from centre in local axes.
corner_offsets = [
(sx * half_x, sy * half_y, sz * half_z)
for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)
]
# Project each corner onto view-plane basis vectors.
proj_right_vals = [
ox * right[0] + oy * right[1] + oz * right[2]
for ox, oy, oz in corner_offsets
]
proj_up_vals = [
ox * screen_up[0] + oy * screen_up[1] + oz * screen_up[2]
for ox, oy, oz in corner_offsets
]
proj_right = max(proj_right_vals) - min(proj_right_vals)
proj_up = max(proj_up_vals) - min(proj_up_vals)
half_fov_rad = np.radians(fov / 2.0)
tan_half_fov = float(np.tan(half_fov_rad))
if tan_half_fov < 1e-9:
return np.array(target, dtype=float) + eye_dir * 1000.0
# Distance: D = full_extent / (2 * pct * tan(fov/2))
pct = self._framing_percentage / 100.0
dist_x = proj_right / (2.0 * pct * tan_half_fov) if proj_right > 0 else float("inf")
dist_y = proj_up / (2.0 * pct * tan_half_fov) if proj_up > 0 else float("inf")
dist = min(dist_x, dist_y)
# Minimum distance to avoid camera inside the object.
if dist < diag * 0.1:
dist = diag * 0.1
return np.array(target, dtype=float) + eye_dir * dist
def _schedule_auto_preview(self):
if not self._auto_preview_cb.isChecked():
return
if self._active_mode is not None:
return
if self._backend is None or self._mesh_path is None:
if self._backend is None or (self._mesh_path is None and not self._assembly_parts):
return
self._auto_preview_timer.start(500)
@@ -1560,15 +1927,55 @@ class RenderTabContent(QWidget):
def _cancel_active_thread(self):
if self._active_mode == "preview" and self._preview_thread:
self._preview_thread.cancel()
self._preview_thread.terminate()
self._preview_thread.wait(2000)
self._stop_thread(self._preview_thread)
elif self._active_mode == "render" and self._render_thread:
self._render_thread.cancel()
self._render_thread.terminate()
self._render_thread.wait(2000)
self._stop_thread(self._render_thread)
self._active_mode = None
def _stop_thread(self, thread, block: bool = False):
"""Cancel *thread*; detach without ever using ``terminate()``.
``QThread.terminate()`` kills the thread mid-instruction inside
Mitsuba/OCC C++ code and corrupts native state (SIGSEGV). Instead
the cooperative cancel flag is set and, if the thread is still
running, its result signals are disconnected and it is kept
referenced (``_retired_threads``) until it exits on its own a
cancelled ``run()`` emits no results, so it cannot clobber the UI.
``block=True`` (shutdown paths only) additionally waits up to 3 s
so a thread does not outlive the application. Interactive paths
keep the default and never stall the GUI thread.
"""
if thread is None:
return
# Drop retired threads that have exited.
for t in list(_RETIRED_THREADS):
if not t.isRunning():
_RETIRED_THREADS.remove(t)
thread.cancel()
if thread.isRunning():
# Disconnect so a detached thread can't update the UI. Signals
# with no receiver only emit a RuntimeWarning on disconnect, so
# silence that specific case. Not every thread class defines
# every signal, so skip missing attributes.
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
for name in ("finished", "error", "progress",
"mesh_ready", "assembly_ready"):
sig = getattr(thread, name, None)
if sig is None:
continue
try:
sig.disconnect()
except (RuntimeError, TypeError):
pass
_RETIRED_THREADS.append(thread)
# Reparent so destroying the owning widget can't delete a
# still-running QThread (a Qt fatal error).
thread.setParent(None)
if block:
thread.wait(3000)
def _set_buttons_rendering(self, mode: str):
self._preview_btn.setEnabled(False)
self._render_btn.setEnabled(False)
+10 -3
View File
@@ -260,6 +260,7 @@ class Sketch2DWidget(QWidget):
self._move_anchor_orig: Optional[QPoint] = None
self._move_orig_positions: Dict[int, Tuple[float, float]] = {}
self._move_active: bool = False
self._move_did_move: bool = False
# Auto-constraint tracking on snap
self._snap_point_target: Optional[OCCSketchEntity] = None
@@ -547,8 +548,12 @@ class Sketch2DWidget(QWidget):
end_uv[0], end_uv[1]
)
# sweep=None → renderer computes shortest-path arc
# register=False: all three reference points are external
# (fixed) — registering the arc on top would
# over-constrain the solver (inconsistent).
self._sketch.add_arc(
center_pt, radius, start_pt, end_pt, sweep=None
center_pt, radius, start_pt, end_pt,
sweep=None, register=False,
)
imported += 1
except Exception as exc:
@@ -811,6 +816,7 @@ class Sketch2DWidget(QWidget):
self._move_anchor_orig = None
self._move_orig_positions = {}
self._move_active = False
self._move_did_move = False
self._hovered_face = None
self._snap_point_target = None
self._snap_line_target = None
@@ -2317,6 +2323,8 @@ class Sketch2DWidget(QWidget):
target_world = self._screen_to_world(snapped_screen)
dx = target_world.x() - self._move_anchor_orig.x()
dy = target_world.y() - self._move_anchor_orig.y()
if dx != 0 or dy != 0:
self._move_did_move = True
for ent in self._moving_points:
if ent.id in self._move_orig_positions and ent.geometry is not None:
ox, oy = self._move_orig_positions[ent.id]
@@ -2514,8 +2522,7 @@ class Sketch2DWidget(QWidget):
# the user expects from dragging a single corner.
if self._move_anchor is not None and self._move_anchor.geometry is not None:
ax, ay = self._move_anchor.geometry
if not self._sketch.is_entity_dragged(self._move_anchor.id):
self._sketch.constrain_fixed(self._move_anchor)
if self._move_did_move and not self._sketch.is_entity_dragged(self._move_anchor.id):
# constrain_fixed reads the current params via
# the dragged() call, so re-sync to be safe.
self._solve_and_sync()
File diff suppressed because it is too large Load Diff
+51 -2
View File
@@ -189,17 +189,27 @@ class Viewer3DWidget(QWidget):
self._ensure_initialized()
return self._renderer
def show_shape(self, shape: Any, color=None, name=None) -> str:
def show_shape(
self,
shape: Any,
color=None,
name=None,
auto_fit: bool = True,
) -> str:
"""Display an OCC TopoDS_Shape.
Uses OCCRenderer.add_shape for native AIS display, or falls back to
triangulation + add_mesh for the PygfxRenderer.
*auto_fit* is forwarded to the renderer: pass ``False`` when
rebuilding a scene under explicit camera control so the first
shape does not trigger a whole-scene camera fit.
"""
self._ensure_initialized()
from fluency.rendering.occ_renderer import OCCRenderer
if isinstance(self._renderer, OCCRenderer):
oid = self._renderer.add_shape(shape, color, name)
oid = self._renderer.add_shape(shape, color, name, auto_fit)
self._renderer.render()
return oid
# Fallback: tessellate and use the mesh pipeline.
@@ -279,6 +289,19 @@ class Viewer3DWidget(QWidget):
self._renderer.render()
return ok
def set_body_color(
self, mesh_id: str, color: Tuple[float, float, float]
) -> bool:
"""Change the colour of a body in the 3D view. Returns True on success."""
self._ensure_initialized()
fn = getattr(self._renderer, "set_color", None)
if fn is None:
return False
ok = fn(mesh_id, color)
if ok:
self._renderer.render()
return ok
def set_transparency(self, mesh_id: str, transparency: float) -> bool:
"""Set a previously-added mesh's transparency (0..1).
@@ -862,6 +885,22 @@ class Viewer3DWidget(QWidget):
fn()
self._renderer.render()
def highlight_operation(self, shape: Any) -> None:
"""Overlay a hot-pink translucent *shape* to show operation history."""
self._ensure_initialized()
fn = getattr(self._renderer, "highlight_operation_shape", None)
if fn is not None:
fn(shape)
self._renderer.render()
def clear_operation_highlight(self) -> None:
"""Remove the operation-history overlay, if any."""
self._ensure_initialized()
fn = getattr(self._renderer, "clear_operation_highlight", None)
if fn is not None:
fn()
self._renderer.render()
# ─── Connector pick mode (assembly) ────────────────────────────────────
def set_connector_pick_mode(self, enabled: bool, clear_gizmo: bool = True) -> None:
@@ -901,6 +940,16 @@ class Viewer3DWidget(QWidget):
def is_connector_pick_mode(self) -> bool:
return self._connector_pick_mode
def show_persistent_connector_gizmo(self, origin, normal, x_dir, entity_type, color=(0.0, 1.0, 0.0)):
fn = getattr(self._renderer, "show_persistent_entity_gizmo", None)
if fn is not None:
fn(entity_type=entity_type, position=origin, normal=normal, x_dir=x_dir, color=color)
def clear_persistent_connector_gizmo(self):
fn = getattr(self._renderer, "clear_persistent_entity_gizmo", None)
if fn is not None:
fn()
def _clear_connector_snap(self) -> None:
"""Remove the hover gizmo."""
fn = getattr(self._renderer, "clear_entity_gizmo", None)
+916
View File
@@ -0,0 +1,916 @@
"""Tests for the technical drawing workbench.
Covers the manual-dimension pipeline (model-space annotations
sheet-space candidates placed primitives), the 2D pick geometry
helpers, and persistence of drawings (manual dimensions included) in
the .fluency project file.
"""
import json
import math
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from fluency.models.data_model import (
Body,
Component,
DrawingAnnotation,
DrawingView,
Project,
TechnicalDrawing,
)
from fluency.technical_drawing import (
build_manual_candidates,
generate_drawing,
_layout_views_on_sheet,
)
from fluency.io.project_io import (
_technical_drawing_from_dict,
_technical_drawing_to_dict,
load_project,
save_project,
)
from fluency.ui.technical_drawing_widget import (
_closest_point_on_segment,
_closest_points_on_segments,
_line_intersection,
_point_to_segment,
)
# ── Fixtures ───────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def qapp():
"""Offscreen QApplication for widget-level tests."""
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
yield app
def _drawing_with_views(view_kinds=("front",)):
drawing = TechnicalDrawing(source_kind="component", source_id="comp-1")
for kind in view_kinds:
drawing.views.append(DrawingView(kind=kind))
return drawing
def _manual_annotation(
dimension_kind: str,
view_id: str,
anchors,
direction=None,
) -> DrawingAnnotation:
return DrawingAnnotation(
kind="dimension",
dimension_kind=dimension_kind,
view_id=view_id,
anchors=list(anchors),
direction=direction,
)
# ── build_manual_candidates ────────────────────────────────────────────────
class TestBuildManualCandidates:
def test_length_candidate_reprojects_anchors(self):
drawing = _drawing_with_views()
ann = _manual_annotation(
"length", "front", ((0.0, 0.0), (0.0, 12.5)), direction=(0.0, 1.0)
)
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(
drawing, {"front": (2.0, 10.0, 20.0)}
)
assert unresolved == []
assert resolved == [ann.id]
c = cands[0]
assert c.kind == "length"
assert c.view_id == "front"
assert c.key == f"manual:{ann.id}"
assert c.references == (ann.id,)
assert c.value == pytest.approx(12.5)
assert c.label == "12.50"
# sheet = model * scale + offset
assert c.anchor_points[0] == pytest.approx((10.0, 20.0))
assert c.anchor_points[1] == pytest.approx((10.0, 45.0))
assert c.direction == (0.0, 1.0)
def test_length_without_transform_is_unresolved(self):
drawing = _drawing_with_views()
ann = _manual_annotation("length", "front", ((0.0, 0.0), (3.0, 4.0)))
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(drawing, {})
assert cands == []
assert unresolved == [ann.id]
cands, resolved, unresolved = build_manual_candidates(
drawing, {"front": (1.0, 0.0, 0.0)}
)
assert unresolved == []
c = cands[0]
assert c.value == pytest.approx(5.0)
assert c.direction == pytest.approx((0.6, 0.8))
def test_diameter_candidate(self):
drawing = _drawing_with_views()
ann = _manual_annotation("diameter", "top", ((-5.0, 0.0), (5.0, 0.0)))
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(
drawing, {"top": (1.0, 0.0, 0.0)}
)
assert unresolved == []
c = cands[0]
assert c.kind == "diameter"
assert c.value == pytest.approx(10.0)
assert c.label == "Ø10.00"
assert c.direction == ()
def test_angle_candidate(self):
drawing = _drawing_with_views()
ann = _manual_annotation(
"angle", "front", ((0.0, 0.0), (10.0, 0.0), (0.0, 10.0))
)
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(
drawing, {"front": (1.0, 0.0, 0.0)}
)
assert unresolved == []
c = cands[0]
assert c.kind == "angle"
assert c.value == pytest.approx(90.0)
assert c.label == "90.00°"
assert len(c.anchor_points) == 3
def test_degenerate_angle_is_unresolved(self):
# Collinear arms → 180° → not a usable angle dimension.
drawing = _drawing_with_views()
ann = _manual_annotation(
"angle", "front", ((0.0, 0.0), (10.0, 0.0), (-10.0, 0.0))
)
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(
drawing, {"front": (1.0, 0.0, 0.0)}
)
assert cands == []
assert resolved == []
assert unresolved == [ann.id]
def test_hidden_annotation_is_skipped(self):
drawing = _drawing_with_views()
ann = _manual_annotation("length", "front", ((0.0, 0.0), (1.0, 0.0)))
ann.visible = False
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(
drawing, {"front": (1.0, 0.0, 0.0)}
)
assert cands == []
assert resolved == []
assert unresolved == []
# ── generate_drawing: auto vs manual dimensions ───────────────────────────
@pytest.fixture(scope="module")
def kernel():
from fluency.geometry_occ.kernel import OCGeometryKernel
return OCGeometryKernel()
@pytest.fixture(scope="module")
def box_project(kernel):
"""Project with one 10 x 20 x 5 box (x: 0..10, y: 0..20, z: 0..5)."""
from fluency.geometry.base import Point2D
points = [Point2D(0, 0), Point2D(10, 0), Point2D(10, 20), Point2D(0, 20)]
polygon = kernel.create_polygon(points)
box = kernel.extrude(polygon, 5.0)
body = Body(name="Box", geometry=box)
comp = Component(name="BoxComp")
comp.bodies[body.id] = body
project = Project()
project.components[comp.id] = comp
project.active_component = comp.id
return project, comp
class TestGenerateDrawingManualDimensions:
def test_manual_dimension_placed_with_auto_off(self, kernel, box_project):
project, comp = box_project
drawing = TechnicalDrawing(
source_kind="component",
source_id=comp.id,
views=[DrawingView(kind="front")],
auto_dimensions=False,
)
# Distance between the two vertical edges of the box front face.
ann = _manual_annotation(
"length", "front", ((0.0, 0.0), (10.0, 0.0)), direction=(1.0, 0.0)
)
drawing.annotations.append(ann)
result = generate_drawing(drawing, project, kernel)
assert result.view_transforms, "view transforms must be published"
manual_keys = [
p.candidate_key for p in result.primitives if p.candidate_key
]
assert f"manual:{ann.id}" in manual_keys
label_texts = [
p.text
for p in result.primitives
if p.candidate_key == f"manual:{ann.id}" and p.kind == "text"
]
assert label_texts == ["10.00"]
# Auto off → no auto-placed dimensions.
assert not any(
k for k in manual_keys if not k.startswith("manual:")
), "auto dimensions must stay out while auto_dimensions is off"
assert ann.id in result.resolved_annotation_ids
def test_auto_off_places_no_auto_dimensions(self, kernel, box_project):
project, comp = box_project
drawing = TechnicalDrawing(
source_kind="component",
source_id=comp.id,
views=[DrawingView(kind="front")],
auto_dimensions=False,
)
result = generate_drawing(drawing, project, kernel)
dim_keys = [p.candidate_key for p in result.primitives if p.candidate_key]
assert dim_keys == [], f"expected no dimensions, got {dim_keys}"
def test_auto_on_places_auto_dimensions(self, kernel, box_project):
project, comp = box_project
drawing = TechnicalDrawing(
source_kind="component",
source_id=comp.id,
views=[DrawingView(kind="front")],
auto_dimensions=True,
)
result = generate_drawing(drawing, project, kernel)
dim_keys = [p.candidate_key for p in result.primitives if p.candidate_key]
assert dim_keys, "auto dimensions expected with auto_dimensions on"
assert all(not k.startswith("manual:") for k in dim_keys)
def test_auto_and_manual_coexist(self, kernel, box_project):
project, comp = box_project
drawing = TechnicalDrawing(
source_kind="component",
source_id=comp.id,
views=[DrawingView(kind="front")],
auto_dimensions=True,
)
ann = _manual_annotation(
"length", "front", ((0.0, 0.0), (10.0, 0.0)), direction=(1.0, 0.0)
)
drawing.annotations.append(ann)
result = generate_drawing(drawing, project, kernel)
dim_keys = {p.candidate_key for p in result.primitives if p.candidate_key}
assert f"manual:{ann.id}" in dim_keys
assert any(k for k in dim_keys if not k.startswith("manual:"))
def test_diameter_manual_on_cylinder(self, kernel):
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt
from fluency.geometry_occ.kernel import OCCGeometryObject
ax = gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1))
cyl = OCCGeometryObject(
BRepPrimAPI_MakeCylinder(ax, 4.0, 8.0).Shape(),
{"type": "cylinder"},
)
body = Body(name="Cyl", geometry=cyl)
comp = Component(name="CylComp")
comp.bodies[body.id] = body
project = Project()
project.components[comp.id] = comp
project.active_component = comp.id
drawing = TechnicalDrawing(
source_kind="component",
source_id=comp.id,
views=[DrawingView(kind="front")],
auto_dimensions=False,
)
ann = _manual_annotation("diameter", "front", ((-4.0, 0.0), (4.0, 0.0)))
drawing.annotations.append(ann)
result = generate_drawing(drawing, project, kernel)
label_texts = [
p.text
for p in result.primitives
if p.candidate_key == f"manual:{ann.id}" and p.kind == "text"
]
assert label_texts == ["Ø8.00"]
# ── Circle centres: ISO center marks + centre-point dimensioning ──────────
def _cylinder_project(kernel):
"""One Ø8 x 8 cylinder (axis +Z) as a draw-able component.
HLR may split a circle's edge into sampled segments for some shapes,
so tests that need a guaranteed circle primitive build it directly
(see :class:`TestCircleCenterMarks` / :class:`TestCircleCenterPick`).
"""
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt
from fluency.geometry_occ.kernel import OCCGeometryObject
ax = gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1))
cyl = OCCGeometryObject(
BRepPrimAPI_MakeCylinder(ax, 4.0, 8.0).Shape(),
{"type": "cylinder"},
)
body = Body(name="Cyl", geometry=cyl)
comp = Component(name="CylComp")
comp.bodies[body.id] = body
project = Project()
project.components[comp.id] = comp
project.active_component = comp.id
return project, comp
class TestCircleCenterMarks:
"""ISO 14128 center marks: a thin cross at each projected circle's
centre, crossing at the centre and extending past the circle edge."""
def test_center_marks_emitted_for_circles(self):
from fluency.technical_drawing import _assemble_view
# Synthetic view plane: a 20 × 16 box with a r4 circle at (10, 8).
edges = [
((0.0, 0.0), (20.0, 0.0), "line", "visible"),
((20.0, 0.0), (20.0, 16.0), "line", "visible"),
((20.0, 16.0), (0.0, 16.0), "line", "visible"),
((0.0, 16.0), (0.0, 0.0), "line", "visible"),
((10.0, 8.0), (14.0, 8.0), "circle_full", "visible"),
]
view = DrawingView(kind="top")
prims, _cands, _warns = _assemble_view(edges, [], view, (10, 10, 200, 150))
circles = [p for p in prims if p.kind == "circle"]
assert len(circles) == 1
c = circles[0]
cx, cy = c.center
marks = [p for p in prims if p.kind == "line" and p.style == "center"]
assert len(marks) == 2, "one horizontal and one vertical center mark"
horiz = next(p for p in marks if p.points[0][1] == p.points[1][1])
vert = next(p for p in marks if p.points[0][0] == p.points[1][0])
# The marks cross at the circle centre.
assert horiz.points[0][1] == cy and horiz.points[1][1] == cy
assert vert.points[0][0] == cx and vert.points[1][0] == cx
# And each extends past the circle edge.
half_h = abs(horiz.points[1][0] - horiz.points[0][0]) / 2.0
half_v = abs(vert.points[1][1] - vert.points[0][1]) / 2.0
assert half_h > c.radius
assert half_v > c.radius
class TestCircleCenterAnchors:
"""Distance picks between circle centres, and between a centre and an
edge, resolve to the right model-space anchor pair."""
def _w(self):
from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
return TechnicalDrawingWidget
def test_pick_point_kinds(self):
W = self._w()
assert W._pick_point({"kind": "point", "point": (1.0, 2.0)}) == (1.0, 2.0)
assert W._pick_point(
{"kind": "circle", "center": (3.0, 4.0), "radius": 1.0}
) == (3.0, 4.0)
assert W._pick_point({"kind": "segment", "p1": (0, 0), "p2": (1, 1)}) is None
def test_center_to_center(self):
W = self._w()
a = {"kind": "circle", "view_id": "v", "center": (0.0, 0.0), "radius": 2.0}
b = {"kind": "circle", "view_id": "v", "center": (5.0, 12.0), "radius": 3.0}
p1, p2 = W._distance_anchors(a, b)
assert p1 == (0.0, 0.0)
assert p2 == (5.0, 12.0)
def test_center_to_edge(self):
W = self._w()
a = {"kind": "point", "view_id": "v", "point": (4.0, 6.0)}
b = {"kind": "segment", "view_id": "v", "p1": (0.0, 0.0), "p2": (10.0, 0.0)}
p1, p2 = W._distance_anchors(a, b)
assert p1 == (4.0, 6.0)
# Closest point on the edge is straight below the centre.
assert p2 == pytest.approx((4.0, 0.0))
def test_edge_to_edge_unchanged(self):
W = self._w()
a = {"kind": "segment", "view_id": "v", "p1": (0.0, 0.0), "p2": (10.0, 0.0)}
b = {"kind": "segment", "view_id": "v", "p1": (2.0, 5.0), "p2": (8.0, 5.0)}
p1, p2 = W._distance_anchors(a, b)
assert p1 == pytest.approx((2.0, 0.0))
assert p2 == pytest.approx((2.0, 5.0))
# ── View layout: page fill, no overlaps, title-block clearance ───────────
class TestViewLayout:
"""_layout_views_on_sheet packs the views to fill the sheet, keeps
them apart and clear of the title block, and rotates individual
views when that makes the set fit more."""
A3W, A3H = 420.0, 297.0
# Title block box + 5 mm clearance zone (see _title_block_primitives).
TB = (235.0, 0.0, 420.0, 62.0)
@staticmethod
def _bbox(w, h):
return (0.0, 0.0, float(w), float(h))
def _layout(self, kinds, boxes):
views = [DrawingView(kind=k) for k in kinds]
return _layout_views_on_sheet(views, {k: boxes[k] for k in kinds})
def _assert_valid(self, slots):
for k, s in slots.items():
assert s[0] >= 10.0 - 1e-6 and s[1] >= 10.0 - 1e-6, (k, s)
assert s[0] + s[2] <= self.A3W - 10.0 + 1e-6, (k, s)
assert s[1] + s[3] <= self.A3H - 10.0 + 1e-6, (k, s)
x0, y0, w, h = s
x1, y1, w2, h2 = self.TB
assert x0 + w <= x1 or x1 + w2 <= x0 or y0 + h <= y1 or y1 + h2 <= y0, \
f"{k} intrudes title block: {s}"
ks = list(slots)
for i in range(len(ks)):
for j in range(i + 1, len(ks)):
a, b_ = slots[ks[i]], slots[ks[j]]
sep = (
a[0] + a[2] <= b_[0] + 0.1 or b_[0] + b_[2] <= a[0] + 0.1
or a[1] + a[3] <= b_[1] + 0.1 or b_[1] + b_[3] <= a[1] + 0.1
)
assert sep, f"{ks[i]} overlaps {ks[j]}: {a} / {b_}"
@staticmethod
def _fill(slots):
x0 = min(s[0] for s in slots.values())
y0 = min(s[1] for s in slots.values())
x1 = max(s[0] + s[2] for s in slots.values())
y1 = max(s[1] + s[3] for s in slots.values())
return (x1 - x0) * (y1 - y0) / (420.0 * 297.0)
def test_single_view_fills_page(self):
slots, scale, rots = self._layout(["front"], {"front": self._bbox(10, 20)})
self._assert_valid(slots)
# 12 views stay upright — no sideways single view.
assert rots == {"front": 0.0}
# The tall 10 × 20 view is scaled until it touches the full-height
# left strip's reduced height (0.8 × 277 mm).
assert scale == pytest.approx(221.6 / 20.0)
assert slots["front"][3] == pytest.approx(221.6)
def test_two_views_share_page(self):
slots, _scale, _rots = self._layout(
["front", "top"],
{"front": self._bbox(40, 40), "top": self._bbox(40, 20)},
)
self._assert_valid(slots)
assert self._fill(slots) > 0.30
def test_classic_three_view_keeps_cross(self):
boxes = {k: self._bbox(40, 40) for k in ("front", "top", "right")}
slots, _scale, rots = self._layout(list(boxes), boxes)
self._assert_valid(slots)
assert set(rots.values()) == {0.0}, "classic cross must not rotate"
# Top sits directly above front; right directly to its right.
assert abs(slots["top"][0] - slots["front"][0]) < 1e-6
assert slots["top"][1] > slots["front"][1] + slots["front"][3]
assert slots["right"][0] > slots["front"][0] + slots["front"][2]
assert abs(slots["right"][1] - slots["front"][1]) < 1e-6
def test_thin_part_gets_rotated_views(self):
boxes = {
"front": self._bbox(200, 30),
"top": self._bbox(30, 50),
"right": self._bbox(50, 30),
"left": self._bbox(50, 30),
"back": self._bbox(200, 30),
"bottom": self._bbox(30, 50),
}
slots, scale, rots = self._layout(list(boxes), boxes)
self._assert_valid(slots)
assert any(r == 90.0 for r in rots.values()), "rotation must kick in"
# Each slot is the (possibly swapped) model size times the scale.
for k, s in slots.items():
w, h = boxes[k][2], boxes[k][3]
sw, sh = s[2] / scale, s[3] / scale
assert (
(sw == pytest.approx(w) and sh == pytest.approx(h))
or (sw == pytest.approx(h) and sh == pytest.approx(w))
), (k, s, w, h)
def test_all_views_plus_isometric_fill_page(self):
boxes = {
"front": self._bbox(80, 40),
"top": self._bbox(80, 30),
"right": self._bbox(30, 40),
"left": self._bbox(30, 40),
"back": self._bbox(80, 40),
"bottom": self._bbox(80, 30),
"isometric": self._bbox(60, 60),
}
slots, _scale, _rots = self._layout(list(boxes), boxes)
self._assert_valid(slots)
assert self._fill(slots) > 0.55
# ── Project drawing persistence ────────────────────────────────────────────
def _drawing_with_manual_dim():
drawing = TechnicalDrawing(
source_kind="component",
source_id="comp-42",
views=[DrawingView(kind="front"), DrawingView(kind="top")],
auto_dimensions=True,
title="Persisted Drawing",
revision="B",
)
drawing.annotations.append(
_manual_annotation(
"length", "front", ((0.0, 0.0), (0.0, 12.5)), direction=(0.0, 1.0)
)
)
drawing.annotations.append(
_manual_annotation("diameter", "top", ((-5.0, 0.0), (5.0, 0.0)))
)
return drawing
class TestDrawingPersistence:
def test_drawing_dict_roundtrip(self):
drawing = _drawing_with_manual_dim()
data = _technical_drawing_to_dict(drawing)
restored = _technical_drawing_from_dict(json.loads(json.dumps(data)))
assert restored.id == drawing.id
assert restored.source_kind == "component"
assert restored.source_id == "comp-42"
assert restored.auto_dimensions is True
assert [v.kind for v in restored.views] == ["front", "top"]
assert len(restored.annotations) == 2
a = restored.annotations[0]
assert a.dimension_kind == "length"
assert a.view_id == "front"
assert a.anchors == [(0.0, 0.0), (0.0, 12.5)]
assert a.direction == (0.0, 1.0)
b = restored.annotations[1]
assert b.dimension_kind == "diameter"
assert b.anchors == [(-5.0, 0.0), (5.0, 0.0)]
def test_project_drawings_lookup(self):
project = Project()
drawing = _drawing_with_manual_dim()
project.add_drawing(drawing)
assert project.get_drawing_for("component", "comp-42") is drawing
assert project.get_drawing_for("assembly", "comp-42") is None
assert project.get_drawing_for("component", "other") is None
def test_project_save_load_roundtrip(self, tmp_path):
project = Project(name="Drawing Project")
drawing = _drawing_with_manual_dim()
project.add_drawing(drawing)
path = save_project(project, str(tmp_path / "proj.fluency"))
loaded, _view_state = load_project(path)
assert len(loaded.drawings) == 1
restored = loaded.drawings[0]
assert restored.source_id == "comp-42"
assert restored.auto_dimensions is True
assert restored.title == "Persisted Drawing"
assert len(restored.annotations) == 2
a = restored.annotations[0]
assert a.dimension_kind == "length"
assert a.view_id == "front"
assert a.anchors == [(0.0, 0.0), (0.0, 12.5)]
assert a.direction == (0.0, 1.0)
assert a.id == drawing.annotations[0].id
# The restored drawing must still build the same candidates.
cands, resolved, unresolved = build_manual_candidates(
restored, {"front": (1.0, 0.0, 0.0), "top": (1.0, 0.0, 0.0)}
)
assert unresolved == []
labels = sorted(c.label for c in cands)
assert labels == ["12.50", "Ø10.00"]
def test_load_ignores_corrupt_drawing_entry(self, tmp_path):
import zipfile
project = Project(name="Mixed")
project.add_drawing(_drawing_with_manual_dim())
path = save_project(project, str(tmp_path / "proj.fluency"))
with zipfile.ZipFile(path, "r") as zf:
names = zf.namelist()
contents = {n: zf.read(n) for n in names}
manifest = json.loads(contents["project.json"])
manifest["drawings"].append({"id": "broken", "views": "not-a-list"})
contents["project.json"] = json.dumps(manifest).encode("utf-8")
with zipfile.ZipFile(path, "w") as zf:
for name in names:
zf.writestr(name, contents[name])
loaded, _ = load_project(path)
# Corrupt entry skipped, valid one kept.
assert len(loaded.drawings) == 1
assert loaded.drawings[0].source_id == "comp-42"
def test_auto_dimensions_default_off(self):
assert TechnicalDrawing().auto_dimensions is False
# ── Pick geometry helpers ──────────────────────────────────────────────────
class TestPickGeometry:
def test_point_to_segment_inside(self):
q, d = _point_to_segment((5.0, 3.0), (0.0, 0.0), (10.0, 0.0))
assert q == pytest.approx((5.0, 0.0))
assert d == pytest.approx(3.0)
def test_point_to_segment_clamps_at_endpoint(self):
q, d = _point_to_segment((-2.0, 1.0), (0.0, 0.0), (10.0, 0.0))
assert q == pytest.approx((0.0, 0.0))
assert d == pytest.approx(math.hypot(2.0, 1.0))
def test_crossing_segments(self):
q1, q2, d = _closest_points_on_segments(
(0.0, 0.0), (10.0, 0.0), (4.0, -2.0), (4.0, 8.0)
)
assert q1 == pytest.approx((4.0, 0.0))
assert q2 == pytest.approx((4.0, 0.0))
assert d == pytest.approx(0.0, abs=1e-9)
def test_parallel_overlapping_segments(self):
# The classic "distance between two parallel edges" pick:
# result must be the true perpendicular distance.
q1, q2, d = _closest_points_on_segments(
(0.0, 0.0), (10.0, 0.0), (2.0, 5.0), (8.0, 5.0)
)
assert d == pytest.approx(5.0)
assert q1[1] == pytest.approx(0.0)
assert q2[1] == pytest.approx(5.0)
assert q1[0] == pytest.approx(q2[0])
def test_parallel_disjoint_segments(self):
q1, q2, d = _closest_points_on_segments(
(0.0, 0.0), (2.0, 0.0), (5.0, 3.0), (7.0, 3.0)
)
assert d == pytest.approx(math.hypot(3.0, 3.0))
def test_line_intersection(self):
pt = _line_intersection(
(0.0, 0.0), (10.0, 0.0), (4.0, -2.0), (4.0, 8.0)
)
assert pt == pytest.approx((4.0, 0.0))
def test_line_intersection_parallel_is_none(self):
assert _line_intersection(
(0.0, 0.0), (10.0, 0.0), (2.0, 5.0), (8.0, 5.0)
) is None
def test_closest_point_on_segment(self):
q = _closest_point_on_segment((4.0, 9.0), (0.0, 0.0), (10.0, 0.0))
assert q == pytest.approx((4.0, 0.0))
# ── Widget: dimension tool plumbing (offscreen) ────────────────────────────
class TestDrawingWidgetTools:
def _widget(self, qapp):
from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
return TechnicalDrawingWidget()
def test_widget_starts_without_pick_mode(self, qapp):
w = self._widget(qapp)
assert w._canvas._pick_mode == ""
assert not w._auto_dim_check.isChecked()
assert not any(b.isChecked() for b in w._tool_buttons.values())
def test_tool_toggle_enters_pick_mode(self, qapp):
w = self._widget(qapp)
btn = w._tool_buttons["distance"]
btn.setChecked(True)
assert w._canvas._pick_mode == "distance"
assert "Distance" in w._status_label.text()
assert "edge" in w._status_label.text()
# Switching tools re-targets the canvas and unchecks the old tool.
w._tool_buttons["diameter"].setChecked(True)
assert btn.isChecked() is False
assert w._canvas._pick_mode == "diameter"
# Escape path: cancels the tool, clears all buttons and mode.
w._cancel_pick()
assert w._canvas._pick_mode == ""
for other in w._tool_buttons.values():
assert not other.isChecked()
def test_add_manual_dimension_appends_and_emits(self, qapp):
w = self._widget(qapp)
w.set_drawing(
TechnicalDrawing(source_kind="component", source_id="c1")
)
changes = []
w.drawing_changed.connect(lambda: changes.append(1))
w._add_manual_dimension(
"length",
anchors=((0.0, 0.0), (10.0, 0.0)),
view_id="front",
direction=(1.0, 0.0),
)
anns = w._drawing.annotations
assert len(anns) == 1
assert anns[0].dimension_kind == "length"
assert anns[0].view_id == "front"
assert anns[0].anchors == [(0.0, 0.0), (10.0, 0.0)]
assert changes == [1]
def test_clear_removes_manual_dimensions_only(self, qapp):
w = self._widget(qapp)
drawing = TechnicalDrawing(source_kind="component", source_id="c1")
drawing.annotations.append(
DrawingAnnotation(kind="note", text="keep me")
)
drawing.annotations.append(
_manual_annotation(
"length", "front", ((0.0, 0.0), (5.0, 0.0))
)
)
w.set_drawing(drawing)
w._on_clear_clicked()
assert len(drawing.annotations) == 1
assert drawing.annotations[0].kind == "note"
def test_adopt_stored_project_drawing(self, qapp, kernel):
w = self._widget(qapp)
project = Project()
comp = Component(name="ExistingComp")
project.components[comp.id] = comp
stored = TechnicalDrawing(
source_kind="component", source_id=comp.id
)
stored.annotations.append(
_manual_annotation(
"diameter", "front", ((-4.0, 0.0), (4.0, 0.0))
)
)
project.add_drawing(stored)
w.set_project(project, kernel)
w.set_active_component(comp)
# The stored drawing is re-adopted (not replaced).
assert w._drawing is stored
assert len(w._drawing.annotations) == 1
def test_new_source_creates_and_registers_drawing(self, qapp, kernel):
w = self._widget(qapp)
project = Project()
comp = Component(name="NewComp")
project.components[comp.id] = comp
w.set_project(project, kernel)
w.set_active_component(comp)
assert w._drawing is not None
assert w._drawing.source_kind == "component"
assert w._drawing.source_id == comp.id
assert len(project.drawings) == 1
assert project.drawings[0] is w._drawing
class TestCircleCenterPick:
"""Clicking a circle's centre mark while a distance tool is active
picks a point feature at the circle centre (model coords + radius).
HLR only projects a true circle for some hole shapes (a plain
cylinder discretises into segments), so the render result is built
directly with one guaranteed circle primitive.
"""
def _canvas_with_circle(self, qapp):
from PySide6.QtCore import QPointF
from fluency.technical_drawing import (
DrawingPrimitive,
DrawingRenderResult,
)
from fluency.ui.technical_drawing_widget import DrawingCanvas
# One r4 circle at model (20, 15), drawn at 10× scale at the
# sheet centre: model (20,15) → sheet (200, 150).
circle = DrawingPrimitive(
kind="circle",
points=(),
style="visible",
center=(200.0, 150.0),
radius=40.0,
view_id="top",
)
result = DrawingRenderResult(
primitives=(circle,),
candidates=(),
resolved_annotation_ids=(),
unresolved_annotation_ids=(),
source_fingerprint="",
warnings=(),
view_transforms={"top": (10.0, 0.0, 0.0)},
)
canvas = DrawingCanvas()
canvas.resize(840, 600)
canvas.set_render_result(result)
canvas.set_pick_mode("distance")
return canvas, QPointF
def test_pick_center_mark_returns_point(self, qapp):
canvas, QPointF = self._canvas_with_circle(qapp)
# Sheet → device position of the circle centre.
rect = canvas._sheet_rect()
scale = rect.width() / 420.0
pos = QPointF(rect.x() + 200.0 * scale, rect.y() + (297.0 - 150.0) * scale)
hit = canvas._pick_feature(pos)
assert hit is not None, "clicking the centre mark must hit something"
assert hit["kind"] == "point"
assert hit["view_id"] == "top"
assert hit["radius"] == pytest.approx(4.0, abs=1e-6)
# The picked model point is the inverse-transformed sheet centre:
# (200, 150) at 10× scale → (20, 15).
assert hit["point"] == pytest.approx((20.0, 15.0), abs=1e-9)
def test_distance_tool_accepts_center_then_edge(self, qapp, kernel):
from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
w = TechnicalDrawingWidget()
project, comp = _cylinder_project(kernel)
w.set_project(project, kernel)
w.set_active_component(comp)
assert w._drawing is not None
# First pick: a circle centre at model (10, 12) — the dict a
# centre-mark click produces (see test above).
w._first_pick = {
"kind": "point",
"view_id": "top",
"point": (10.0, 12.0),
"radius": 4.0,
}
# Second pick: a horizontal edge 6 mm above the centre.
second = {
"kind": "segment",
"view_id": "top",
"p1": (0.0, 18.0),
"p2": (20.0, 18.0),
}
w._on_edge_pick(second, "distance")
assert w._drawing.annotations, "a manual dimension must be appended"
ann = w._drawing.annotations[-1]
assert ann.dimension_kind == "length"
assert ann.view_id == "top"
# First anchor is the picked centre; the second is the closest
# point on the edge, straight above it.
assert ann.anchors[0] == pytest.approx((10.0, 12.0), abs=1e-9)
assert ann.anchors[1] == pytest.approx((10.0, 18.0), abs=1e-6)