Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9abeb6266a | |||
| 6b6f7de5ab | |||
| b184ade967 | |||
| 67b73c13b8 | |||
| 813ddc3596 | |||
| 37e5335446 | |||
| 108ad2d5b5 | |||
| 1eee203ba8 | |||
| a00129603c | |||
| 3d63f033f2 | |||
| b29bc11b42 | |||
| 79ad85594e | |||
| 655df71cd8 | |||
| 7072dcee08 |
+39
-1
@@ -35,4 +35,42 @@ uv.lock
|
|||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.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
|
||||||
|
|||||||
Generated
+102
-66
@@ -4,21 +4,16 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- 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$/.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/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.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/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/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/main.py" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.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/tests/test_project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/tests/test_project_io.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/ui/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/gui_ui.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/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/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" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
@@ -80,6 +75,7 @@
|
|||||||
"Python.side_fluency.executor": "Run",
|
"Python.side_fluency.executor": "Run",
|
||||||
"Python.simple_mesh.executor": "Run",
|
"Python.simple_mesh.executor": "Run",
|
||||||
"Python.sketch.executor": "Run",
|
"Python.sketch.executor": "Run",
|
||||||
|
"Python.technical_drawing_widget.executor": "Run",
|
||||||
"Python.vtk_widget.executor": "Run",
|
"Python.vtk_widget.executor": "Run",
|
||||||
"Python.vulkan.executor": "Run",
|
"Python.vulkan.executor": "Run",
|
||||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||||
@@ -89,7 +85,7 @@
|
|||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
"git-widget-placeholder": "feature/occ-migration",
|
"git-widget-placeholder": "feature/occ-migration",
|
||||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
|
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
@@ -104,11 +100,11 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="RecentsManager">
|
<component name="RecentsManager">
|
||||||
<key name="CopyFile.RECENT_KEYS">
|
<key name="CopyFile.RECENT_KEYS">
|
||||||
<recent name="$PROJECT_DIR$" />
|
|
||||||
<recent name="$PROJECT_DIR$/src/fluency" />
|
<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$/src/fluency/rendering" />
|
||||||
<recent name="$PROJECT_DIR$/drawing_modules" />
|
<recent name="$PROJECT_DIR$/drawing_modules" />
|
||||||
<recent name="$PROJECT_DIR$/modules" />
|
|
||||||
</key>
|
</key>
|
||||||
<key name="MoveFile.RECENT_KEYS">
|
<key name="MoveFile.RECENT_KEYS">
|
||||||
<recent name="$PROJECT_DIR$" />
|
<recent name="$PROJECT_DIR$" />
|
||||||
@@ -130,54 +126,6 @@
|
|||||||
<option name="presentableId" value="Default" />
|
<option name="presentableId" value="Default" />
|
||||||
<updated>1703867682707</updated>
|
<updated>1703867682707</updated>
|
||||||
</task>
|
</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">
|
<task id="LOCAL-00007" summary="- changing compos for sketches works">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
<created>1735652081552</created>
|
<created>1735652081552</created>
|
||||||
@@ -482,7 +430,95 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1785915859137</updated>
|
<updated>1785915859137</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="45" />
|
<task id="LOCAL-00045" summary="- arc improvements, fillets, operations, bodys">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1785941246695</created>
|
||||||
|
<option name="number" value="00045" />
|
||||||
|
<option name="presentableId" value="LOCAL-00045" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1785941246695</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00046" summary="- arc improvements, fillets, operations, bodys">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1785948828454</created>
|
||||||
|
<option name="number" value="00046" />
|
||||||
|
<option name="presentableId" value="LOCAL-00046" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1785948828454</updated>
|
||||||
|
</task>
|
||||||
|
<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 />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -503,11 +539,6 @@
|
|||||||
<ignored-roots>
|
<ignored-roots>
|
||||||
<path value="$PROJECT_DIR$/pythonProject" />
|
<path value="$PROJECT_DIR$/pythonProject" />
|
||||||
</ignored-roots>
|
</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 - Cut working - Edit sketch working" />
|
|
||||||
<MESSAGE value="- delete sketch working - added mid point snap - added hovering line with distance" />
|
|
||||||
<MESSAGE value="- Added new buttons and settings" />
|
|
||||||
<MESSAGE value="- Added construction lines switching - Moved callbacks into sketchwidget from main. - Changed reset on right click" />
|
<MESSAGE value="- Added construction lines switching - Moved callbacks into sketchwidget from main. - Changed reset on right click" />
|
||||||
<MESSAGE value="- Added contrain displayed next to line - Slight change to point check from solver." />
|
<MESSAGE value="- Added contrain displayed next to line - Slight change to point check from solver." />
|
||||||
<MESSAGE value="- Added enabling of midpsnap and prepared others - Show dimesnion on hover" />
|
<MESSAGE value="- Added enabling of midpsnap and prepared others - Show dimesnion on hover" />
|
||||||
@@ -528,6 +559,11 @@
|
|||||||
<MESSAGE value="- Render improvements, camera plane, update" />
|
<MESSAGE value="- Render improvements, camera plane, update" />
|
||||||
<MESSAGE value="- added "measurement lines"" />
|
<MESSAGE value="- added "measurement lines"" />
|
||||||
<MESSAGE value="- arc improvements, fillets, operations, bodys" />
|
<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>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
+376
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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.
@@ -1170,41 +1170,27 @@
|
|||||||
<string>Modify</string>
|
<string>Modify</string>
|
||||||
</property>
|
</property>
|
||||||
<layout class="QGridLayout" name="gridLayout_3">
|
<layout class="QGridLayout" name="gridLayout_3">
|
||||||
<item row="1" column="0">
|
<item row="2" column="1">
|
||||||
<widget class="QPushButton" name="pb_combop">
|
<widget class="QPushButton" name="pb_chamfer_op">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Comb</string>
|
<string>Chamfer</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="2" column="0">
|
<item row="2" column="0">
|
||||||
<widget class="QPushButton" name="pb_arrayop">
|
|
||||||
<property name="text">
|
|
||||||
<string>Array</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item row="1" column="1">
|
|
||||||
<widget class="QPushButton" name="pb_moveop">
|
|
||||||
<property name="text">
|
|
||||||
<string>Mve</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item row="2" column="1">
|
|
||||||
<widget class="QPushButton" name="pb_revop">
|
|
||||||
<property name="text">
|
|
||||||
<string>Rev</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item row="3" column="0">
|
|
||||||
<widget class="QPushButton" name="pb_fillet_op">
|
<widget class="QPushButton" name="pb_fillet_op">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Fillet</string>
|
<string>Fillet</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
<item row="6" column="0">
|
||||||
|
<widget class="QPushButton" name="pb_thread">
|
||||||
|
<property name="text">
|
||||||
|
<string>Thread</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
<item row="0" column="0">
|
<item row="0" column="0">
|
||||||
<widget class="QPushButton" name="pb_extrdop">
|
<widget class="QPushButton" name="pb_extrdop">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
@@ -1212,6 +1198,13 @@
|
|||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
<item row="4" column="1">
|
||||||
|
<widget class="QPushButton" name="pb_revop">
|
||||||
|
<property name="text">
|
||||||
|
<string>Rev</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
<item row="0" column="1">
|
<item row="0" column="1">
|
||||||
<widget class="QPushButton" name="pb_cutop">
|
<widget class="QPushButton" name="pb_cutop">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
@@ -1219,17 +1212,31 @@
|
|||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="3" column="1">
|
<item row="4" column="0">
|
||||||
<widget class="QPushButton" name="pb_chamfer">
|
<widget class="QPushButton" name="pb_arrayop">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Chamfer</string>
|
<string>Arry</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="4" column="0">
|
<item row="1" column="0">
|
||||||
<widget class="QPushButton" name="pb_thread">
|
<widget class="QPushButton" name="pb_combop">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Thread</string>
|
<string>Comb</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="1">
|
||||||
|
<widget class="QPushButton" name="pb_moveop">
|
||||||
|
<property name="text">
|
||||||
|
<string>Mve</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="6" column="1">
|
||||||
|
<widget class="QPushButton" name="pb_mirror_op">
|
||||||
|
<property name="text">
|
||||||
|
<string>Mirror</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
|||||||
@@ -628,50 +628,55 @@ class Ui_fluencyCAD(object):
|
|||||||
self.groupBox.setMaximumSize(QSize(200, 16777215))
|
self.groupBox.setMaximumSize(QSize(200, 16777215))
|
||||||
self.gridLayout_3 = QGridLayout(self.groupBox)
|
self.gridLayout_3 = QGridLayout(self.groupBox)
|
||||||
self.gridLayout_3.setObjectName(u"gridLayout_3")
|
self.gridLayout_3.setObjectName(u"gridLayout_3")
|
||||||
self.pb_combop = QPushButton(self.groupBox)
|
self.pb_chamfer_op = QPushButton(self.groupBox)
|
||||||
self.pb_combop.setObjectName(u"pb_combop")
|
self.pb_chamfer_op.setObjectName(u"pb_chamfer_op")
|
||||||
|
|
||||||
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
|
self.gridLayout_3.addWidget(self.pb_chamfer_op, 2, 1, 1, 1)
|
||||||
|
|
||||||
self.pb_arrayop = QPushButton(self.groupBox)
|
|
||||||
self.pb_arrayop.setObjectName(u"pb_arrayop")
|
|
||||||
|
|
||||||
self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1)
|
|
||||||
|
|
||||||
self.pb_moveop = QPushButton(self.groupBox)
|
|
||||||
self.pb_moveop.setObjectName(u"pb_moveop")
|
|
||||||
|
|
||||||
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
|
|
||||||
|
|
||||||
self.pb_revop = QPushButton(self.groupBox)
|
|
||||||
self.pb_revop.setObjectName(u"pb_revop")
|
|
||||||
|
|
||||||
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1)
|
|
||||||
|
|
||||||
self.pb_fillet_op = QPushButton(self.groupBox)
|
self.pb_fillet_op = QPushButton(self.groupBox)
|
||||||
self.pb_fillet_op.setObjectName(u"pb_fillet_op")
|
self.pb_fillet_op.setObjectName(u"pb_fillet_op")
|
||||||
|
|
||||||
self.gridLayout_3.addWidget(self.pb_fillet_op, 3, 0, 1, 1)
|
self.gridLayout_3.addWidget(self.pb_fillet_op, 2, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_thread = QPushButton(self.groupBox)
|
||||||
|
self.pb_thread.setObjectName(u"pb_thread")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_thread, 6, 0, 1, 1)
|
||||||
|
|
||||||
self.pb_extrdop = QPushButton(self.groupBox)
|
self.pb_extrdop = QPushButton(self.groupBox)
|
||||||
self.pb_extrdop.setObjectName(u"pb_extrdop")
|
self.pb_extrdop.setObjectName(u"pb_extrdop")
|
||||||
|
|
||||||
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
|
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_revop = QPushButton(self.groupBox)
|
||||||
|
self.pb_revop.setObjectName(u"pb_revop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_revop, 4, 1, 1, 1)
|
||||||
|
|
||||||
self.pb_cutop = QPushButton(self.groupBox)
|
self.pb_cutop = QPushButton(self.groupBox)
|
||||||
self.pb_cutop.setObjectName(u"pb_cutop")
|
self.pb_cutop.setObjectName(u"pb_cutop")
|
||||||
|
|
||||||
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
|
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
|
||||||
|
|
||||||
self.pb_chamfer = QPushButton(self.groupBox)
|
self.pb_arrayop = QPushButton(self.groupBox)
|
||||||
self.pb_chamfer.setObjectName(u"pb_chamfer")
|
self.pb_arrayop.setObjectName(u"pb_arrayop")
|
||||||
|
|
||||||
self.gridLayout_3.addWidget(self.pb_chamfer, 3, 1, 1, 1)
|
self.gridLayout_3.addWidget(self.pb_arrayop, 4, 0, 1, 1)
|
||||||
|
|
||||||
self.pb_thread = QPushButton(self.groupBox)
|
self.pb_combop = QPushButton(self.groupBox)
|
||||||
self.pb_thread.setObjectName(u"pb_thread")
|
self.pb_combop.setObjectName(u"pb_combop")
|
||||||
|
|
||||||
self.gridLayout_3.addWidget(self.pb_thread, 4, 0, 1, 1)
|
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pb_moveop = QPushButton(self.groupBox)
|
||||||
|
self.pb_moveop.setObjectName(u"pb_moveop")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
self.pb_mirror_op = QPushButton(self.groupBox)
|
||||||
|
self.pb_mirror_op.setObjectName(u"pb_mirror_op")
|
||||||
|
|
||||||
|
self.gridLayout_3.addWidget(self.pb_mirror_op, 6, 1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
|
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
|
||||||
@@ -922,15 +927,16 @@ class Ui_fluencyCAD(object):
|
|||||||
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
|
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
|
||||||
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
|
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
|
||||||
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
||||||
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
self.pb_chamfer_op.setText(QCoreApplication.translate("fluencyCAD", u"Chamfer", None))
|
||||||
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Array", None))
|
|
||||||
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
|
||||||
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
|
||||||
self.pb_fillet_op.setText(QCoreApplication.translate("fluencyCAD", u"Fillet", None))
|
self.pb_fillet_op.setText(QCoreApplication.translate("fluencyCAD", u"Fillet", None))
|
||||||
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
|
|
||||||
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
|
|
||||||
self.pb_chamfer.setText(QCoreApplication.translate("fluencyCAD", u"Chamfer", None))
|
|
||||||
self.pb_thread.setText(QCoreApplication.translate("fluencyCAD", u"Thread", None))
|
self.pb_thread.setText(QCoreApplication.translate("fluencyCAD", u"Thread", None))
|
||||||
|
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
|
||||||
|
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
||||||
|
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
|
||||||
|
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
|
||||||
|
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
||||||
|
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
||||||
|
self.pb_mirror_op.setText(QCoreApplication.translate("fluencyCAD", u"Mirror", None))
|
||||||
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
||||||
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||||
self.pb_body_hide.setText(QCoreApplication.translate("fluencyCAD", u"Hide", None))
|
self.pb_body_hide.setText(QCoreApplication.translate("fluencyCAD", u"Hide", None))
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 267 B |
@@ -16,8 +16,35 @@ from fluency.geometry.base import (
|
|||||||
Point3D,
|
Point3D,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class OCCGeometryObject(GeometryObject):
|
||||||
"""Geometry object wrapper for OpenCASCADE shapes."""
|
"""Geometry object wrapper for OpenCASCADE shapes."""
|
||||||
@@ -446,52 +473,82 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
def fillet(
|
def fillet(
|
||||||
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
|
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Apply fillet to edges."""
|
"""Apply fillet to edges. Skips edges that cannot be filleted."""
|
||||||
shape = self._get_shape(body)
|
|
||||||
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
|
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:
|
# Collect candidate edges
|
||||||
for edge in edges:
|
if edges is not None:
|
||||||
fillet.Add(radius, edge)
|
candidates = list(edges)
|
||||||
else:
|
else:
|
||||||
from OCP.TopExp import TopExp_Explorer
|
from OCP.TopExp import TopExp_Explorer
|
||||||
from OCP.TopAbs import TopAbs_EDGE
|
from OCP.TopAbs import TopAbs_EDGE
|
||||||
from OCP.TopoDS import TopoDS
|
from OCP.TopoDS import TopoDS
|
||||||
|
|
||||||
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||||
|
candidates = []
|
||||||
while explorer.More():
|
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()
|
explorer.Next()
|
||||||
|
|
||||||
fillet.Build()
|
# Add all edges, then Build once — avoids OCC internal crashes
|
||||||
return OCCGeometryObject(fillet.Shape(), {"type": "fillet"})
|
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(
|
def chamfer(
|
||||||
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
|
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Apply chamfer to edges."""
|
"""Apply chamfer to edges. Skips edges that cannot be chamfered."""
|
||||||
shape = self._get_shape(body)
|
|
||||||
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
|
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:
|
# Collect candidate edges
|
||||||
for edge in edges:
|
if edges is not None:
|
||||||
chamfer.Add(size, edge)
|
candidates = list(edges)
|
||||||
else:
|
else:
|
||||||
from OCP.TopExp import TopExp_Explorer
|
from OCP.TopExp import TopExp_Explorer
|
||||||
from OCP.TopAbs import TopAbs_EDGE
|
from OCP.TopAbs import TopAbs_EDGE
|
||||||
from OCP.TopoDS import TopoDS
|
from OCP.TopoDS import TopoDS
|
||||||
|
|
||||||
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||||
|
candidates = []
|
||||||
while explorer.More():
|
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()
|
explorer.Next()
|
||||||
|
|
||||||
chamfer.Build()
|
# Add all edges, then Build once — avoids OCC internal crashes
|
||||||
return OCCGeometryObject(chamfer.Shape(), {"type": "chamfer"})
|
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(
|
def shell(
|
||||||
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
|
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
|
||||||
|
|||||||
@@ -327,9 +327,16 @@ class OCCSketch(SketchInterface):
|
|||||||
start_point: SketchEntity,
|
start_point: SketchEntity,
|
||||||
end_point: SketchEntity,
|
end_point: SketchEntity,
|
||||||
sweep: Optional[float] = None,
|
sweep: Optional[float] = None,
|
||||||
|
register: bool = True,
|
||||||
) -> OCCSketchEntity:
|
) -> OCCSketchEntity:
|
||||||
"""Add an arc (added to solver + tracked).
|
"""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
|
The arc is registered with SolveSpace so its three reference points
|
||||||
are linked: start, end, and centre. SolveSpace's arc entity
|
are linked: start, end, and centre. SolveSpace's arc entity
|
||||||
implicitly enforces ``distance(start, centre) = distance(end, centre)``,
|
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:
|
if center_entity is None or start_entity is None or end_entity is None:
|
||||||
raise ValueError("Arc points not found in sketch")
|
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")
|
raise ValueError("Arc endpoints must already be in the solver")
|
||||||
|
|
||||||
cx, cy = center_entity.geometry
|
cx, cy = center_entity.geometry
|
||||||
@@ -386,17 +397,20 @@ class OCCSketch(SketchInterface):
|
|||||||
# whenever the workplane orientation changes. The normal is
|
# whenever the workplane orientation changes. The normal is
|
||||||
# invalidated by ``clear`` / ``_rebuild_solver`` /
|
# invalidated by ``clear`` / ``_rebuild_solver`` /
|
||||||
# ``set_workplane`` (the workplane reference changes).
|
# ``set_workplane`` (the workplane reference changes).
|
||||||
if self._wp_normal_handle is None:
|
if register:
|
||||||
self._wp_normal_handle = self._make_arc_normal_3d()
|
if self._wp_normal_handle is None:
|
||||||
nm: Any = self._wp_normal_handle
|
self._wp_normal_handle = self._make_arc_normal_3d()
|
||||||
assert nm is not None # _make_arc_normal_3d always returns a handle
|
nm: Any = self._wp_normal_handle
|
||||||
arc_handle = self._solver.add_arc(
|
assert nm is not None # _make_arc_normal_3d always returns a handle
|
||||||
nm,
|
arc_handle = self._solver.add_arc(
|
||||||
center_entity.handle,
|
nm,
|
||||||
start_entity.handle,
|
center_entity.handle,
|
||||||
end_entity.handle,
|
start_entity.handle,
|
||||||
self._wp,
|
end_entity.handle,
|
||||||
)
|
self._wp,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
arc_handle = None
|
||||||
|
|
||||||
entity = OCCSketchEntity(
|
entity = OCCSketchEntity(
|
||||||
entity_id=entity_id,
|
entity_id=entity_id,
|
||||||
@@ -418,6 +432,9 @@ class OCCSketch(SketchInterface):
|
|||||||
"end": end_point.id,
|
"end": end_point.id,
|
||||||
"radius": radius,
|
"radius": radius,
|
||||||
"sweep": sweep,
|
"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
|
# ``original_sweep`` captures the angular span the user drew
|
||||||
# the arc with. When the host geometry (e.g. a rectangle
|
# the arc with. When the host geometry (e.g. a rectangle
|
||||||
# the arc is attached to) resizes, ``_sync_solved_positions``
|
# the arc is attached to) resizes, ``_sync_solved_positions``
|
||||||
@@ -587,6 +604,70 @@ class OCCSketch(SketchInterface):
|
|||||||
pass
|
pass
|
||||||
return all_points, all_lines
|
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:
|
def _drop_external_entities(self) -> set:
|
||||||
"""Remove external entities from local tracking + prune their constraints.
|
"""Remove external entities from local tracking + prune their constraints.
|
||||||
|
|
||||||
@@ -606,6 +687,21 @@ class OCCSketch(SketchInterface):
|
|||||||
self._lines.pop(eid, None)
|
self._lines.pop(eid, None)
|
||||||
self._circles.pop(eid, None)
|
self._circles.pop(eid, None)
|
||||||
self._arcs.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
|
# Also clean lines that USE an external point as an endpoint but
|
||||||
# somehow aren't themselves external (defensive — shouldn't happen
|
# somehow aren't themselves external (defensive — shouldn't happen
|
||||||
# via the public API, but rebuild_solver needs a clean graph).
|
# 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 the set of external (underlay) entity ids currently in the sketch."""
|
||||||
return set(self._external_entity_ids)
|
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.
|
"""Re-project external (underlay) entities from updated source geometry.
|
||||||
|
|
||||||
Called when the 3D body the underlay was projected from has been
|
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
|
body so user geometry constrained to it propagates through the
|
||||||
solver.
|
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:
|
Two paths:
|
||||||
|
|
||||||
* **In-place update** (same topology): when the new projection has
|
* **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.
|
Returns True when the underlay was updated and solved OK.
|
||||||
"""
|
"""
|
||||||
# Flatten the new projection into unique corner positions + segments.
|
# 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
|
tol = self._EXTERNAL_MERGE_TOL
|
||||||
new_pts: List[Tuple[float, float]] = []
|
new_pts: List[Tuple[float, float]] = []
|
||||||
|
|
||||||
@@ -674,7 +791,7 @@ class OCCSketch(SketchInterface):
|
|||||||
return len(new_pts) - 1
|
return len(new_pts) - 1
|
||||||
|
|
||||||
new_segs: List[Tuple[int, int]] = []
|
new_segs: List[Tuple[int, int]] = []
|
||||||
for poly in polylines:
|
for poly in polys:
|
||||||
if len(poly) < 2:
|
if len(poly) < 2:
|
||||||
continue
|
continue
|
||||||
idx = [new_index(float(u), float(v)) for (u, v) in poly]
|
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
|
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:
|
if same_topology and old_ext_points:
|
||||||
# Greedy one-to-one nearest matching old point -> new position.
|
# 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
|
# points and (per the add_point guard) does not auto-anchor a user
|
||||||
# point — which would conflict with the re-bound coincidents below.
|
# point — which would conflict with the re-bound coincidents below.
|
||||||
self._drop_external_entities()
|
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_solver()
|
||||||
self._rebuild_labels()
|
self._rebuild_labels()
|
||||||
|
|
||||||
@@ -952,7 +1076,17 @@ class OCCSketch(SketchInterface):
|
|||||||
elif ctype == "distance":
|
elif ctype == "distance":
|
||||||
if h(ids[0]) is None or h(ids[1]) is None:
|
if h(ids[0]) is None or h(ids[1]) is None:
|
||||||
return False
|
return False
|
||||||
self._solver.distance(h(ids[0]), h(ids[1]), params[0], self._wp)
|
ent0 = self._entities.get(ids[0])
|
||||||
|
ent1 = self._entities.get(ids[1])
|
||||||
|
# Normalise (line, point) -> (point, line) like constrain_distance
|
||||||
|
# does, and drop legacy line-line entries the solver can't hold.
|
||||||
|
if ent0 is not None and ent1 is not None:
|
||||||
|
if ent0.entity_type == "line" and ent1.entity_type == "line":
|
||||||
|
return False
|
||||||
|
if ent0.entity_type == "line" and ent1.entity_type == "point":
|
||||||
|
self._solver.distance(h(ids[1]), h(ids[0]), params[0], self._wp)
|
||||||
|
else:
|
||||||
|
self._solver.distance(h(ids[0]), h(ids[1]), params[0], self._wp)
|
||||||
elif ctype == "angle":
|
elif ctype == "angle":
|
||||||
if h(ids[0]) is None or h(ids[1]) is None:
|
if h(ids[0]) is None or h(ids[1]) is None:
|
||||||
return False
|
return False
|
||||||
@@ -1115,6 +1249,8 @@ class OCCSketch(SketchInterface):
|
|||||||
assert nm is not None
|
assert nm is not None
|
||||||
for aid in sorted(self._arcs.keys()):
|
for aid in sorted(self._arcs.keys()):
|
||||||
arc_data = self._arcs[aid]
|
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")
|
c_id = arc_data.get("center")
|
||||||
s_id = arc_data.get("start")
|
s_id = arc_data.get("start")
|
||||||
e_id = arc_data.get("end")
|
e_id = arc_data.get("end")
|
||||||
@@ -1175,16 +1311,92 @@ class OCCSketch(SketchInterface):
|
|||||||
entity.constraints.append("vrt")
|
entity.constraints.append("vrt")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _point_line_signed_offset(
|
||||||
|
self, point_ent: OCCSketchEntity, line_ent: OCCSketchEntity
|
||||||
|
) -> float:
|
||||||
|
"""Signed perpendicular offset of *point_ent* from *line_ent*, in the
|
||||||
|
solver's sign convention — i.e. the ``valA`` that pins the point on
|
||||||
|
its current side of the line.
|
||||||
|
|
||||||
|
Mirrors SolveSpace's ``PT_LINE_DISTANCE`` equation exactly
|
||||||
|
(a = line start, b = line end, d = a - b):
|
||||||
|
|
||||||
|
proj = dv·(ua − u) − du·(va − v), m = |d|
|
||||||
|
offset = proj / m
|
||||||
|
|
||||||
|
Returns 0.0 when the line is degenerate or the point lies on it
|
||||||
|
(the side is then undefined — callers fall back to the unsigned
|
||||||
|
value).
|
||||||
|
"""
|
||||||
|
if line_ent.id not in self._lines:
|
||||||
|
return 0.0
|
||||||
|
sid, eid2 = self._lines[line_ent.id]
|
||||||
|
s_ent = self._entities.get(sid)
|
||||||
|
e_ent = self._entities.get(eid2)
|
||||||
|
if s_ent is None or e_ent is None or not s_ent.geometry or not e_ent.geometry:
|
||||||
|
return 0.0
|
||||||
|
ua, va = s_ent.geometry
|
||||||
|
ub, vb = e_ent.geometry
|
||||||
|
u, v = point_ent.geometry
|
||||||
|
du = ua - ub
|
||||||
|
dv = va - vb
|
||||||
|
m = math.hypot(du, dv)
|
||||||
|
if m < 1e-12:
|
||||||
|
return 0.0
|
||||||
|
return (dv * (ua - u) - du * (va - v)) / m
|
||||||
|
|
||||||
def constrain_distance(
|
def constrain_distance(
|
||||||
self, entity1: SketchEntity, entity2: SketchEntity, distance: float
|
self, entity1: SketchEntity, entity2: SketchEntity, distance: float
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Constrain distance between two entities."""
|
"""Constrain distance between two entities.
|
||||||
|
|
||||||
|
python-solvespace's ``distance`` accepts point-point and point-line
|
||||||
|
(in that order) only, so line-point pairs are normalised and line-line
|
||||||
|
pairs are rejected with a warning instead of raising TypeError.
|
||||||
|
|
||||||
|
A point constrained to itself with a non-zero value is always
|
||||||
|
inconsistent — reject it up front so the UI never creates a constraint
|
||||||
|
the solver cannot satisfy.
|
||||||
|
"""
|
||||||
e1 = self._entities.get(entity1.id)
|
e1 = self._entities.get(entity1.id)
|
||||||
e2 = self._entities.get(entity2.id)
|
e2 = self._entities.get(entity2.id)
|
||||||
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
|
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
|
||||||
return False
|
return False
|
||||||
self._solver.distance(e1.handle, e2.handle, distance, self._wp)
|
if e1 is e2 and e1.entity_type == "point" and distance != 0.0:
|
||||||
self._record_constraint("distance", (entity1.id, entity2.id), (distance,))
|
logger.warning("distance: refusing point-to-itself constraint")
|
||||||
|
return False
|
||||||
|
|
||||||
|
t1, t2 = e1.entity_type, e2.entity_type
|
||||||
|
if t1 == "line" and t2 == "line":
|
||||||
|
logger.warning(
|
||||||
|
"distance: line-to-line distance is not supported by the solver "
|
||||||
|
"(select a point and a line instead)"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# python-solvespace only accepts (point, line) ordering.
|
||||||
|
if t1 == "line" and t2 == "point":
|
||||||
|
e1, e2 = e2, e1
|
||||||
|
entity1, entity2 = entity2, entity1
|
||||||
|
|
||||||
|
# Point-to-line distance is SIGNED in SolveSpace: the constraint
|
||||||
|
# equation is (signed perpendicular offset) = valA, so a positive
|
||||||
|
# valA always drives the point onto one fixed side of the line
|
||||||
|
# (for a vertical line, always +x). Pass a value whose sign
|
||||||
|
# matches the side the point is currently on so the constraint
|
||||||
|
# pins it there instead of flipping it across the line. The sign
|
||||||
|
# is recorded with the value so a solver rebuild reproduces the
|
||||||
|
# same side.
|
||||||
|
solver_value = distance
|
||||||
|
if e2.entity_type == "line" and e1.entity_type == "point" and distance != 0.0:
|
||||||
|
signed = self._point_line_signed_offset(e1, e2)
|
||||||
|
if abs(signed) > 1e-9:
|
||||||
|
solver_value = math.copysign(distance, signed)
|
||||||
|
|
||||||
|
self._solver.distance(e1.handle, e2.handle, solver_value, self._wp)
|
||||||
|
# Record in the normalised (point-first) order so replayed / legacy
|
||||||
|
# constraint logs are consistent.
|
||||||
|
self._record_constraint("distance", (entity1.id, entity2.id), (solver_value,))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
|
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
|
||||||
@@ -1863,7 +2075,7 @@ class OCCSketch(SketchInterface):
|
|||||||
|
|
||||||
_SNAP_TOL: float = 1e-2 # world-unit tolerance for snapping line endpoints in loop detection
|
_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).
|
"""Current line segments as world-coordinate tuples (uses solved positions).
|
||||||
|
|
||||||
Returns both straight line segments AND tessellated arc segments so
|
Returns both straight line segments AND tessellated arc segments so
|
||||||
@@ -1871,10 +2083,13 @@ class OCCSketch(SketchInterface):
|
|||||||
and external entities are excluded — they're reference geometry and
|
and external entities are excluded — they're reference geometry and
|
||||||
must not affect the sketch profile.
|
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,
|
Tessellation density: roughly 12 segments per π radians of arc sweep,
|
||||||
which gives smooth-looking closed loops for face detection.
|
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 ──
|
# ── Straight line segments ──
|
||||||
for line_id, (sid, eid2) in self._lines.items():
|
for line_id, (sid, eid2) in self._lines.items():
|
||||||
@@ -1888,8 +2103,9 @@ class OCCSketch(SketchInterface):
|
|||||||
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
|
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
|
||||||
segs.append(
|
segs.append(
|
||||||
(
|
(
|
||||||
(float(s_ent.geometry[0]), float(s_ent.geometry[1])),
|
float(s_ent.geometry[0]), float(s_ent.geometry[1]),
|
||||||
(float(e_ent.geometry[0]), float(e_ent.geometry[1])),
|
float(e_ent.geometry[0]), float(e_ent.geometry[1]),
|
||||||
|
line_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1924,7 +2140,7 @@ class OCCSketch(SketchInterface):
|
|||||||
a2 = start_angle + t2 * sweep
|
a2 = start_angle + t2 * sweep
|
||||||
p1 = (cx + radius * math.cos(a1), cy + radius * math.sin(a1))
|
p1 = (cx + radius * math.cos(a1), cy + radius * math.sin(a1))
|
||||||
p2 = (cx + radius * math.cos(a2), cy + radius * math.sin(a2))
|
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
|
return segs
|
||||||
|
|
||||||
@@ -1932,8 +2148,8 @@ class OCCSketch(SketchInterface):
|
|||||||
"""Detect closed loops: polygon cycles from connected lines + each circle.
|
"""Detect closed loops: polygon cycles from connected lines + each circle.
|
||||||
|
|
||||||
Each loop is one of:
|
Each loop is one of:
|
||||||
{"type": "polygon", "points": [(x,y), ...]} (closed, last == first)
|
{"type": "polygon", "points": [(x,y), ...], "entity_ids": [int, ...]} (closed, last == first)
|
||||||
{"type": "circle", "center": (x,y), "radius": r}
|
{"type": "circle", "center": (x,y), "radius": r, "entity_ids": [int]}
|
||||||
Line endpoint coordinates are snapped to ``_SNAP_TOL`` so a closed
|
Line endpoint coordinates are snapped to ``_SNAP_TOL`` so a closed
|
||||||
rectangle's four corners join into one cycle even after solver floating
|
rectangle's four corners join into one cycle even after solver floating
|
||||||
point jitter. Only connected components where every node has degree 2
|
point jitter. Only connected components where every node has degree 2
|
||||||
@@ -1949,11 +2165,13 @@ class OCCSketch(SketchInterface):
|
|||||||
|
|
||||||
reprs: Dict[Any, Tuple[float, float]] = {} # key -> averaged world pt
|
reprs: Dict[Any, Tuple[float, float]] = {} # key -> averaged world pt
|
||||||
edges: List[Tuple[Any, Any]] = []
|
edges: List[Tuple[Any, Any]] = []
|
||||||
for p1, p2 in segs:
|
edge_eids: Dict[Tuple[Any, Any], int] = {} # (k1,k2) -> entity_id
|
||||||
k1, k2 = key(p1), key(p2)
|
for x1, y1, x2, y2, eid in segs:
|
||||||
reprs.setdefault(k1, p1)
|
k1, k2 = key((x1, y1)), key((x2, y2))
|
||||||
reprs.setdefault(k2, p2)
|
reprs.setdefault(k1, (x1, y1))
|
||||||
|
reprs.setdefault(k2, (x2, y2))
|
||||||
edges.append((k1, k2))
|
edges.append((k1, k2))
|
||||||
|
edge_eids[(k1, k2) if k1 < k2 else (k2, k1)] = eid
|
||||||
|
|
||||||
# Undirected adjacency.
|
# Undirected adjacency.
|
||||||
adj: Dict[Any, List[Any]] = {}
|
adj: Dict[Any, List[Any]] = {}
|
||||||
@@ -1980,8 +2198,9 @@ class OCCSketch(SketchInterface):
|
|||||||
if nb not in comp_seen:
|
if nb not in comp_seen:
|
||||||
stack.append(nb)
|
stack.append(nb)
|
||||||
if all(len(adj[n]) == 2 for n in comp) and len(comp) >= 3:
|
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] = []
|
ordered: List[Any] = []
|
||||||
|
eids: List[int] = []
|
||||||
cur = comp[0]
|
cur = comp[0]
|
||||||
prev = None
|
prev = None
|
||||||
for _ in range(len(comp)):
|
for _ in range(len(comp)):
|
||||||
@@ -1989,16 +2208,26 @@ class OCCSketch(SketchInterface):
|
|||||||
nbrs = [nb for nb in adj[cur] if nb != prev]
|
nbrs = [nb for nb in adj[cur] if nb != prev]
|
||||||
if not nbrs:
|
if not nbrs:
|
||||||
break
|
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
|
prev = cur
|
||||||
cur = nbrs[0]
|
cur = nbrs[0]
|
||||||
if len(ordered) == len(comp):
|
if len(ordered) == len(comp):
|
||||||
pts = [reprs[k] for k in ordered]
|
pts = [reprs[k] for k in ordered]
|
||||||
pts.append(pts[0])
|
pts.append(pts[0])
|
||||||
loops.append({"type": "polygon", "points": pts})
|
loops.append(
|
||||||
|
{"type": "polygon", "points": pts, "entity_ids": sorted(set(eids))}
|
||||||
|
)
|
||||||
seen |= comp_seen
|
seen |= comp_seen
|
||||||
|
|
||||||
# Circles are closed loops of their own.
|
# Circles are closed loops of their own.
|
||||||
for cid, (center_id, r) in self._circles.items():
|
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)
|
c_ent = self._entities.get(center_id)
|
||||||
if c_ent and c_ent.geometry and r > 0:
|
if c_ent and c_ent.geometry and r > 0:
|
||||||
loops.append(
|
loops.append(
|
||||||
@@ -2006,6 +2235,7 @@ class OCCSketch(SketchInterface):
|
|||||||
"type": "circle",
|
"type": "circle",
|
||||||
"center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])),
|
"center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])),
|
||||||
"radius": float(r),
|
"radius": float(r),
|
||||||
|
"entity_ids": [cid],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return loops
|
return loops
|
||||||
@@ -2156,8 +2386,7 @@ class OCCSketch(SketchInterface):
|
|||||||
that is the rectangle minus the circle — exactly the
|
that is the rectangle minus the circle — exactly the
|
||||||
"shape within a shape = closed without inner" behavior. A shape nested
|
"shape within a shape = closed without inner" behavior. A shape nested
|
||||||
inside a hole (depth 2) becomes its own solid face again.
|
inside a hole (depth 2) becomes its own solid face again.
|
||||||
|
Returns a list of ``{"outer": loop, "holes": [loop, ...], "depth": int, "entity_ids": [int, ...]}``.
|
||||||
Returns a list of ``{"outer": loop, "holes": [loop, ...], "depth": int}``.
|
|
||||||
"""
|
"""
|
||||||
loops = self.get_closed_loops()
|
loops = self.get_closed_loops()
|
||||||
if not loops:
|
if not loops:
|
||||||
@@ -2181,7 +2410,11 @@ class OCCSketch(SketchInterface):
|
|||||||
# directly nested: depth one greater, and outer contains inner.
|
# directly nested: depth one greater, and outer contains inner.
|
||||||
if depths[j] == depths[i] + 1 and OCCSketch._loop_contains(inner, outer):
|
if depths[j] == depths[i] + 1 and OCCSketch._loop_contains(inner, outer):
|
||||||
holes.append(inner)
|
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
|
return faces
|
||||||
|
|
||||||
def find_face_at(self, x: float, y: float) -> Optional[Dict[str, Any]]:
|
def find_face_at(self, x: float, y: float) -> Optional[Dict[str, Any]]:
|
||||||
@@ -2618,6 +2851,9 @@ class OCCSketch(SketchInterface):
|
|||||||
"end": tuple(e_ent.geometry),
|
"end": tuple(e_ent.geometry),
|
||||||
"radius": radius_val,
|
"radius": radius_val,
|
||||||
"sweep": sweep_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(
|
entities_payload.append(
|
||||||
{
|
{
|
||||||
@@ -2834,12 +3070,30 @@ class OCCSketch(SketchInterface):
|
|||||||
if c_id is None or s_id is None or e_id is None:
|
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)
|
logger.warning("Skipping arc %s during load: endpoints not found", eid)
|
||||||
return
|
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(
|
ent = self.add_arc(
|
||||||
entities_by_id[c_id],
|
entities_by_id[c_id],
|
||||||
radius,
|
radius,
|
||||||
entities_by_id[s_id],
|
entities_by_id[s_id],
|
||||||
entities_by_id[e_id],
|
entities_by_id[e_id],
|
||||||
sweep=sweep,
|
sweep=sweep,
|
||||||
|
register=in_solver,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("Unknown sketch entity type %r; skipping", etype)
|
logger.warning("Unknown sketch entity type %r; skipping", etype)
|
||||||
|
|||||||
@@ -43,9 +43,12 @@ from fluency.models.data_model import (
|
|||||||
Body,
|
Body,
|
||||||
Component,
|
Component,
|
||||||
Connector,
|
Connector,
|
||||||
|
DrawingAnnotation,
|
||||||
|
DrawingView,
|
||||||
Feature,
|
Feature,
|
||||||
Project,
|
Project,
|
||||||
Sketch,
|
Sketch,
|
||||||
|
TechnicalDrawing,
|
||||||
Workplane,
|
Workplane,
|
||||||
)
|
)
|
||||||
from fluency.geometry_occ.kernel import OCCGeometryObject, OCGeometryKernel
|
from fluency.geometry_occ.kernel import OCCGeometryObject, OCGeometryKernel
|
||||||
@@ -226,10 +229,15 @@ def _feature_to_dict(feat: Feature) -> Dict[str, Any]:
|
|||||||
"tangent_propagation": bool(feat.tangent_propagation),
|
"tangent_propagation": bool(feat.tangent_propagation),
|
||||||
"scope": feat.scope,
|
"scope": feat.scope,
|
||||||
"edge_refs": list(feat.edge_refs),
|
"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,
|
"pattern_type": feat.pattern_type,
|
||||||
"count": feat.count,
|
"count": feat.count,
|
||||||
"spacing": feat.spacing,
|
"spacing": feat.spacing,
|
||||||
"direction": [float(v) for v in (feat.direction or (1, 0, 0))],
|
"direction": [float(v) for v in (feat.direction or (1, 0, 0))],
|
||||||
|
"mirror_plane_origin": [float(v) for v in (feat.mirror_plane_origin or (0.0, 0.0, 0.0))],
|
||||||
|
"mirror_plane_normal": [float(v) for v in (feat.mirror_plane_normal or (1, 0, 0))],
|
||||||
|
"keep_original": bool(feat.keep_original),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -253,11 +261,26 @@ def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Fea
|
|||||||
tangent_propagation=bool(data.get("tangent_propagation", False)),
|
tangent_propagation=bool(data.get("tangent_propagation", False)),
|
||||||
scope=data.get("scope", "selected"),
|
scope=data.get("scope", "selected"),
|
||||||
edge_refs=list(data.get("edge_refs") or []),
|
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"),
|
pattern_type=data.get("pattern_type", "linear"),
|
||||||
count=int(data.get("count") or 2),
|
count=int(data.get("count") or 2),
|
||||||
spacing=_to_float(data.get("spacing"), 10.0),
|
spacing=_to_float(data.get("spacing"), 10.0),
|
||||||
direction=tuple(float(v) for v in (data.get("direction") or (1, 0, 0))),
|
direction=tuple(float(v) for v in (data.get("direction") or (1, 0, 0))),
|
||||||
|
mirror_plane_origin=tuple(float(v) for v in (data.get("mirror_plane_origin") or (0.0, 0.0, 0.0))),
|
||||||
|
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")
|
sid = data.get("sketch_id")
|
||||||
if sid and sid in sketches:
|
if sid and sid in sketches:
|
||||||
feat.sketch = sketches[sid]
|
feat.sketch = sketches[sid]
|
||||||
@@ -472,6 +495,8 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
|
|||||||
"offset": _to_float(conn.offset, 0.0),
|
"offset": _to_float(conn.offset, 0.0),
|
||||||
"assembly_component_id": conn.assembly_component_id,
|
"assembly_component_id": conn.assembly_component_id,
|
||||||
"source_obj_id": conn.source_obj_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_ac_id": conn.partner_ac_id,
|
||||||
"partner_connector_id": conn.partner_connector_id,
|
"partner_connector_id": conn.partner_connector_id,
|
||||||
"is_grounded": bool(conn.is_grounded),
|
"is_grounded": bool(conn.is_grounded),
|
||||||
@@ -492,6 +517,8 @@ def _connector_from_dict(data: Dict[str, Any]) -> Connector:
|
|||||||
offset=_to_float(data.get("offset"), 0.0),
|
offset=_to_float(data.get("offset"), 0.0),
|
||||||
assembly_component_id=data.get("assembly_component_id", ""),
|
assembly_component_id=data.get("assembly_component_id", ""),
|
||||||
source_obj_id=data.get("source_obj_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_ac_id = data.get("partner_ac_id")
|
||||||
conn.partner_connector_id = data.get("partner_connector_id")
|
conn.partner_connector_id = data.get("partner_connector_id")
|
||||||
@@ -510,12 +537,22 @@ def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
|
|||||||
"position": _coerce_listlike(ac.position),
|
"position": _coerce_listlike(ac.position),
|
||||||
"rotation": _coerce_listlike(ac.rotation),
|
"rotation": _coerce_listlike(ac.rotation),
|
||||||
"connectors": {cid: _connector_to_dict(c) for cid, c in ac.connectors.items()},
|
"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,
|
"created_at": ac.created_at.isoformat() if ac.created_at else None,
|
||||||
"modified_at": ac.modified_at.isoformat() if ac.modified_at else None,
|
"modified_at": ac.modified_at.isoformat() if ac.modified_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _assembly_component_from_dict(
|
||||||
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
|
data: Dict[str, Any],
|
||||||
|
component: Optional[Component] = None,
|
||||||
|
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||||
|
) -> AssemblyComponent:
|
||||||
ac = AssemblyComponent(
|
ac = AssemblyComponent(
|
||||||
id=_saved_id(data),
|
id=_saved_id(data),
|
||||||
component_id=data.get("component_id", ""),
|
component_id=data.get("component_id", ""),
|
||||||
@@ -527,6 +564,36 @@ def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
|
|||||||
ac.modified_at = _parse_iso(data.get("modified_at"))
|
ac.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
for cid, c_data in (data.get("connectors") or {}).items():
|
for cid, c_data in (data.get("connectors") or {}).items():
|
||||||
ac.connectors[cid] = _connector_from_dict(c_data)
|
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
|
return ac
|
||||||
|
|
||||||
|
|
||||||
@@ -564,8 +631,11 @@ def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
|
|||||||
"modified_at": asm.modified_at.isoformat() if asm.modified_at else None,
|
"modified_at": asm.modified_at.isoformat() if asm.modified_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _assembly_from_dict(
|
||||||
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
|
data: Dict[str, Any],
|
||||||
|
components: Optional[Dict[str, Component]] = None,
|
||||||
|
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||||
|
) -> Assembly:
|
||||||
asm = Assembly(
|
asm = Assembly(
|
||||||
id=_saved_id(data),
|
id=_saved_id(data),
|
||||||
name=data.get("name", "Untitled Assembly"),
|
name=data.get("name", "Untitled Assembly"),
|
||||||
@@ -574,12 +644,133 @@ def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
|
|||||||
asm.created_at = _parse_iso(data.get("created_at"))
|
asm.created_at = _parse_iso(data.get("created_at"))
|
||||||
asm.modified_at = _parse_iso(data.get("modified_at"))
|
asm.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
for cid, ac_data in (data.get("components") or {}).items():
|
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 []:
|
for c_data in data.get("connections") or []:
|
||||||
asm.connections.append(_assembly_connection_from_dict(c_data))
|
asm.connections.append(_assembly_connection_from_dict(c_data))
|
||||||
return asm
|
return asm
|
||||||
|
|
||||||
|
|
||||||
|
def _drawing_view_to_dict(v: DrawingView) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": v.id,
|
||||||
|
"kind": v.kind,
|
||||||
|
"name": v.name,
|
||||||
|
"direction": _coerce_listlike(v.direction),
|
||||||
|
"up_vector": _coerce_listlike(v.up_vector),
|
||||||
|
"show_hidden_lines": bool(v.show_hidden_lines),
|
||||||
|
"show_centerlines": bool(v.show_centerlines),
|
||||||
|
"scale": _to_float(v.scale, 1.0),
|
||||||
|
"sheet_origin": _coerce_listlike(v.sheet_origin),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _drawing_view_from_dict(data: Dict[str, Any]) -> DrawingView:
|
||||||
|
view = DrawingView(
|
||||||
|
id=_saved_id(data),
|
||||||
|
kind=data.get("kind", "front"),
|
||||||
|
name=data.get("name"),
|
||||||
|
direction=_to_3tuple(data.get("direction")) or None,
|
||||||
|
up_vector=_to_3tuple(data.get("up_vector")) or None,
|
||||||
|
show_hidden_lines=bool(data.get("show_hidden_lines", False)),
|
||||||
|
show_centerlines=bool(data.get("show_centerlines", False)),
|
||||||
|
scale=_to_float(data.get("scale"), 1.0),
|
||||||
|
)
|
||||||
|
origin = data.get("sheet_origin")
|
||||||
|
if isinstance(origin, (list, tuple)) and len(origin) >= 2:
|
||||||
|
view.sheet_origin = (_to_float(origin[0]), _to_float(origin[1]))
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
def _drawing_annotation_to_dict(a: DrawingAnnotation) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": a.id,
|
||||||
|
"kind": a.kind,
|
||||||
|
"text": a.text,
|
||||||
|
"visible": bool(a.visible),
|
||||||
|
"references": list(a.references),
|
||||||
|
"sheet_position": _coerce_listlike(a.sheet_position),
|
||||||
|
"view_id": a.view_id,
|
||||||
|
"anchors": [list(pt) for pt in a.anchors],
|
||||||
|
"dimension_kind": a.dimension_kind,
|
||||||
|
"direction": _coerce_listlike(a.direction),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _drawing_annotation_from_dict(data: Dict[str, Any]) -> DrawingAnnotation:
|
||||||
|
ann = DrawingAnnotation(
|
||||||
|
id=_saved_id(data),
|
||||||
|
kind=data.get("kind", "dimension"),
|
||||||
|
text=data.get("text"),
|
||||||
|
visible=bool(data.get("visible", True)),
|
||||||
|
references=list(data.get("references") or []),
|
||||||
|
view_id=data.get("view_id", ""),
|
||||||
|
dimension_kind=data.get("dimension_kind", ""),
|
||||||
|
)
|
||||||
|
pos = data.get("sheet_position")
|
||||||
|
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
|
||||||
|
ann.sheet_position = (_to_float(pos[0]), _to_float(pos[1]))
|
||||||
|
anchors = data.get("anchors") or []
|
||||||
|
for pt in anchors:
|
||||||
|
if isinstance(pt, (list, tuple)) and len(pt) >= 2:
|
||||||
|
ann.anchors.append((_to_float(pt[0]), _to_float(pt[1])))
|
||||||
|
direction = data.get("direction")
|
||||||
|
if isinstance(direction, (list, tuple)) and len(direction) >= 2:
|
||||||
|
ann.direction = (_to_float(direction[0]), _to_float(direction[1]))
|
||||||
|
return ann
|
||||||
|
|
||||||
|
|
||||||
|
def _technical_drawing_to_dict(d: TechnicalDrawing) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": d.id,
|
||||||
|
"name": d.name,
|
||||||
|
"source_kind": d.source_kind,
|
||||||
|
"source_id": d.source_id,
|
||||||
|
"views": [_drawing_view_to_dict(v) for v in d.views],
|
||||||
|
"annotations": [_drawing_annotation_to_dict(a) for a in d.annotations],
|
||||||
|
"title": d.title,
|
||||||
|
"part_number": d.part_number,
|
||||||
|
"material": d.material,
|
||||||
|
"revision": d.revision,
|
||||||
|
"notes": d.notes,
|
||||||
|
"sheet_size": d.sheet_size,
|
||||||
|
"units": d.units,
|
||||||
|
"auto_dimensions": bool(d.auto_dimensions),
|
||||||
|
"auto_views": bool(d.auto_views),
|
||||||
|
"created_at": d.created_at.isoformat() if d.created_at else None,
|
||||||
|
"modified_at": d.modified_at.isoformat() if d.modified_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _technical_drawing_from_dict(data: Dict[str, Any]) -> TechnicalDrawing:
|
||||||
|
drawing = TechnicalDrawing(
|
||||||
|
id=_saved_id(data),
|
||||||
|
name=data.get("name", "Untitled Drawing"),
|
||||||
|
source_kind=data.get("source_kind", "component"),
|
||||||
|
source_id=data.get("source_id", ""),
|
||||||
|
title=data.get("title", ""),
|
||||||
|
part_number=data.get("part_number", ""),
|
||||||
|
material=data.get("material", ""),
|
||||||
|
revision=data.get("revision", ""),
|
||||||
|
notes=data.get("notes", ""),
|
||||||
|
# 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(
|
def _project_to_dict(
|
||||||
project: Project,
|
project: Project,
|
||||||
view_state: Optional[Dict[str, Any]] = None,
|
view_state: Optional[Dict[str, Any]] = None,
|
||||||
@@ -592,6 +783,7 @@ def _project_to_dict(
|
|||||||
"active_assembly": project.active_assembly,
|
"active_assembly": project.active_assembly,
|
||||||
"components": {cid: _component_to_dict(c) for cid, c in project.components.items()},
|
"components": {cid: _component_to_dict(c) for cid, c in project.components.items()},
|
||||||
"assemblies": {aid: _assembly_to_dict(a) for aid, a in project.assemblies.items()},
|
"assemblies": {aid: _assembly_to_dict(a) for aid, a in project.assemblies.items()},
|
||||||
|
"drawings": [_technical_drawing_to_dict(d) for d in project.drawings],
|
||||||
"created_at": project.created_at.isoformat() if project.created_at else None,
|
"created_at": project.created_at.isoformat() if project.created_at else None,
|
||||||
"modified_at": project.modified_at.isoformat() if project.modified_at else None,
|
"modified_at": project.modified_at.isoformat() if project.modified_at else None,
|
||||||
"view_state": view_state or {},
|
"view_state": view_state or {},
|
||||||
@@ -752,6 +944,40 @@ def save_project(
|
|||||||
sketch_files.append((arcname, step_bytes))
|
sketch_files.append((arcname, step_bytes))
|
||||||
manifest["components"][comp_id]["sketches"][sketch_id]["geometry_ref"] = arcname
|
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
|
# Write the ZIP. Use a temp file + rename so a partial write can't
|
||||||
# clobber an existing good file.
|
# clobber an existing good file.
|
||||||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".fluency")
|
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".fluency")
|
||||||
@@ -817,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
|
# 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.
|
# it in now and patch the manifest so _sketch_from_dict sees it.
|
||||||
for comp_id, comp_data in (manifest.get("components") or {}).items():
|
# Applies to both component sketches and instance-local sketches.
|
||||||
for sk_id, sk_data in (comp_data.get("sketches") or {}).items():
|
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")
|
ref = sk_data.get("occ_sketch_ref")
|
||||||
if not ref:
|
if not ref:
|
||||||
continue
|
continue
|
||||||
@@ -845,6 +1072,12 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
|
|||||||
if k in meta:
|
if k in meta:
|
||||||
sk_data[k] = meta[k]
|
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(
|
project = Project(
|
||||||
name=manifest.get("name", "Untitled Project"),
|
name=manifest.get("name", "Untitled Project"),
|
||||||
description=manifest.get("description", ""),
|
description=manifest.get("description", ""),
|
||||||
@@ -864,7 +1097,17 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
for aid, a_data in (manifest.get("assemblies") or {}).items():
|
for aid, a_data in (manifest.get("assemblies") or {}).items():
|
||||||
project.assemblies[aid] = _assembly_from_dict(a_data)
|
project.assemblies[aid] = _assembly_from_dict(
|
||||||
|
a_data,
|
||||||
|
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
|
# After all components are loaded, re-wire connector partner ids so
|
||||||
# they point to the freshly-loaded AssemblyComponents. (The dict
|
# they point to the freshly-loaded AssemblyComponents. (The dict
|
||||||
|
|||||||
@@ -229,17 +229,21 @@ class Feature:
|
|||||||
- "chamfer": bevel a set of edges of the running geometry
|
- "chamfer": bevel a set of edges of the running geometry
|
||||||
(same fields as "fillet"; ``radius`` holds the
|
(same fields as "fillet"; ``radius`` holds the
|
||||||
chamfer size)
|
chamfer size)
|
||||||
|
- "mirror": mirror the running solid across a plane defined by
|
||||||
|
``mirror_plane_origin`` and ``mirror_plane_normal``.
|
||||||
|
When ``keep_original`` is True (the default), the
|
||||||
|
original solid is boolean-union'd with its mirror
|
||||||
- "array" / "pattern": repeat the running solid in a linear or
|
- "array" / "pattern": repeat the running solid in a linear or
|
||||||
circular array. ``pattern_type`` is "linear" or
|
circular array. ``pattern_type`` is "linear" or
|
||||||
"circular"; ``count`` is the total number of items
|
"circular"; ``count`` is the total number of items
|
||||||
(original + copies). Linear uses ``direction``
|
(original + copies). Linear uses ``direction``
|
||||||
(unit vector) and ``spacing`` (mm between adjacent
|
(unit vector) and ``spacing`` (mm between adjacent
|
||||||
items); circular reuses ``axis`` + ``origin`` for the
|
items); circular reuses ``axis`` + ``origin`` for the
|
||||||
rotation axis and ``angle`` for the total angular
|
rotation axis and ``angle`` for the total angular
|
||||||
span in degrees (copies evenly distributed).
|
span in degrees (copies evenly distributed).
|
||||||
- "base": frozen geometry snapshot (``geometry`` field) — used
|
- "base": frozen geometry snapshot (``geometry`` field) — used
|
||||||
to migrate legacy bodies whose original base feature
|
to migrate legacy bodies whose original base feature
|
||||||
is unknown. Never the result of a user operation.
|
is unknown. Never the result of a user operation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
@@ -271,6 +275,14 @@ class Feature:
|
|||||||
scope: str = "selected"
|
scope: str = "selected"
|
||||||
edge_refs: List[str] = field(default_factory=list)
|
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.
|
# "array" / "pattern" features only: repeat the running solid.
|
||||||
# ``pattern_type`` is "linear" or "circular"; ``count`` is the total
|
# ``pattern_type`` is "linear" or "circular"; ``count`` is the total
|
||||||
# number of items including the original. Linear arrays use
|
# number of items including the original. Linear arrays use
|
||||||
@@ -281,6 +293,11 @@ class Feature:
|
|||||||
spacing: float = 10.0
|
spacing: float = 10.0
|
||||||
direction: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
direction: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
# "mirror" feature only: mirror plane definition.
|
||||||
|
mirror_plane_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
|
mirror_plane_normal: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||||
|
keep_original: bool = True
|
||||||
|
|
||||||
created_at: datetime = field(default_factory=datetime.now)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
@@ -470,6 +487,16 @@ class Connector:
|
|||||||
assembly_component_id: str = ""
|
assembly_component_id: str = ""
|
||||||
# Which body/face this connector was placed on (renderer obj_id).
|
# Which body/face this connector was placed on (renderer obj_id).
|
||||||
source_obj_id: str = ""
|
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) ---
|
# --- Rigid-group pairing (set when two connectors are mated) ---
|
||||||
# The id of the partner AssemblyComponent this connector is mated to.
|
# The id of the partner AssemblyComponent this connector is mated to.
|
||||||
@@ -489,6 +516,16 @@ class Connector:
|
|||||||
created_at: datetime = field(default_factory=datetime.now)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
modified_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
|
@dataclass
|
||||||
class AssemblyComponent:
|
class AssemblyComponent:
|
||||||
@@ -512,6 +549,20 @@ class AssemblyComponent:
|
|||||||
# Connectors defined on this component instance.
|
# Connectors defined on this component instance.
|
||||||
connectors: Dict[str, Connector] = field(default_factory=dict)
|
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)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
modified_at: datetime = field(default_factory=datetime.now)
|
modified_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
@@ -522,6 +573,8 @@ class AssemblyComponent:
|
|||||||
x_dir: Tuple[float, float, float],
|
x_dir: Tuple[float, float, float],
|
||||||
source_obj_id: str = "",
|
source_obj_id: str = "",
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
|
entity_type: str = "",
|
||||||
|
normal_flip: bool = False,
|
||||||
) -> Connector:
|
) -> Connector:
|
||||||
"""Add a connector to this component instance."""
|
"""Add a connector to this component instance."""
|
||||||
conn = Connector(
|
conn = Connector(
|
||||||
@@ -531,6 +584,8 @@ class AssemblyComponent:
|
|||||||
x_dir=x_dir,
|
x_dir=x_dir,
|
||||||
assembly_component_id=self.id,
|
assembly_component_id=self.id,
|
||||||
source_obj_id=source_obj_id,
|
source_obj_id=source_obj_id,
|
||||||
|
entity_type=entity_type,
|
||||||
|
normal_flip=normal_flip,
|
||||||
)
|
)
|
||||||
self.connectors[conn.id] = conn
|
self.connectors[conn.id] = conn
|
||||||
self.modified_at = datetime.now()
|
self.modified_at = datetime.now()
|
||||||
@@ -544,6 +599,45 @@ class AssemblyComponent:
|
|||||||
return True
|
return True
|
||||||
return False
|
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
|
@dataclass
|
||||||
class AssemblyConnection:
|
class AssemblyConnection:
|
||||||
@@ -707,6 +801,10 @@ class Project:
|
|||||||
assemblies: Dict[str, Assembly] = field(default_factory=dict)
|
assemblies: Dict[str, Assembly] = field(default_factory=dict)
|
||||||
active_assembly: Optional[str] = None
|
active_assembly: Optional[str] = None
|
||||||
|
|
||||||
|
# Technical drawings keyed to their source component/assembly.
|
||||||
|
# Manual dimensions and view options added in the drawing workbench
|
||||||
|
# are persisted here so they survive save/load.
|
||||||
|
drawings: List["TechnicalDrawing"] = field(default_factory=list)
|
||||||
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
|
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
|
||||||
|
|
||||||
created_at: datetime = field(default_factory=datetime.now)
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
@@ -783,36 +881,21 @@ class Project:
|
|||||||
"""Look up a component by id across all project components."""
|
"""Look up a component by id across all project components."""
|
||||||
return self.components.get(component_id)
|
return self.components.get(component_id)
|
||||||
|
|
||||||
def add_component(self, component: Optional[Component] = None) -> Component:
|
# ── Drawing helpers ──
|
||||||
"""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
|
|
||||||
|
|
||||||
def remove_component(self, component_id: str) -> bool:
|
def get_drawing_for(self, source_kind: str, source_id: str) -> Optional["TechnicalDrawing"]:
|
||||||
"""Remove a component from the project."""
|
"""Return the drawing bound to *(source_kind, source_id)*, if any."""
|
||||||
if component_id in self.components:
|
for drawing in self.drawings:
|
||||||
del self.components[component_id]
|
if drawing.source_kind == source_kind and drawing.source_id == source_id:
|
||||||
if self.active_component == component_id:
|
return drawing
|
||||||
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]
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def set_active_component(self, component_id: Optional[str]) -> None:
|
def add_drawing(self, drawing: "TechnicalDrawing") -> "TechnicalDrawing":
|
||||||
"""Set the active component."""
|
"""Register *drawing* with the project and return it."""
|
||||||
self.active_component = component_id
|
self.drawings.append(drawing)
|
||||||
self.modified_at = datetime.now()
|
self.modified_at = datetime.now()
|
||||||
|
return drawing
|
||||||
|
|
||||||
|
|
||||||
def export_step(self, filepath: str) -> bool:
|
def export_step(self, filepath: str) -> bool:
|
||||||
"""Export all visible bodies to STEP."""
|
"""Export all visible bodies to STEP."""
|
||||||
@@ -881,3 +964,102 @@ class Project:
|
|||||||
for comp in self.components.values():
|
for comp in self.components.values():
|
||||||
sketches.extend(comp.sketches.values())
|
sketches.extend(comp.sketches.values())
|
||||||
return sketches
|
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)
|
||||||
|
|||||||
@@ -157,17 +157,24 @@ class OCCRenderer(Renderer):
|
|||||||
self._highlight_ais: Any = None
|
self._highlight_ais: Any = None
|
||||||
# Overlays for the fillet tool's two picked faces (one AIS per face).
|
# Overlays for the fillet tool's two picked faces (one AIS per face).
|
||||||
self._faces_highlight_ais: List[Any] = []
|
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.
|
# Temporary transparent preview AIS for the live extrude/cut dialog.
|
||||||
self._preview_ais: Any = None
|
self._preview_ais: Any = None
|
||||||
# Smart entity picker gizmo objects (snap markers, axis lines, rings).
|
# Smart entity picker gizmo objects (snap markers, axis lines, rings).
|
||||||
# Keyed by a synthetic id; values are raw AIS_InteractiveObject.
|
# Keyed by a synthetic id; values are raw AIS_InteractiveObject.
|
||||||
self._gizmo_objects: Dict[str, Any] = {}
|
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
|
# World-anchored sketch reference gizmo (a triad at the sketch
|
||||||
# midpoint): part kind ("center" / "axis_x" / … / "plane_xy" …) →
|
# midpoint): part kind ("center" / "axis_x" / … / "plane_xy" …) →
|
||||||
# dict {"ais": [AIS…], "color": rgb, "pick": descriptor}.
|
# dict {"ais": [AIS…], "color": rgb, "pick": descriptor}.
|
||||||
self._sketch_gizmo_parts: Dict[str, Any] = {}
|
self._sketch_gizmo_parts: Dict[str, Any] = {}
|
||||||
# Part kind currently highlighted on hover (for restore-on-leave).
|
# Part kind currently highlighted on hover (for restore-on-leave).
|
||||||
self._sketch_gizmo_highlighted: Optional[str] = None
|
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:
|
def initialize(self, parent_widget: Any) -> bool:
|
||||||
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
|
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
|
||||||
@@ -333,10 +340,16 @@ class OCCRenderer(Renderer):
|
|||||||
shape: Any,
|
shape: Any,
|
||||||
color: Optional[Tuple[float, float, float]] = None,
|
color: Optional[Tuple[float, float, float]] = None,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
|
auto_fit: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Display an OCC ``TopoDS_Shape`` directly via ``AIS_Shape``.
|
"""Display an OCC ``TopoDS_Shape`` directly via ``AIS_Shape``.
|
||||||
|
|
||||||
Returns a unique object ID (or *name* if provided).
|
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.AIS import AIS_Shape
|
||||||
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||||
@@ -396,7 +409,7 @@ class OCCRenderer(Renderer):
|
|||||||
self._objects[obj_id] = robj
|
self._objects[obj_id] = robj
|
||||||
|
|
||||||
# Fit camera on first shape added.
|
# Fit camera on first shape added.
|
||||||
if len(self._objects) == 1:
|
if auto_fit and len(self._objects) == 1:
|
||||||
try:
|
try:
|
||||||
self.fit_camera()
|
self.fit_camera()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -543,6 +556,16 @@ class OCCRenderer(Renderer):
|
|||||||
return False
|
return False
|
||||||
return True
|
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) ──────────────────────────────
|
# ─── Live preview (extrude/cut preview) ──────────────────────────────
|
||||||
|
|
||||||
_PREVIEW_ID = "__extrude_preview__"
|
_PREVIEW_ID = "__extrude_preview__"
|
||||||
@@ -608,6 +631,7 @@ class OCCRenderer(Renderer):
|
|||||||
return
|
return
|
||||||
self.clear_preview()
|
self.clear_preview()
|
||||||
self.clear_face_highlight()
|
self.clear_face_highlight()
|
||||||
|
self.clear_operation_highlight()
|
||||||
self.clear_entity_gizmo()
|
self.clear_entity_gizmo()
|
||||||
# The sketch reference gizmo is scene-anchored — drop it with the rest.
|
# The sketch reference gizmo is scene-anchored — drop it with the rest.
|
||||||
self.remove_sketch_gizmo()
|
self.remove_sketch_gizmo()
|
||||||
@@ -1385,6 +1409,60 @@ class OCCRenderer(Renderer):
|
|||||||
logger.debug("clear_faces_highlight remove failed", exc_info=True)
|
logger.debug("clear_faces_highlight remove failed", exc_info=True)
|
||||||
self._faces_highlight_ais = []
|
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) ───────────
|
# ─── General entity picking (for assembly connectors / snaps) ───────────
|
||||||
|
|
||||||
def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]:
|
def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]:
|
||||||
@@ -1447,6 +1525,10 @@ class OCCRenderer(Renderer):
|
|||||||
"""
|
"""
|
||||||
if shape is None:
|
if shape is None:
|
||||||
return []
|
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.TopoDS import TopoDS
|
||||||
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
|
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: viewport-aligned so connector gizmo matches screen.
|
||||||
x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
|
x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
|
||||||
|
|
||||||
return [
|
res = [
|
||||||
{
|
{
|
||||||
"type": "planar_face",
|
"type": "planar_face",
|
||||||
"position": origin,
|
"position": origin,
|
||||||
@@ -1518,6 +1600,8 @@ class OCCRenderer(Renderer):
|
|||||||
"owner_obj_id": owner_obj_id,
|
"owner_obj_id": owner_obj_id,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
self._classify_cache[cache_key] = res
|
||||||
|
return res
|
||||||
|
|
||||||
elif stype == GeomAbs_Cylinder:
|
elif stype == GeomAbs_Cylinder:
|
||||||
cyl = adaptor.Cylinder()
|
cyl = adaptor.Cylinder()
|
||||||
@@ -1625,6 +1709,7 @@ class OCCRenderer(Renderer):
|
|||||||
"radius": radius,
|
"radius": radius,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
self._classify_cache[cache_key] = results
|
||||||
return results
|
return results
|
||||||
|
|
||||||
# Try edge.
|
# Try edge.
|
||||||
@@ -1668,7 +1753,7 @@ class OCCRenderer(Renderer):
|
|||||||
x = x / xlen
|
x = x / xlen
|
||||||
x_dir = (float(x[0]), float(x[1]), float(x[2]))
|
x_dir = (float(x[0]), float(x[1]), float(x[2]))
|
||||||
|
|
||||||
return [
|
res = [
|
||||||
{
|
{
|
||||||
"type": "edge",
|
"type": "edge",
|
||||||
"position": position,
|
"position": position,
|
||||||
@@ -1678,6 +1763,8 @@ class OCCRenderer(Renderer):
|
|||||||
"owner_obj_id": owner_obj_id,
|
"owner_obj_id": owner_obj_id,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
self._classify_cache[cache_key] = res
|
||||||
|
return res
|
||||||
|
|
||||||
# Try vertex.
|
# Try vertex.
|
||||||
vertex = None
|
vertex = None
|
||||||
@@ -1685,7 +1772,7 @@ class OCCRenderer(Renderer):
|
|||||||
vertex = TopoDS.Vertex_s(shape)
|
vertex = TopoDS.Vertex_s(shape)
|
||||||
p = BRep_Tool.Pnt_s(vertex)
|
p = BRep_Tool.Pnt_s(vertex)
|
||||||
position = (p.X(), p.Y(), p.Z())
|
position = (p.X(), p.Y(), p.Z())
|
||||||
return [
|
res = [
|
||||||
{
|
{
|
||||||
"type": "vertex",
|
"type": "vertex",
|
||||||
"position": position,
|
"position": position,
|
||||||
@@ -1695,9 +1782,12 @@ class OCCRenderer(Renderer):
|
|||||||
"owner_obj_id": owner_obj_id,
|
"owner_obj_id": owner_obj_id,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
self._classify_cache[cache_key] = res
|
||||||
|
return res
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
self._classify_cache[cache_key] = []
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def probe_snap_candidates(
|
def probe_snap_candidates(
|
||||||
@@ -2400,6 +2490,114 @@ class OCCRenderer(Renderer):
|
|||||||
if self._view is not None:
|
if self._view is not None:
|
||||||
self._view.Update()
|
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 ───────────────────────────────────────────
|
# ─── Selection mode control ───────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# When connector gizmo mode is active, standard OCC face/edge/vertex
|
# 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
|
(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
|
return results
|
||||||
|
|
||||||
def recognize_composite_features(
|
def recognize_composite_features(
|
||||||
|
|||||||
@@ -187,19 +187,29 @@ class SolverSketch(SolverSystem):
|
|||||||
def constrain_distance(
|
def constrain_distance(
|
||||||
self, entity_a, entity_b, distance: float
|
self, entity_a, entity_b, distance: float
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Constrain distance between point-point or point-line."""
|
"""Constrain distance between point-point or point-line.
|
||||||
|
|
||||||
|
python-solvespace's ``distance`` accepts (point, line) ordering only,
|
||||||
|
so line-point pairs are normalised; line-line pairs and a point
|
||||||
|
constrained to itself are rejected (the solver cannot hold them).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
handle_a = entity_a.handle if isinstance(entity_a, SolverPoint) else entity_a.handle
|
if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
|
||||||
handle_b = entity_b.handle if isinstance(entity_b, SolverPoint) else entity_b.handle
|
logger.warning("distance: line-to-line distance is not supported")
|
||||||
|
return False
|
||||||
|
if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
|
||||||
|
# Normalise to (point, line) ordering.
|
||||||
|
entity_a, entity_b = entity_b, entity_a
|
||||||
|
if entity_a is entity_b and distance != 0.0:
|
||||||
|
logger.warning("distance: refusing point-to-itself constraint")
|
||||||
|
return False
|
||||||
|
handle_a = entity_a.handle
|
||||||
|
handle_b = entity_b.handle
|
||||||
|
|
||||||
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
|
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
|
||||||
self.distance(handle_a, handle_b, distance, self.wp)
|
self.distance(handle_a, handle_b, distance, self.wp)
|
||||||
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
|
|
||||||
self.distance(handle_b, handle_a, distance, self.wp)
|
|
||||||
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
|
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
|
||||||
self.distance(handle_a, handle_b, distance, self.wp)
|
self.distance(handle_a, handle_b, distance, self.wp)
|
||||||
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
|
|
||||||
self.distance(handle_a, handle_b, distance, self.wp)
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}")
|
logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
@@ -641,6 +641,246 @@ class ArrayDialog(QDialog):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MirrorDialog(QDialog):
|
||||||
|
"""Dialog for the mirror tool: plane selection with live preview.
|
||||||
|
|
||||||
|
Mirrors the selected body across a plane. The user can pick a
|
||||||
|
plane from:
|
||||||
|
|
||||||
|
- A list of existing workplanes (combo box, passed by the caller)
|
||||||
|
- Six axis-aligned presets (XY, XZ, YZ and their negatives)
|
||||||
|
- Custom origin + normal enterable through spinboxes
|
||||||
|
|
||||||
|
``keep_original`` (checked by default) boolean-unions the original
|
||||||
|
solid with its mirror image so you get a symmetric part from half
|
||||||
|
the modeling work.
|
||||||
|
|
||||||
|
A live-preview callback (``set_preview_callback``) fires on every
|
||||||
|
change so the host can show the mirrored shape in the 3D view
|
||||||
|
before committing.
|
||||||
|
|
||||||
|
``get_values()`` returns a dict:
|
||||||
|
``{"mirror_plane_origin", "mirror_plane_normal", "keep_original"}``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: Optional[QWidget] = None,
|
||||||
|
workplanes: Optional[list] = None,
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Mirror Options")
|
||||||
|
self.setMinimumWidth(400)
|
||||||
|
|
||||||
|
self._preview_callback: Optional[Callable[[Any], None]] = None
|
||||||
|
self._workplanes: list = workplanes if workplanes is not None else []
|
||||||
|
|
||||||
|
# Preset plane definitions: (label, normal, origin)
|
||||||
|
self._presets: list = [
|
||||||
|
("XY (Top)", (0, 0, 1), (0, 0, 0)),
|
||||||
|
("XZ (Front)", (0, 1, 0), (0, 0, 0)),
|
||||||
|
("YZ (Right)", (1, 0, 0), (0, 0, 0)),
|
||||||
|
("-XY (Bottom)", (0, 0, -1), (0, 0, 0)),
|
||||||
|
("-XZ (Back)", (0, -1, 0), (0, 0, 0)),
|
||||||
|
("-YZ (Left)", (-1, 0, 0), (0, 0, 0)),
|
||||||
|
]
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# ── Plane source ──
|
||||||
|
src_layout = QHBoxLayout()
|
||||||
|
src_layout.addWidget(QLabel("Mirror plane:"))
|
||||||
|
self._wp_combo = QComboBox()
|
||||||
|
if self._workplanes:
|
||||||
|
for name, _origin, _normal in self._workplanes:
|
||||||
|
self._wp_combo.addItem(name)
|
||||||
|
else:
|
||||||
|
self._wp_combo.addItem("(no workplanes)")
|
||||||
|
self._wp_combo.setEnabled(False)
|
||||||
|
self._wp_combo.setToolTip("Pick an existing workplane as the mirror plane.")
|
||||||
|
src_layout.addWidget(self._wp_combo, 1)
|
||||||
|
layout.addLayout(src_layout)
|
||||||
|
|
||||||
|
# ── Preset radio buttons ──
|
||||||
|
preset_label = QLabel("Preset planes:")
|
||||||
|
layout.addWidget(preset_label)
|
||||||
|
|
||||||
|
self._preset_group = QButtonGroup(self)
|
||||||
|
preset_grid = QGridLayout()
|
||||||
|
for idx, (label, normal, origin) in enumerate(self._presets):
|
||||||
|
btn = QRadioButton(label)
|
||||||
|
btn.setChecked(idx == 0) # XY default
|
||||||
|
self._preset_group.addButton(btn, idx)
|
||||||
|
preset_grid.addWidget(btn, idx // 2, idx % 2)
|
||||||
|
layout.addLayout(preset_grid)
|
||||||
|
|
||||||
|
# ── Custom origin ──
|
||||||
|
line1 = QFrame()
|
||||||
|
line1.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
line1.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
layout.addWidget(line1)
|
||||||
|
|
||||||
|
origin_label = QLabel("Plane origin (mm):")
|
||||||
|
layout.addWidget(origin_label)
|
||||||
|
origin_row = QHBoxLayout()
|
||||||
|
origin_row.addWidget(QLabel("X:"))
|
||||||
|
self._origin_x = QDoubleSpinBox()
|
||||||
|
self._origin_x.setRange(-100000.0, 100000.0)
|
||||||
|
self._origin_x.setDecimals(2)
|
||||||
|
self._origin_x.setValue(0.0)
|
||||||
|
origin_row.addWidget(self._origin_x)
|
||||||
|
origin_row.addWidget(QLabel("Y:"))
|
||||||
|
self._origin_y = QDoubleSpinBox()
|
||||||
|
self._origin_y.setRange(-100000.0, 100000.0)
|
||||||
|
self._origin_y.setDecimals(2)
|
||||||
|
self._origin_y.setValue(0.0)
|
||||||
|
origin_row.addWidget(self._origin_y)
|
||||||
|
origin_row.addWidget(QLabel("Z:"))
|
||||||
|
self._origin_z = QDoubleSpinBox()
|
||||||
|
self._origin_z.setRange(-100000.0, 100000.0)
|
||||||
|
self._origin_z.setDecimals(2)
|
||||||
|
self._origin_z.setValue(0.0)
|
||||||
|
origin_row.addWidget(self._origin_z)
|
||||||
|
layout.addLayout(origin_row)
|
||||||
|
|
||||||
|
# ── Custom normal ──
|
||||||
|
normal_label = QLabel("Plane normal:")
|
||||||
|
layout.addWidget(normal_label)
|
||||||
|
normal_row = QHBoxLayout()
|
||||||
|
normal_row.addWidget(QLabel("X:"))
|
||||||
|
self._normal_x = QDoubleSpinBox()
|
||||||
|
self._normal_x.setRange(-1.0, 1.0)
|
||||||
|
self._normal_x.setSingleStep(1.0)
|
||||||
|
self._normal_x.setDecimals(0)
|
||||||
|
self._normal_x.setValue(0.0)
|
||||||
|
normal_row.addWidget(self._normal_x)
|
||||||
|
normal_row.addWidget(QLabel("Y:"))
|
||||||
|
self._normal_y = QDoubleSpinBox()
|
||||||
|
self._normal_y.setRange(-1.0, 1.0)
|
||||||
|
self._normal_y.setSingleStep(1.0)
|
||||||
|
self._normal_y.setDecimals(0)
|
||||||
|
self._normal_y.setValue(0.0)
|
||||||
|
normal_row.addWidget(self._normal_y)
|
||||||
|
normal_row.addWidget(QLabel("Z:"))
|
||||||
|
self._normal_z = QDoubleSpinBox()
|
||||||
|
self._normal_z.setRange(-1.0, 1.0)
|
||||||
|
self._normal_z.setSingleStep(1.0)
|
||||||
|
self._normal_z.setDecimals(0)
|
||||||
|
self._normal_z.setValue(1.0)
|
||||||
|
normal_row.addWidget(self._normal_z)
|
||||||
|
layout.addLayout(normal_row)
|
||||||
|
|
||||||
|
# ── Keep original ──
|
||||||
|
self._keep_original_cb = QCheckBox("Keep original")
|
||||||
|
self._keep_original_cb.setChecked(True)
|
||||||
|
self._keep_original_cb.setToolTip(
|
||||||
|
"When checked, the original solid is kept and union'd with its mirror image."
|
||||||
|
)
|
||||||
|
layout.addWidget(self._keep_original_cb)
|
||||||
|
|
||||||
|
# ── Buttons ──
|
||||||
|
line2 = QFrame()
|
||||||
|
line2.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
line2.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
layout.addWidget(line2)
|
||||||
|
|
||||||
|
btn_layout = QHBoxLayout()
|
||||||
|
ok_btn = QPushButton("Mirror")
|
||||||
|
ok_btn.clicked.connect(self.accept)
|
||||||
|
cancel_btn = QPushButton("Cancel")
|
||||||
|
cancel_btn.clicked.connect(self.reject)
|
||||||
|
btn_layout.addWidget(ok_btn)
|
||||||
|
btn_layout.addWidget(cancel_btn)
|
||||||
|
layout.addLayout(btn_layout)
|
||||||
|
|
||||||
|
# ── Wire signals ──
|
||||||
|
self._wp_combo.currentIndexChanged.connect(self._on_workplane_selected)
|
||||||
|
self._preset_group.buttonClicked.connect(self._on_preset_changed)
|
||||||
|
for w in (self._origin_x, self._origin_y, self._origin_z,
|
||||||
|
self._normal_x, self._normal_y, self._normal_z):
|
||||||
|
w.valueChanged.connect(self._emit_preview)
|
||||||
|
self._keep_original_cb.toggled.connect(self._emit_preview)
|
||||||
|
|
||||||
|
# Set initial values from the first preset (XY)
|
||||||
|
self._on_preset_changed(self._preset_group.button(0))
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_workplane_selected(self, index: int) -> None:
|
||||||
|
"""Fill origin / normal from the selected workplane."""
|
||||||
|
if index < 0 or index >= len(self._workplanes):
|
||||||
|
return
|
||||||
|
_name, origin, normal = self._workplanes[index]
|
||||||
|
self._origin_x.setValue(float(origin[0]))
|
||||||
|
self._origin_y.setValue(float(origin[1]))
|
||||||
|
self._origin_z.setValue(float(origin[2]))
|
||||||
|
self._normal_x.setValue(float(normal[0]))
|
||||||
|
self._normal_y.setValue(float(normal[1]))
|
||||||
|
self._normal_z.setValue(float(normal[2]))
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def _on_preset_changed(self, btn: QRadioButton) -> None:
|
||||||
|
"""Fill origin / normal from the selected preset."""
|
||||||
|
idx = self._preset_group.id(btn)
|
||||||
|
if idx < 0 or idx >= len(self._presets):
|
||||||
|
return
|
||||||
|
_label, normal, origin = self._presets[idx]
|
||||||
|
self._origin_x.setValue(float(origin[0]))
|
||||||
|
self._origin_y.setValue(float(origin[1]))
|
||||||
|
self._origin_z.setValue(float(origin[2]))
|
||||||
|
self._normal_x.setValue(float(normal[0]))
|
||||||
|
self._normal_y.setValue(float(normal[1]))
|
||||||
|
self._normal_z.setValue(float(normal[2]))
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def _emit_preview(self) -> None:
|
||||||
|
"""Fire the preview callback (if installed) with current values."""
|
||||||
|
if self._preview_callback is None:
|
||||||
|
return
|
||||||
|
values = self.get_values()
|
||||||
|
self._preview_callback(values)
|
||||||
|
|
||||||
|
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
|
||||||
|
"""Install a callback for live 3D preview of the mirrored shape.
|
||||||
|
|
||||||
|
The callback receives a ``dict`` with keys
|
||||||
|
``mirror_plane_origin``, ``mirror_plane_normal``, and
|
||||||
|
``keep_original``, or *None* to clear the preview.
|
||||||
|
"""
|
||||||
|
self._preview_callback = callback
|
||||||
|
if callback is not None:
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def get_values(self) -> Dict[str, Any]:
|
||||||
|
"""Return the current mirror parameters as a dict.
|
||||||
|
|
||||||
|
Keys: ``mirror_plane_origin`` (3-tuple), ``mirror_plane_normal``
|
||||||
|
(3-tuple, auto-normalized), ``keep_original`` (bool).
|
||||||
|
"""
|
||||||
|
normal = (
|
||||||
|
self._normal_x.value(),
|
||||||
|
self._normal_y.value(),
|
||||||
|
self._normal_z.value(),
|
||||||
|
)
|
||||||
|
# Auto-normalize the normal vector (zero-vector → +Z).
|
||||||
|
import math as _math
|
||||||
|
nx, ny, nz = normal
|
||||||
|
norm = _math.sqrt(nx * nx + ny * ny + nz * nz)
|
||||||
|
if norm < 1e-12:
|
||||||
|
normal = (0.0, 0.0, 1.0)
|
||||||
|
else:
|
||||||
|
normal = (nx / norm, ny / norm, nz / norm)
|
||||||
|
return {
|
||||||
|
"mirror_plane_origin": (
|
||||||
|
self._origin_x.value(),
|
||||||
|
self._origin_y.value(),
|
||||||
|
self._origin_z.value(),
|
||||||
|
),
|
||||||
|
"mirror_plane_normal": normal,
|
||||||
|
"keep_original": self._keep_original_cb.isChecked(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class WorkplaneOrientationDialog(QDialog):
|
class WorkplaneOrientationDialog(QDialog):
|
||||||
"""Modal dialog to choose the orientation of a new workplane.
|
"""Modal dialog to choose the orientation of a new workplane.
|
||||||
|
|
||||||
|
|||||||
+2917
-260
File diff suppressed because it is too large
Load Diff
+477
-70
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import warnings
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -48,6 +49,24 @@ from fluency.rendering.render_backend import (
|
|||||||
logger = logging.getLogger(__name__)
|
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 ────────────────────────────────────────
|
# ── Background render thread ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -153,6 +172,77 @@ class _AssemblyRenderThread(QThread):
|
|||||||
self.error.emit(str(e))
|
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 ───────────────────────────────────────────────────
|
# ── Render window ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -777,17 +867,63 @@ class RenderWindow(QMainWindow):
|
|||||||
self._status_badge.setStyleSheet("color: #a6e3a1; font-size: 11px; padding: 2px;")
|
self._status_badge.setStyleSheet("color: #a6e3a1; font-size: 11px; padding: 2px;")
|
||||||
|
|
||||||
def _cancel_active_thread(self):
|
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:
|
if self._active_mode == "preview" and self._preview_thread:
|
||||||
self._preview_thread.cancel()
|
self._stop_thread(self._preview_thread)
|
||||||
self._preview_thread.terminate()
|
|
||||||
self._preview_thread.wait(2000)
|
|
||||||
elif self._active_mode == "render" and self._render_thread:
|
elif self._active_mode == "render" and self._render_thread:
|
||||||
self._render_thread.cancel()
|
self._stop_thread(self._render_thread)
|
||||||
self._render_thread.terminate()
|
|
||||||
self._render_thread.wait(2000)
|
|
||||||
self._active_mode = None
|
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):
|
def _set_buttons_rendering(self, mode: str):
|
||||||
"""Disable buttons while rendering."""
|
"""Disable buttons while rendering."""
|
||||||
self._preview_btn.setEnabled(False)
|
self._preview_btn.setEnabled(False)
|
||||||
@@ -974,10 +1110,8 @@ class RenderWindow(QMainWindow):
|
|||||||
|
|
||||||
# Kill both possible threads
|
# Kill both possible threads
|
||||||
for thread in (self._preview_thread, self._render_thread):
|
for thread in (self._preview_thread, self._render_thread):
|
||||||
if thread and thread.isRunning():
|
# block=True: at window close a thread must not outlive the app.
|
||||||
thread.cancel()
|
self._stop_thread(thread, block=True)
|
||||||
thread.terminate()
|
|
||||||
thread.wait(2000)
|
|
||||||
|
|
||||||
# Clean up temp mesh file
|
# Clean up temp mesh file
|
||||||
if self._mesh_path and os.path.exists(self._mesh_path):
|
if self._mesh_path and os.path.exists(self._mesh_path):
|
||||||
@@ -1009,17 +1143,31 @@ class RenderTabContent(QWidget):
|
|||||||
self._backend = None
|
self._backend = None
|
||||||
self._mesh_path: Optional[str] = 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)
|
# Assembly support: list of (mesh_path, RenderMaterial)
|
||||||
self._assembly_parts: list = []
|
self._assembly_parts: list = []
|
||||||
|
|
||||||
# Rendering threads & images
|
# Rendering threads & images
|
||||||
self._render_thread: Optional[_RenderThread] = None
|
self._render_thread: Optional[_RenderThread] = None
|
||||||
self._preview_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_image: Optional[np.ndarray] = None
|
||||||
self._last_preview: Optional[np.ndarray] = None
|
self._last_preview: Optional[np.ndarray] = None
|
||||||
self._camera: Optional[RenderCamera] = None
|
self._camera: Optional[RenderCamera] = None
|
||||||
self._ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5)
|
self._ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5)
|
||||||
self._active_mode: Optional[str] = None
|
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._auto_preview_timer: Optional[QTimer] = None
|
||||||
|
|
||||||
self._init_ui()
|
self._init_ui()
|
||||||
@@ -1031,22 +1179,39 @@ class RenderTabContent(QWidget):
|
|||||||
def set_shape(self, shape, camera: Optional[RenderCamera] = None) -> None:
|
def set_shape(self, shape, camera: Optional[RenderCamera] = None) -> None:
|
||||||
"""Load a new OCC TopoDS_Shape for rendering.
|
"""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
|
*camera* — if provided, overrides the stored camera. Pass the
|
||||||
viewport\'s render camera to match the 3D view framing.
|
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
|
self._shape = shape
|
||||||
if camera is not None:
|
if camera is not None:
|
||||||
self._camera = camera
|
self._camera = self._apply_framing(camera)
|
||||||
self._last_image = None
|
self._last_image = None
|
||||||
self._last_preview = None
|
self._last_preview = None
|
||||||
self._image_label.setPixmap(QPixmap())
|
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._status_badge.setText("")
|
||||||
self._export_btn.setEnabled(False)
|
self._export_btn.setEnabled(False)
|
||||||
self._prepare_mesh()
|
|
||||||
self._populate_camera_controls()
|
self._populate_camera_controls()
|
||||||
# Trigger auto-preview when a new shape is loaded
|
self._start_meshing()
|
||||||
self._schedule_auto_preview()
|
|
||||||
|
|
||||||
def get_camera(self) -> Optional[RenderCamera]:
|
def get_camera(self) -> Optional[RenderCamera]:
|
||||||
"""Return the current camera (from UI controls or initial)."""
|
"""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
|
*parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples
|
||||||
where the second element is an optional material preset name.
|
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._shape = None
|
||||||
self._mesh_path = None
|
self._mesh_path = None
|
||||||
self._assembly_parts = []
|
self._assembly_parts = []
|
||||||
|
self._assembly_pending = list(parts)
|
||||||
|
self._assembly_bounds = None
|
||||||
if camera is not None:
|
if camera is not None:
|
||||||
self._camera = camera
|
self._camera = self._apply_framing(camera)
|
||||||
self._last_image = None
|
self._last_image = None
|
||||||
self._last_preview = None
|
self._last_preview = None
|
||||||
self._image_label.setPixmap(QPixmap())
|
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._status_badge.setText("")
|
||||||
self._export_btn.setEnabled(False)
|
self._export_btn.setEnabled(False)
|
||||||
self._prepare_assembly_mesh(parts)
|
|
||||||
self._populate_camera_controls()
|
self._populate_camera_controls()
|
||||||
self._schedule_auto_preview()
|
self._start_meshing()
|
||||||
|
|
||||||
def set_camera(self, camera: RenderCamera) -> None:
|
def set_camera(self, camera: RenderCamera) -> None:
|
||||||
"""Update the render camera from an external source (e.g. 3D viewport).
|
"""Update the render camera from an external source (e.g. 3D viewport).
|
||||||
@@ -1086,7 +1260,7 @@ class RenderTabContent(QWidget):
|
|||||||
"""
|
"""
|
||||||
if camera is None:
|
if camera is None:
|
||||||
return
|
return
|
||||||
self._camera = camera
|
self._camera = self._apply_framing(camera)
|
||||||
self._cam_fov_spin.blockSignals(True)
|
self._cam_fov_spin.blockSignals(True)
|
||||||
try:
|
try:
|
||||||
self._cam_fov_spin.setValue(camera.fov)
|
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.
|
# For full renders or idle: schedule a preview if auto-preview is on.
|
||||||
self._schedule_auto_preview()
|
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:
|
def cleanup(self) -> None:
|
||||||
"""Stop threads and delete temp files. Call when the tab is hidden/closed."""
|
"""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():
|
if self._auto_preview_timer and self._auto_preview_timer.isActive():
|
||||||
self._auto_preview_timer.stop()
|
self._auto_preview_timer.stop()
|
||||||
for thread in (self._preview_thread, self._render_thread):
|
# block=True: at tab exit / app shutdown a thread must not outlive
|
||||||
if thread and thread.isRunning():
|
# the owning widget.
|
||||||
thread.cancel()
|
for thread in (self._mesh_thread, self._preview_thread, self._render_thread):
|
||||||
thread.terminate()
|
self._stop_thread(thread, block=True)
|
||||||
thread.wait(2000)
|
self._mesh_thread = None
|
||||||
if self._mesh_path and os.path.exists(self._mesh_path):
|
self._preview_thread = None
|
||||||
try:
|
self._render_thread = None
|
||||||
os.unlink(self._mesh_path)
|
self._active_mode = None
|
||||||
except OSError:
|
# Delete temp PLY files (single shape plus all assembly parts).
|
||||||
pass
|
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
|
self._mesh_path = None
|
||||||
|
|
||||||
# ── UI Setup ───────────────────────────────────────────────────
|
# ── UI Setup ───────────────────────────────────────────────────
|
||||||
@@ -1248,6 +1450,23 @@ class RenderTabContent(QWidget):
|
|||||||
|
|
||||||
layout.addWidget(camera_gb)
|
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 ────────────────────────────────────────────────
|
# ── Lighting ────────────────────────────────────────────────
|
||||||
light_gb = QGroupBox("Lighting")
|
light_gb = QGroupBox("Lighting")
|
||||||
light_layout = QVBoxLayout(light_gb)
|
light_layout = QVBoxLayout(light_gb)
|
||||||
@@ -1452,44 +1671,72 @@ class RenderTabContent(QWidget):
|
|||||||
self._preview_btn.setEnabled(False)
|
self._preview_btn.setEnabled(False)
|
||||||
self._preview_btn.setToolTip("No render backend installed (pip install mitsuba)")
|
self._preview_btn.setToolTip("No render backend installed (pip install mitsuba)")
|
||||||
|
|
||||||
def _prepare_mesh(self):
|
def _start_meshing(self):
|
||||||
if self._shape is None:
|
"""Kick off background tessellation of the current shape/assembly.
|
||||||
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 _prepare_assembly_mesh(self, parts: list):
|
The GUI thread is never blocked: the tab shows "Tessellating…"
|
||||||
"""Tessellate multiple shapes to separate PLY files.
|
until the mesh is ready, then the auto-preview is scheduled.
|
||||||
|
|
||||||
*parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples.
|
|
||||||
Each material preset name is resolved via ``get_preset``.
|
|
||||||
"""
|
"""
|
||||||
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 = []
|
def _on_mesh_ready(self, mesh_path: str, gen: int) -> None:
|
||||||
first_bounds = None
|
"""Background tessellation finished (single shape)."""
|
||||||
for shape, mat_name in parts:
|
if gen != self._mesh_generation:
|
||||||
try:
|
# The load was replaced mid-tessellation — discard the stale mesh.
|
||||||
mesh_path = occ_shape_to_ply(shape, linear_deflection=0.1, angular_deflection=0.15)
|
_unlink_quiet(mesh_path)
|
||||||
material = get_preset(mat_name) if mat_name else get_preset("Brushed Steel")
|
return
|
||||||
self._assembly_parts.append((mesh_path, material))
|
self._mesh_path = mesh_path
|
||||||
if first_bounds is None:
|
if self._camera is None and self._backend is not None:
|
||||||
first_bounds = occ_shape_bounds(shape)
|
mn, mx = occ_shape_bounds(self._shape)
|
||||||
except Exception as e:
|
self._camera = self._backend.default_camera_from_bounds(mn, mx)
|
||||||
logger.warning(f"Failed to tessellate assembly part: {e}")
|
self._populate_camera_controls()
|
||||||
if first_bounds and self._camera is None:
|
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
|
mn, mx = first_bounds
|
||||||
self._camera = self._backend.default_camera_from_bounds(mn, mx)
|
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")
|
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):
|
def _setup_auto_preview(self):
|
||||||
self._auto_preview_timer = QTimer(self)
|
self._auto_preview_timer = QTimer(self)
|
||||||
@@ -1503,12 +1750,132 @@ class RenderTabContent(QWidget):
|
|||||||
return
|
return
|
||||||
self._cam_fov_spin.setValue(self._camera.fov)
|
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):
|
def _schedule_auto_preview(self):
|
||||||
if not self._auto_preview_cb.isChecked():
|
if not self._auto_preview_cb.isChecked():
|
||||||
return
|
return
|
||||||
if self._active_mode is not None:
|
if self._active_mode is not None:
|
||||||
return
|
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
|
return
|
||||||
self._auto_preview_timer.start(500)
|
self._auto_preview_timer.start(500)
|
||||||
|
|
||||||
@@ -1560,15 +1927,55 @@ class RenderTabContent(QWidget):
|
|||||||
|
|
||||||
def _cancel_active_thread(self):
|
def _cancel_active_thread(self):
|
||||||
if self._active_mode == "preview" and self._preview_thread:
|
if self._active_mode == "preview" and self._preview_thread:
|
||||||
self._preview_thread.cancel()
|
self._stop_thread(self._preview_thread)
|
||||||
self._preview_thread.terminate()
|
|
||||||
self._preview_thread.wait(2000)
|
|
||||||
elif self._active_mode == "render" and self._render_thread:
|
elif self._active_mode == "render" and self._render_thread:
|
||||||
self._render_thread.cancel()
|
self._stop_thread(self._render_thread)
|
||||||
self._render_thread.terminate()
|
|
||||||
self._render_thread.wait(2000)
|
|
||||||
self._active_mode = None
|
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):
|
def _set_buttons_rendering(self, mode: str):
|
||||||
self._preview_btn.setEnabled(False)
|
self._preview_btn.setEnabled(False)
|
||||||
self._render_btn.setEnabled(False)
|
self._render_btn.setEnabled(False)
|
||||||
|
|||||||
+295
-57
@@ -260,6 +260,7 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._move_anchor_orig: Optional[QPoint] = None
|
self._move_anchor_orig: Optional[QPoint] = None
|
||||||
self._move_orig_positions: Dict[int, Tuple[float, float]] = {}
|
self._move_orig_positions: Dict[int, Tuple[float, float]] = {}
|
||||||
self._move_active: bool = False
|
self._move_active: bool = False
|
||||||
|
self._move_did_move: bool = False
|
||||||
|
|
||||||
# Auto-constraint tracking on snap
|
# Auto-constraint tracking on snap
|
||||||
self._snap_point_target: Optional[OCCSketchEntity] = None
|
self._snap_point_target: Optional[OCCSketchEntity] = None
|
||||||
@@ -547,8 +548,12 @@ class Sketch2DWidget(QWidget):
|
|||||||
end_uv[0], end_uv[1]
|
end_uv[0], end_uv[1]
|
||||||
)
|
)
|
||||||
# sweep=None → renderer computes shortest-path arc
|
# 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(
|
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
|
imported += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -811,6 +816,7 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._move_anchor_orig = None
|
self._move_anchor_orig = None
|
||||||
self._move_orig_positions = {}
|
self._move_orig_positions = {}
|
||||||
self._move_active = False
|
self._move_active = False
|
||||||
|
self._move_did_move = False
|
||||||
self._hovered_face = None
|
self._hovered_face = None
|
||||||
self._snap_point_target = None
|
self._snap_point_target = None
|
||||||
self._snap_line_target = None
|
self._snap_line_target = None
|
||||||
@@ -863,19 +869,17 @@ class Sketch2DWidget(QWidget):
|
|||||||
constraint multi-select, then emits ``constrain_done`` so the
|
constraint multi-select, then emits ``constrain_done`` so the
|
||||||
toolbar buttons uncheck themselves.
|
toolbar buttons uncheck themselves.
|
||||||
"""
|
"""
|
||||||
if not self._is_drawing_tool_active():
|
was_active = self._is_drawing_tool_active()
|
||||||
return
|
|
||||||
self._mode = None
|
|
||||||
self._draw_buffer = []
|
self._draw_buffer = []
|
||||||
self._dynamic_line_end = None
|
self._dynamic_line_end = None
|
||||||
self._selected_entities = []
|
self._selected_entities = []
|
||||||
self._hovered_constraint_idx = -1
|
self._hovered_constraint_idx = -1
|
||||||
self._arc_accum_sweep = 0.0
|
self._arc_accum_sweep = 0.0
|
||||||
self._arc_prev_angle = None
|
self._arc_prev_angle = None
|
||||||
# Invalidate any pending dimension-input prompt so the value
|
|
||||||
# dialog (if one is open elsewhere) doesn't latch onto stale state.
|
|
||||||
self._pending_distance_val = None
|
self._pending_distance_val = None
|
||||||
self.constrain_done.emit()
|
if was_active:
|
||||||
|
self._mode = None
|
||||||
|
self.constrain_done.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def set_construct_mode(self, enabled: bool):
|
def set_construct_mode(self, enabled: bool):
|
||||||
@@ -930,13 +934,12 @@ class Sketch2DWidget(QWidget):
|
|||||||
|
|
||||||
# ─── Snapping ─────────────────────────────────────────────────────────
|
# ─── Snapping ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _find_nearest_point(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
|
def _find_nearest_point(self, pos: QPoint, max_distance: int = 15, exclude_ids: Optional[set[int]] = None) -> Optional[QPoint]:
|
||||||
# Delegates to _find_nearest_point_entity so the position snap and
|
|
||||||
# the constraint target ALWAYS agree — previously this loop ignored
|
# the constraint target ALWAYS agree — previously this loop ignored
|
||||||
# underlay visibility, so a hidden underlay corner would snap the
|
# underlay visibility, so a hidden underlay corner would snap the
|
||||||
# cursor position while the entity lookup returned None and no
|
# cursor position while the entity lookup returned None and no
|
||||||
# coincident constraint was created (silent disconnect).
|
# coincident constraint was created (silent disconnect).
|
||||||
entity = self._find_nearest_point_entity(pos, max_distance)
|
entity = self._find_nearest_point_entity(pos, max_distance, exclude_ids=exclude_ids)
|
||||||
if entity is None:
|
if entity is None:
|
||||||
return None
|
return None
|
||||||
xy = self._flat_xy(entity.geometry)
|
xy = self._flat_xy(entity.geometry)
|
||||||
@@ -946,7 +949,7 @@ class Sketch2DWidget(QWidget):
|
|||||||
return QPoint(int(round(x)), int(round(y)))
|
return QPoint(int(round(x)), int(round(y)))
|
||||||
|
|
||||||
def _find_nearest_point_entity(
|
def _find_nearest_point_entity(
|
||||||
self, pos: QPoint, max_distance: int = 15
|
self, pos: QPoint, max_distance: int = 15, exclude_ids: Optional[set[int]] = None
|
||||||
) -> Optional[OCCSketchEntity]:
|
) -> Optional[OCCSketchEntity]:
|
||||||
"""Find the nearest point entity to a screen position.
|
"""Find the nearest point entity to a screen position.
|
||||||
|
|
||||||
@@ -962,6 +965,8 @@ class Sketch2DWidget(QWidget):
|
|||||||
for entity in self._points:
|
for entity in self._points:
|
||||||
if self._is_external(entity) and not self._underlay_visible:
|
if self._is_external(entity) and not self._underlay_visible:
|
||||||
continue
|
continue
|
||||||
|
if exclude_ids and entity.id in exclude_ids:
|
||||||
|
continue
|
||||||
xy = self._flat_xy(entity.geometry)
|
xy = self._flat_xy(entity.geometry)
|
||||||
if xy is None:
|
if xy is None:
|
||||||
continue
|
continue
|
||||||
@@ -1066,7 +1071,7 @@ class Sketch2DWidget(QWidget):
|
|||||||
return end
|
return end
|
||||||
return QPoint(start.x(), end.y())
|
return QPoint(start.x(), end.y())
|
||||||
|
|
||||||
def _apply_all_snaps(self, pos: QPoint, start: Optional[QPoint] = None) -> QPoint:
|
def _apply_all_snaps(self, pos: QPoint, start: Optional[QPoint] = None, exclude_ids: Optional[set[int]] = None) -> QPoint:
|
||||||
result = pos
|
result = pos
|
||||||
# Reset auto-constraint tracking
|
# Reset auto-constraint tracking
|
||||||
self._snap_point_target = None
|
self._snap_point_target = None
|
||||||
@@ -1074,10 +1079,16 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._snap_horizontal = False
|
self._snap_horizontal = False
|
||||||
self._snap_vertical = False
|
self._snap_vertical = False
|
||||||
|
|
||||||
point_snap = self._find_nearest_point(pos)
|
point_snap = self._find_nearest_point(pos, exclude_ids=exclude_ids)
|
||||||
if point_snap:
|
if point_snap:
|
||||||
self._snap_point_target = self._find_nearest_point_entity(pos)
|
self._snap_point_target = self._find_nearest_point_entity(pos)
|
||||||
return self._world_to_screen(point_snap)
|
return self._world_to_screen(point_snap)
|
||||||
|
# When exclude_ids is active (constraint multi-select), don't fall
|
||||||
|
# through to midpoint / line snap — those redirect the click to a
|
||||||
|
# position the handler's point lookup won't find. The handler uses
|
||||||
|
# its own world-space entity lookup with the same exclude set.
|
||||||
|
if exclude_ids:
|
||||||
|
return result
|
||||||
if self._snap_mode.get("mpoint", False):
|
if self._snap_mode.get("mpoint", False):
|
||||||
mid_snap = self._find_midpoint_snap(pos)
|
mid_snap = self._find_midpoint_snap(pos)
|
||||||
if mid_snap:
|
if mid_snap:
|
||||||
@@ -1240,27 +1251,84 @@ class Sketch2DWidget(QWidget):
|
|||||||
|
|
||||||
# ─── Solver helpers ───────────────────────────────────────────────────
|
# ─── Solver helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
def _get_point_entity_at(self, world_pos: QPoint) -> Optional[OCCSketchEntity]:
|
def _get_point_entity_at(self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None) -> Optional[OCCSketchEntity]:
|
||||||
"""Find nearest point entity to world position.
|
"""Find nearest point entity to world position.
|
||||||
|
|
||||||
External (underlay) points are pickable when the underlay is visible
|
External (underlay) points are pickable when the underlay is visible
|
||||||
so the user can use them as constraint anchors (e.g. the corner of a
|
so the user can use them as constraint anchors (e.g. the corner of a
|
||||||
projected face); they're skipped when the underlay is hidden.
|
projected face); they're skipped when the underlay is hidden.
|
||||||
|
|
||||||
|
When several points are within tolerance (e.g. a user-drawn corner
|
||||||
|
sitting exactly on the centerline origin), user-drawn points win
|
||||||
|
over centerline / external reference points — otherwise a click on
|
||||||
|
your own corner would silently grab the reference point underneath
|
||||||
|
it and the distance would apply to the wrong entity.
|
||||||
"""
|
"""
|
||||||
|
best: Optional[OCCSketchEntity] = None
|
||||||
|
best_dist = float("inf")
|
||||||
|
ref_best: Optional[OCCSketchEntity] = None
|
||||||
|
ref_best_dist = float("inf")
|
||||||
|
tolerance = self._pick_tolerance_world(10)
|
||||||
for entity in self._points:
|
for entity in self._points:
|
||||||
if self._is_external(entity) and not self._underlay_visible:
|
if self._is_external(entity) and not self._underlay_visible:
|
||||||
continue
|
continue
|
||||||
|
if exclude_ids and entity.id in exclude_ids:
|
||||||
|
continue
|
||||||
xy = self._flat_xy(entity.geometry)
|
xy = self._flat_xy(entity.geometry)
|
||||||
if xy is None:
|
if xy is None:
|
||||||
continue
|
continue
|
||||||
x, y = xy
|
x, y = xy
|
||||||
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
|
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
|
||||||
if dist < self._pick_tolerance_world(10):
|
if dist >= tolerance:
|
||||||
return entity
|
continue
|
||||||
return None
|
if self._is_external(entity) or self._is_centerline(entity):
|
||||||
|
# Reference geometry — fallback only, never preferred.
|
||||||
|
if dist < ref_best_dist:
|
||||||
|
ref_best, ref_best_dist = entity, dist
|
||||||
|
else:
|
||||||
|
# User-drawn point — preferred over reference geometry.
|
||||||
|
if dist < best_dist:
|
||||||
|
best, best_dist = entity, dist
|
||||||
|
return best if best is not None else ref_best
|
||||||
|
|
||||||
|
def _get_point_entity_at_fallback(
|
||||||
|
self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None
|
||||||
|
) -> Optional[OCCSketchEntity]:
|
||||||
|
"""Same as :meth:`_get_point_entity_at` but with 3× tolerance.
|
||||||
|
|
||||||
|
Used by the distance constraint handler when the standard lookup fails
|
||||||
|
during multi-select — the snap may not have fired (the excluded entity
|
||||||
|
was the only one within screen range), so ``world_snapped`` is the raw
|
||||||
|
click position which can be further from the intended target than the
|
||||||
|
snapped position would have been.
|
||||||
|
"""
|
||||||
|
best: Optional[OCCSketchEntity] = None
|
||||||
|
best_dist = float("inf")
|
||||||
|
ref_best: Optional[OCCSketchEntity] = None
|
||||||
|
ref_best_dist = float("inf")
|
||||||
|
tolerance = self._pick_tolerance_world(30)
|
||||||
|
for entity in self._points:
|
||||||
|
if self._is_external(entity) and not self._underlay_visible:
|
||||||
|
continue
|
||||||
|
if exclude_ids and entity.id in exclude_ids:
|
||||||
|
continue
|
||||||
|
xy = self._flat_xy(entity.geometry)
|
||||||
|
if xy is None:
|
||||||
|
continue
|
||||||
|
x, y = xy
|
||||||
|
dist = math.sqrt((world_pos.x() - x) ** 2 + (world_pos.y() - y) ** 2)
|
||||||
|
if dist >= tolerance:
|
||||||
|
continue
|
||||||
|
if self._is_external(entity) or self._is_centerline(entity):
|
||||||
|
if dist < ref_best_dist:
|
||||||
|
ref_best, ref_best_dist = entity, dist
|
||||||
|
else:
|
||||||
|
if dist < best_dist:
|
||||||
|
best, best_dist = entity, dist
|
||||||
|
return best if best is not None else ref_best
|
||||||
|
|
||||||
def _get_line_entity_at(
|
def _get_line_entity_at(
|
||||||
self, world_pos: QPoint
|
self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None
|
||||||
) -> Optional[Tuple[OCCSketchEntity, OCCSketchEntity]]:
|
) -> Optional[Tuple[OCCSketchEntity, OCCSketchEntity]]:
|
||||||
"""Find a line near the given world position.
|
"""Find a line near the given world position.
|
||||||
|
|
||||||
@@ -1273,12 +1341,27 @@ class Sketch2DWidget(QWidget):
|
|||||||
uses perpendicular distance to the infinite line (no segment
|
uses perpendicular distance to the infinite line (no segment
|
||||||
clamping) with a zoom-adjusted tolerance so they are pickable at
|
clamping) with a zoom-adjusted tolerance so they are pickable at
|
||||||
any zoom level.
|
any zoom level.
|
||||||
|
|
||||||
|
When several lines are within tolerance (e.g. a user-drawn edge
|
||||||
|
lying exactly on the X axis), user-drawn lines win over centerline
|
||||||
|
/ external reference lines — otherwise a click on your own edge
|
||||||
|
would silently grab the reference axis underneath it.
|
||||||
"""
|
"""
|
||||||
|
best: Optional[Tuple[OCCSketchEntity, OCCSketchEntity]] = None
|
||||||
|
best_dist = float("inf")
|
||||||
|
ref_best: Optional[Tuple[OCCSketchEntity, OCCSketchEntity]] = None
|
||||||
|
ref_best_dist = float("inf")
|
||||||
for p1_ent, p2_ent in self._lines:
|
for p1_ent, p2_ent in self._lines:
|
||||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
is_ext = bool(line_ent is not None and self._is_external(line_ent))
|
is_ext = bool(line_ent is not None and self._is_external(line_ent))
|
||||||
if is_ext and not self._underlay_visible:
|
if is_ext and not self._underlay_visible:
|
||||||
continue
|
continue
|
||||||
|
# NOTE: exclude_ids is accepted for API consistency but NOT applied
|
||||||
|
# here — it contains POINT ids (the already-selected entity for
|
||||||
|
# multi-click constraint tools). Filtering lines by their endpoint
|
||||||
|
# ids would prevent the user from picking a line that shares the
|
||||||
|
# selected point, which is a valid operation for point-on-line /
|
||||||
|
# midpoint / distance constraints.
|
||||||
if p1_ent.geometry and p2_ent.geometry:
|
if p1_ent.geometry and p2_ent.geometry:
|
||||||
x1, y1 = p1_ent.geometry
|
x1, y1 = p1_ent.geometry
|
||||||
x2, y2 = p2_ent.geometry
|
x2, y2 = p2_ent.geometry
|
||||||
@@ -1300,7 +1383,9 @@ class Sketch2DWidget(QWidget):
|
|||||||
# perpendicular distance directly (no segment clamping).
|
# perpendicular distance directly (no segment clamping).
|
||||||
tol = self._pick_tolerance_world(12)
|
tol = self._pick_tolerance_world(12)
|
||||||
if perp_dist < tol:
|
if perp_dist < tol:
|
||||||
return (p1_ent, p2_ent)
|
cand = (p1_ent, p2_ent)
|
||||||
|
if perp_dist < ref_best_dist:
|
||||||
|
ref_best, ref_best_dist = cand, perp_dist
|
||||||
else:
|
else:
|
||||||
# Regular lines: clamp the projection to the segment
|
# Regular lines: clamp the projection to the segment
|
||||||
# and check distance to that clamped point.
|
# and check distance to that clamped point.
|
||||||
@@ -1317,8 +1402,15 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._pick_tolerance_world(8) if is_ext else self._pick_tolerance_world(14)
|
self._pick_tolerance_world(8) if is_ext else self._pick_tolerance_world(14)
|
||||||
)
|
)
|
||||||
if seg_dist < tol:
|
if seg_dist < tol:
|
||||||
return (p1_ent, p2_ent)
|
cand = (p1_ent, p2_ent)
|
||||||
return None
|
# Prefer user-drawn lines over reference geometry.
|
||||||
|
if is_ext:
|
||||||
|
if seg_dist < ref_best_dist:
|
||||||
|
ref_best, ref_best_dist = cand, seg_dist
|
||||||
|
else:
|
||||||
|
if seg_dist < best_dist:
|
||||||
|
best, best_dist = cand, seg_dist
|
||||||
|
return best if best is not None else ref_best
|
||||||
|
|
||||||
def _find_line_sketch_entity(
|
def _find_line_sketch_entity(
|
||||||
self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity
|
self, p1_ent: OCCSketchEntity, p2_ent: OCCSketchEntity
|
||||||
@@ -1399,6 +1491,43 @@ class Sketch2DWidget(QWidget):
|
|||||||
x1, y1 = s_ent.geometry
|
x1, y1 = s_ent.geometry
|
||||||
x2, y2 = e_ent.geometry
|
x2, y2 = e_ent.geometry
|
||||||
return QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
|
return QPoint(int(round((x1 + x2) / 2)), int(round((y1 + y2) / 2)))
|
||||||
|
def _line_world_endpoints(
|
||||||
|
self, line_id: int
|
||||||
|
) -> Optional[Tuple[Tuple[float, float], Tuple[float, float]]]:
|
||||||
|
"""World-space endpoint coordinates of the line with the given id."""
|
||||||
|
if not self._sketch or line_id not in self._sketch._lines:
|
||||||
|
return None
|
||||||
|
sid, eid2 = self._sketch._lines[line_id]
|
||||||
|
s_ent = self._sketch._entities.get(sid)
|
||||||
|
e_ent = self._sketch._entities.get(eid2)
|
||||||
|
if not s_ent or not e_ent or not s_ent.geometry or not e_ent.geometry:
|
||||||
|
return None
|
||||||
|
x1, y1 = s_ent.geometry
|
||||||
|
x2, y2 = e_ent.geometry
|
||||||
|
if not all(isinstance(v, (int, float)) for v in (x1, y1, x2, y2)):
|
||||||
|
return None
|
||||||
|
return (float(x1), float(y1)), (float(x2), float(y2))
|
||||||
|
|
||||||
|
def _perpendicular_foot_world(self, point: QPoint, line_id: int) -> Optional[QPoint]:
|
||||||
|
"""Perpendicular foot of *point* projected onto the line, world-space.
|
||||||
|
|
||||||
|
The foot lies on the *infinite* line through the two endpoints — when
|
||||||
|
the point projects beyond the segment the dimension line legitimately
|
||||||
|
extends past the endpoint. Returns None if the line geometry is
|
||||||
|
unavailable.
|
||||||
|
"""
|
||||||
|
ends = self._line_world_endpoints(line_id)
|
||||||
|
if ends is None:
|
||||||
|
return None
|
||||||
|
(x1, y1), (x2, y2) = ends
|
||||||
|
dx = x2 - x1
|
||||||
|
dy = y2 - y1
|
||||||
|
denom = dx * dx + dy * dy
|
||||||
|
if denom < 1e-12:
|
||||||
|
# Degenerate (zero-length) line — fall back to its start point.
|
||||||
|
return QPoint(int(round(x1)), int(round(y1)))
|
||||||
|
t = ((point.x() - x1) * dx + (point.y() - y1) * dy) / denom
|
||||||
|
return QPoint(int(round(x1 + t * dx)), int(round(y1 + t * dy)))
|
||||||
|
|
||||||
def _point_world(self, pid: int) -> Optional[QPoint]:
|
def _point_world(self, pid: int) -> Optional[QPoint]:
|
||||||
"""World-space position of the point entity with the given id.
|
"""World-space position of the point entity with the given id.
|
||||||
@@ -1508,13 +1637,32 @@ class Sketch2DWidget(QWidget):
|
|||||||
anchor = self._line_world_mid(ids[1])
|
anchor = self._line_world_mid(ids[1])
|
||||||
label = "mid"
|
label = "mid"
|
||||||
elif ctype == "distance":
|
elif ctype == "distance":
|
||||||
# Distance may be point-to-point OR point-to-line (e.g.
|
# Distance may be point-to-point OR point-to-line. For a
|
||||||
# point-on-line coincident surfaces as a coincident entry;
|
# point-to-line distance the dimension line must run from
|
||||||
# a future point-to-line distance would do the same).
|
# the selected point to its perpendicular foot on the line
|
||||||
# Use _entity_anchor so a line id routes to the line
|
# (i.e. at 90° to the line) — NOT to the line's midpoint,
|
||||||
# midpoint instead of crashing on round(<tuple>).
|
# which would render the measured distance at an arbitrary
|
||||||
|
# angle. Use _entity_anchor so a line id routes to the
|
||||||
|
# line midpoint as a fallback instead of crashing on
|
||||||
|
# round(<tuple>).
|
||||||
a = self._entity_anchor(ids[0])
|
a = self._entity_anchor(ids[0])
|
||||||
b = self._entity_anchor(ids[1])
|
b = self._entity_anchor(ids[1])
|
||||||
|
ent_a = self._sketch._entities.get(ids[0])
|
||||||
|
ent_b = self._sketch._entities.get(ids[1])
|
||||||
|
ta = ent_a.entity_type if ent_a is not None else None
|
||||||
|
tb = ent_b.entity_type if ent_b is not None else None
|
||||||
|
if ta == "point" and tb == "line":
|
||||||
|
if a is not None:
|
||||||
|
foot = self._perpendicular_foot_world(a, ids[1])
|
||||||
|
if foot is not None:
|
||||||
|
b = foot
|
||||||
|
elif ta == "line" and tb == "point":
|
||||||
|
# Legacy (line, point) log ordering — same geometry,
|
||||||
|
# mirrored.
|
||||||
|
if b is not None:
|
||||||
|
foot = self._perpendicular_foot_world(b, ids[0])
|
||||||
|
if foot is not None:
|
||||||
|
a = foot
|
||||||
# NOTE: use `is not None`, not truthiness — QPoint(0,0) is falsy in PySide6.
|
# NOTE: use `is not None`, not truthiness — QPoint(0,0) is falsy in PySide6.
|
||||||
if a is not None and b is not None:
|
if a is not None and b is not None:
|
||||||
anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
|
anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
|
||||||
@@ -1599,7 +1747,10 @@ class Sketch2DWidget(QWidget):
|
|||||||
if tag_p1 is not None and tag_p2 is not None:
|
if tag_p1 is not None and tag_p2 is not None:
|
||||||
tag_entry["p1_world"] = tag_p1
|
tag_entry["p1_world"] = tag_p1
|
||||||
tag_entry["p2_world"] = tag_p2
|
tag_entry["p2_world"] = tag_p2
|
||||||
tag_entry["distance"] = params[0] if params else 0.0
|
# The stored value carries the side-sign (negative
|
||||||
|
# when the point is on the "negative" side of the
|
||||||
|
# line) — the dimension text shows the magnitude.
|
||||||
|
tag_entry["distance"] = abs(params[0]) if params else 0.0
|
||||||
tags.append(tag_entry)
|
tags.append(tag_entry)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Catch any failure while building this one tag (bad
|
# Catch any failure while building this one tag (bad
|
||||||
@@ -2105,9 +2256,15 @@ class Sketch2DWidget(QWidget):
|
|||||||
self.update()
|
self.update()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Exclude already-selected entities from snap/lookup during constraint multi-select
|
||||||
|
# so the user can pick a second entity distinct from the first.
|
||||||
|
_exclude_ids: Optional[set[int]] = None
|
||||||
|
if self._mode and self._mode.startswith("constrain_") and self._selected_entities:
|
||||||
|
_exclude_ids = {e.id for e in self._selected_entities}
|
||||||
snapped_pos = self._apply_all_snaps(
|
snapped_pos = self._apply_all_snaps(
|
||||||
event.pos(),
|
event.pos(),
|
||||||
self._world_to_screen(self._draw_buffer[0]) if self._draw_buffer else None,
|
self._world_to_screen(self._draw_buffer[0]) if self._draw_buffer else None,
|
||||||
|
exclude_ids=_exclude_ids,
|
||||||
)
|
)
|
||||||
world_snapped = (
|
world_snapped = (
|
||||||
self._screen_to_world(snapped_pos) if snapped_pos != event.pos() else world_pos
|
self._screen_to_world(snapped_pos) if snapped_pos != event.pos() else world_pos
|
||||||
@@ -2126,23 +2283,23 @@ class Sketch2DWidget(QWidget):
|
|||||||
elif self._mode == "select":
|
elif self._mode == "select":
|
||||||
self._handle_select_click(world_snapped)
|
self._handle_select_click(world_snapped)
|
||||||
elif self._mode == "constrain_coincident":
|
elif self._mode == "constrain_coincident":
|
||||||
self._handle_constraint_coincident(world_snapped)
|
self._handle_constraint_coincident(world_snapped, exclude_ids=_exclude_ids)
|
||||||
elif self._mode == "constrain_horizontal":
|
elif self._mode == "constrain_horizontal":
|
||||||
self._handle_constraint_horizontal(world_snapped)
|
self._handle_constraint_horizontal(world_snapped)
|
||||||
elif self._mode == "constrain_vertical":
|
elif self._mode == "constrain_vertical":
|
||||||
self._handle_constraint_vertical(world_snapped)
|
self._handle_constraint_vertical(world_snapped)
|
||||||
elif self._mode == "constrain_distance":
|
elif self._mode == "constrain_distance":
|
||||||
self._handle_constraint_distance(world_snapped)
|
self._handle_constraint_distance(world_snapped, exclude_ids=_exclude_ids)
|
||||||
elif self._mode == "constrain_midpoint":
|
elif self._mode == "constrain_midpoint":
|
||||||
self._handle_constraint_midpoint(world_snapped)
|
self._handle_constraint_midpoint(world_snapped, exclude_ids=_exclude_ids)
|
||||||
elif self._mode == "constrain_perpendicular":
|
elif self._mode == "constrain_perpendicular":
|
||||||
self._handle_constraint_perpendicular(world_snapped)
|
self._handle_constraint_perpendicular(world_snapped)
|
||||||
elif self._mode == "constrain_parallel":
|
elif self._mode == "constrain_parallel":
|
||||||
self._handle_constraint_parallel(world_snapped)
|
self._handle_constraint_parallel(world_snapped)
|
||||||
elif self._mode == "constrain_ptline":
|
elif self._mode == "constrain_ptline":
|
||||||
self._handle_constraint_ptline(world_snapped)
|
self._handle_constraint_ptline(world_snapped, exclude_ids=_exclude_ids)
|
||||||
elif self._mode == "constrain_symmetric":
|
elif self._mode == "constrain_symmetric":
|
||||||
self._handle_constraint_symmetric(world_snapped)
|
self._handle_constraint_symmetric(world_snapped, exclude_ids=_exclude_ids)
|
||||||
elif self._mode == "constrain_diameter":
|
elif self._mode == "constrain_diameter":
|
||||||
self._handle_constraint_diameter(world_snapped)
|
self._handle_constraint_diameter(world_snapped)
|
||||||
|
|
||||||
@@ -2166,6 +2323,8 @@ class Sketch2DWidget(QWidget):
|
|||||||
target_world = self._screen_to_world(snapped_screen)
|
target_world = self._screen_to_world(snapped_screen)
|
||||||
dx = target_world.x() - self._move_anchor_orig.x()
|
dx = target_world.x() - self._move_anchor_orig.x()
|
||||||
dy = target_world.y() - self._move_anchor_orig.y()
|
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:
|
for ent in self._moving_points:
|
||||||
if ent.id in self._move_orig_positions and ent.geometry is not None:
|
if ent.id in self._move_orig_positions and ent.geometry is not None:
|
||||||
ox, oy = self._move_orig_positions[ent.id]
|
ox, oy = self._move_orig_positions[ent.id]
|
||||||
@@ -2363,8 +2522,7 @@ class Sketch2DWidget(QWidget):
|
|||||||
# the user expects from dragging a single corner.
|
# the user expects from dragging a single corner.
|
||||||
if self._move_anchor is not None and self._move_anchor.geometry is not None:
|
if self._move_anchor is not None and self._move_anchor.geometry is not None:
|
||||||
ax, ay = self._move_anchor.geometry
|
ax, ay = self._move_anchor.geometry
|
||||||
if not self._sketch.is_entity_dragged(self._move_anchor.id):
|
if self._move_did_move and not self._sketch.is_entity_dragged(self._move_anchor.id):
|
||||||
self._sketch.constrain_fixed(self._move_anchor)
|
|
||||||
# constrain_fixed reads the current params via
|
# constrain_fixed reads the current params via
|
||||||
# the dragged() call, so re-sync to be safe.
|
# the dragged() call, so re-sync to be safe.
|
||||||
self._solve_and_sync()
|
self._solve_and_sync()
|
||||||
@@ -3254,10 +3412,13 @@ class Sketch2DWidget(QWidget):
|
|||||||
|
|
||||||
# ─── Constraint handlers (with solver calls) ──────────────────────────
|
# ─── Constraint handlers (with solver calls) ──────────────────────────
|
||||||
|
|
||||||
def _handle_constraint_coincident(self, world_pos: QPoint):
|
def _handle_constraint_coincident(self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None):
|
||||||
ent = self._get_point_entity_at(world_pos)
|
ent = self._get_point_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if ent is None:
|
if ent is None:
|
||||||
return
|
return
|
||||||
|
if ent in self._selected_entities:
|
||||||
|
self.update()
|
||||||
|
return
|
||||||
self._selected_entities.append(ent)
|
self._selected_entities.append(ent)
|
||||||
if len(self._selected_entities) >= 2:
|
if len(self._selected_entities) >= 2:
|
||||||
e1, e2 = self._selected_entities[:2]
|
e1, e2 = self._selected_entities[:2]
|
||||||
@@ -3310,7 +3471,6 @@ class Sketch2DWidget(QWidget):
|
|||||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
if line_ent is None:
|
if line_ent is None:
|
||||||
return
|
return
|
||||||
# Save state before adding constraint
|
|
||||||
if self._undo_manager:
|
if self._undo_manager:
|
||||||
self._undo_manager.save_state()
|
self._undo_manager.save_state()
|
||||||
if self._sketch:
|
if self._sketch:
|
||||||
@@ -3327,18 +3487,62 @@ class Sketch2DWidget(QWidget):
|
|||||||
self.constrain_done.emit()
|
self.constrain_done.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def _handle_constraint_distance(self, world_pos: QPoint):
|
def _handle_constraint_distance(self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None):
|
||||||
point_ent = self._get_point_entity_at(world_pos)
|
point_ent = self._get_point_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
|
if point_ent is None:
|
||||||
|
# The snap may not have fired (excluded entity was the only one
|
||||||
|
# within screen range, or the standard tolerance is too tight after
|
||||||
|
# a viewport change like re-projecting a face). Retry with 3×.
|
||||||
|
point_ent = self._get_point_entity_at_fallback(world_pos, exclude_ids=exclude_ids)
|
||||||
if point_ent:
|
if point_ent:
|
||||||
# Point clicked: collect points; constraint applied after 2nd point.
|
if point_ent in self._selected_entities:
|
||||||
|
self.update()
|
||||||
|
return
|
||||||
self._selected_entities.append(point_ent)
|
self._selected_entities.append(point_ent)
|
||||||
else:
|
else:
|
||||||
line_hit = self._get_line_entity_at(world_pos)
|
line_hit = self._get_line_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if line_hit:
|
if line_hit:
|
||||||
p1_ent, p2_ent = line_hit
|
p1_ent, p2_ent = line_hit
|
||||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
if line_ent is not None:
|
if line_ent is not None:
|
||||||
# Line clicked: constrain its length (endpoint distance).
|
if self._selected_entities:
|
||||||
|
# A point is already selected: this line is the
|
||||||
|
# SECOND entity of a point-to-line distance.
|
||||||
|
prev_ent = self._selected_entities[0]
|
||||||
|
dist, ok = QInputDialog.getDouble(
|
||||||
|
self,
|
||||||
|
"Point-to-line distance",
|
||||||
|
"Distance (mm):",
|
||||||
|
self._constraint_distance_value,
|
||||||
|
0,
|
||||||
|
10000,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
if ok and self._sketch:
|
||||||
|
# Save state before adding constraint
|
||||||
|
if self._undo_manager:
|
||||||
|
self._undo_manager.save_state()
|
||||||
|
self._sketch.constrain_distance(prev_ent, line_ent, dist)
|
||||||
|
self._solve_and_sync()
|
||||||
|
logger.info(f"Point-line distance {dist:.2f}mm")
|
||||||
|
self._selected_entities = []
|
||||||
|
self._mode = None
|
||||||
|
self.constrain_done.emit()
|
||||||
|
self.update()
|
||||||
|
return
|
||||||
|
# No selection: a lone line click sets its length
|
||||||
|
# (distance between its endpoint points). Reference
|
||||||
|
# lines (centerlines / underlay projections) are fixed
|
||||||
|
# and have no meaningful "length" — skip them; the
|
||||||
|
# user must pick a point first to constrain against
|
||||||
|
# them (point-to-line distance).
|
||||||
|
if self._is_centerline(line_ent) or self._is_external(line_ent):
|
||||||
|
logger.info(
|
||||||
|
"distance: select a point first, then this reference line "
|
||||||
|
"for a point-to-line distance"
|
||||||
|
)
|
||||||
|
self.update()
|
||||||
|
return
|
||||||
dist, ok = QInputDialog.getDouble(
|
dist, ok = QInputDialog.getDouble(
|
||||||
self,
|
self,
|
||||||
"Distance",
|
"Distance",
|
||||||
@@ -3378,12 +3582,12 @@ class Sketch2DWidget(QWidget):
|
|||||||
self.constrain_done.emit()
|
self.constrain_done.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def _handle_constraint_midpoint(self, world_pos: QPoint):
|
def _handle_constraint_midpoint(self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None):
|
||||||
point_ent = self._get_point_entity_at(world_pos)
|
point_ent = self._get_point_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if point_ent and not self._selected_entities:
|
if point_ent and not self._selected_entities:
|
||||||
self._selected_entities.append(point_ent)
|
self._selected_entities.append(point_ent)
|
||||||
elif self._selected_entities:
|
elif self._selected_entities:
|
||||||
line_hit = self._get_line_entity_at(world_pos)
|
line_hit = self._get_line_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if line_hit:
|
if line_hit:
|
||||||
p1_ent, p2_ent = line_hit
|
p1_ent, p2_ent = line_hit
|
||||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
@@ -3454,13 +3658,13 @@ class Sketch2DWidget(QWidget):
|
|||||||
self.constrain_done.emit()
|
self.constrain_done.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def _handle_constraint_ptline(self, world_pos: QPoint):
|
def _handle_constraint_ptline(self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None):
|
||||||
"""Point-on-line coincident (point on line)."""
|
"""Point-on-line coincident (point on line)."""
|
||||||
point_ent = self._get_point_entity_at(world_pos)
|
point_ent = self._get_point_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if point_ent and not self._selected_entities:
|
if point_ent and not self._selected_entities:
|
||||||
self._selected_entities.append(point_ent)
|
self._selected_entities.append(point_ent)
|
||||||
elif self._selected_entities:
|
elif self._selected_entities:
|
||||||
line_hit = self._get_line_entity_at(world_pos)
|
line_hit = self._get_line_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if line_hit:
|
if line_hit:
|
||||||
p1_ent, p2_ent = line_hit
|
p1_ent, p2_ent = line_hit
|
||||||
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
line_ent = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
@@ -3477,11 +3681,11 @@ class Sketch2DWidget(QWidget):
|
|||||||
self.constrain_done.emit()
|
self.constrain_done.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def _handle_constraint_symmetric(self, world_pos: QPoint):
|
def _handle_constraint_symmetric(self, world_pos: QPoint, exclude_ids: Optional[set[int]] = None):
|
||||||
"""Symmetric constraint: select entity1, entity2, then mirror line."""
|
"""Symmetric constraint: select entity1, entity2, then mirror line."""
|
||||||
# Click 3: mirror line
|
# Click 3: mirror line
|
||||||
if len(self._selected_entities) == 2:
|
if len(self._selected_entities) == 2:
|
||||||
line_hit = self._get_line_entity_at(world_pos)
|
line_hit = self._get_line_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if line_hit:
|
if line_hit:
|
||||||
p1_ent, p2_ent = line_hit
|
p1_ent, p2_ent = line_hit
|
||||||
mirror_line = self._find_line_sketch_entity(p1_ent, p2_ent)
|
mirror_line = self._find_line_sketch_entity(p1_ent, p2_ent)
|
||||||
@@ -3503,8 +3707,11 @@ class Sketch2DWidget(QWidget):
|
|||||||
self.update()
|
self.update()
|
||||||
return
|
return
|
||||||
# Clicks 1-2: select point entities
|
# Clicks 1-2: select point entities
|
||||||
point_ent = self._get_point_entity_at(world_pos)
|
point_ent = self._get_point_entity_at(world_pos, exclude_ids=exclude_ids)
|
||||||
if point_ent:
|
if point_ent:
|
||||||
|
if point_ent in self._selected_entities:
|
||||||
|
self.update()
|
||||||
|
return
|
||||||
self._selected_entities.append(point_ent)
|
self._selected_entities.append(point_ent)
|
||||||
n_selected = len(self._selected_entities)
|
n_selected = len(self._selected_entities)
|
||||||
if n_selected < 2:
|
if n_selected < 2:
|
||||||
@@ -4026,6 +4233,32 @@ class Sketch2DWidget(QWidget):
|
|||||||
painter.setPen(QPen(QColor("#cdd6f4"), 2))
|
painter.setPen(QPen(QColor("#cdd6f4"), 2))
|
||||||
painter.drawLine(sp1, sp2)
|
painter.drawLine(sp1, sp2)
|
||||||
|
|
||||||
|
# ── Selected-entity highlight (multi-click constraint tools) ──
|
||||||
|
# While collecting entities for a distance constraint the first pick
|
||||||
|
# gets highlighted so the user can see it registered before clicking
|
||||||
|
# the second entity. Points get a bright ring, lines a bright
|
||||||
|
# overlay stroke.
|
||||||
|
if self._selected_entities and self._is_drawing_tool_active():
|
||||||
|
for ent in self._selected_entities:
|
||||||
|
if ent.entity_type == "point":
|
||||||
|
xy = self._flat_xy(ent.geometry)
|
||||||
|
if xy is None:
|
||||||
|
continue
|
||||||
|
x, y = xy
|
||||||
|
sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
|
||||||
|
painter.setPen(QPen(QColor("#f9e2af"), 2))
|
||||||
|
painter.setBrush(Qt.NoBrush)
|
||||||
|
painter.drawEllipse(sp, 9, 9)
|
||||||
|
elif ent.entity_type == "line":
|
||||||
|
p1e, p2e = self._get_line_endpoints(ent)
|
||||||
|
if p1e and p2e and p1e.geometry and p2e.geometry:
|
||||||
|
x1, y1 = p1e.geometry
|
||||||
|
x2, y2 = p2e.geometry
|
||||||
|
sp1 = self._world_to_screen(QPoint(int(round(x1)), int(round(y1))))
|
||||||
|
sp2 = self._world_to_screen(QPoint(int(round(x2)), int(round(y2))))
|
||||||
|
painter.setPen(QPen(QColor("#f9e2af"), 3))
|
||||||
|
painter.drawLine(sp1, sp2)
|
||||||
|
|
||||||
# ── Constraint tags (log-driven; drawn upright in screen space) ──
|
# ── Constraint tags (log-driven; drawn upright in screen space) ──
|
||||||
# Tags are recomputed here so paint stays in sync with the latest solve.
|
# Tags are recomputed here so paint stays in sync with the latest solve.
|
||||||
# While a drawing or constraint tool is active the badges are hidden
|
# While a drawing or constraint tool is active the badges are hidden
|
||||||
@@ -4382,13 +4615,18 @@ class Sketch2DWidget(QWidget):
|
|||||||
painter.drawPath(path)
|
painter.drawPath(path)
|
||||||
|
|
||||||
# ── Selected entities ──
|
# ── Selected entities ──
|
||||||
|
# Point entities only (rings); line selection is highlighted by the
|
||||||
|
# dedicated constraint-selection block above. Guard with _flat_xy so
|
||||||
|
# a line entity (tuple-of-tuples geometry) can never reach round().
|
||||||
for entity in self._selected_entities:
|
for entity in self._selected_entities:
|
||||||
if entity.geometry:
|
xy = self._flat_xy(entity.geometry)
|
||||||
x, y = entity.geometry
|
if xy is None:
|
||||||
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
|
continue
|
||||||
painter.setPen(QPen(QColor("#f9e2af"), 2))
|
x, y = xy
|
||||||
painter.setBrush(Qt.NoBrush)
|
screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
|
||||||
painter.drawEllipse(screen_pos, 12, 12)
|
painter.setPen(QPen(QColor("#f9e2af"), 2))
|
||||||
|
painter.setBrush(Qt.NoBrush)
|
||||||
|
painter.drawEllipse(screen_pos, 12, 12)
|
||||||
|
|
||||||
# ── DOF display ──
|
# ── DOF display ──
|
||||||
if self._sketch:
|
if self._sketch:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -189,17 +189,27 @@ class Viewer3DWidget(QWidget):
|
|||||||
self._ensure_initialized()
|
self._ensure_initialized()
|
||||||
return self._renderer
|
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.
|
"""Display an OCC TopoDS_Shape.
|
||||||
|
|
||||||
Uses OCCRenderer.add_shape for native AIS display, or falls back to
|
Uses OCCRenderer.add_shape for native AIS display, or falls back to
|
||||||
triangulation + add_mesh for the PygfxRenderer.
|
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()
|
self._ensure_initialized()
|
||||||
from fluency.rendering.occ_renderer import OCCRenderer
|
from fluency.rendering.occ_renderer import OCCRenderer
|
||||||
|
|
||||||
if isinstance(self._renderer, 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()
|
self._renderer.render()
|
||||||
return oid
|
return oid
|
||||||
# Fallback: tessellate and use the mesh pipeline.
|
# Fallback: tessellate and use the mesh pipeline.
|
||||||
@@ -279,6 +289,19 @@ class Viewer3DWidget(QWidget):
|
|||||||
self._renderer.render()
|
self._renderer.render()
|
||||||
return ok
|
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:
|
def set_transparency(self, mesh_id: str, transparency: float) -> bool:
|
||||||
"""Set a previously-added mesh's transparency (0..1).
|
"""Set a previously-added mesh's transparency (0..1).
|
||||||
|
|
||||||
@@ -862,6 +885,22 @@ class Viewer3DWidget(QWidget):
|
|||||||
fn()
|
fn()
|
||||||
self._renderer.render()
|
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) ────────────────────────────────────
|
# ─── Connector pick mode (assembly) ────────────────────────────────────
|
||||||
|
|
||||||
def set_connector_pick_mode(self, enabled: bool, clear_gizmo: bool = True) -> None:
|
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:
|
def is_connector_pick_mode(self) -> bool:
|
||||||
return self._connector_pick_mode
|
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:
|
def _clear_connector_snap(self) -> None:
|
||||||
"""Remove the hover gizmo."""
|
"""Remove the hover gizmo."""
|
||||||
fn = getattr(self._renderer, "clear_entity_gizmo", None)
|
fn = getattr(self._renderer, "clear_entity_gizmo", None)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
# 1–2 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)
|
||||||
|
|
||||||
Reference in New Issue
Block a user