Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9abeb6266a | |||
| 6b6f7de5ab | |||
| b184ade967 | |||
| 67b73c13b8 | |||
| 813ddc3596 | |||
| 37e5335446 | |||
| 108ad2d5b5 | |||
| 1eee203ba8 | |||
| a00129603c | |||
| 3d63f033f2 | |||
| b29bc11b42 | |||
| 79ad85594e | |||
| 655df71cd8 | |||
| 7072dcee08 | |||
| 1e9b0cab1c | |||
| a7e7a9f2c3 | |||
| 48042659fc | |||
| baa2fd5d47 | |||
| 0daa6152ee | |||
| 9f1c29d319 | |||
| 2037106c0d | |||
| d6e829c23d | |||
| 742d06d242 | |||
| c78d0af78c | |||
| dda9db822b | |||
| 9f1387fe68 | |||
| 210e3cfb5d | |||
| b8516fff91 | |||
| d7e5929a13 | |||
| 2b2afbc479 | |||
| b0aebdc04f |
+39
-1
@@ -35,4 +35,42 @@ uv.lock
|
||||
# IDE
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*.swo
|
||||
/src/fluency/Tesfiles/bordo_adapter3.fluency
|
||||
/CONSTRAINT_STATUS_FINAL.md
|
||||
/CONSTRAINT_STATUS_IMPLEMENTATION.md
|
||||
/src/fluency/rendering/first.png
|
||||
/littlebrother.md
|
||||
/src/fluency/Tesfiles/multiboidy.fluency
|
||||
/src/fluency/rendering/nromal_test.png
|
||||
/package.json
|
||||
/package-lock.json
|
||||
/src/fluency/Screenshot 2026-06-28 at 17.57.52.png
|
||||
/src/fluency/rendering/Screenshot 2026-07-12 at 16.54.14.png
|
||||
/src/fluency/Screenshot 2026-07-26 at 20.23.42.png
|
||||
/Screenshot 2026-08-05 at 10.14.15.png
|
||||
/Screenshot 2026-08-05 at 11.15.33.png
|
||||
/Screenshot 2026-08-05 at 11.22.42.png
|
||||
/SURFACE_MODIFIER_PLAN.md
|
||||
/test.step
|
||||
/src/fluency/test333.step
|
||||
/src/fluency/tests/test_arc_attached_to_rectangle.py
|
||||
/src/fluency/tests/test_array_pattern.py
|
||||
/src/fluency/tests/test_chamfer.py
|
||||
/src/fluency/tests/test_circle_diameter_constraint.py
|
||||
/tests/test_distance_constraint_picking.py
|
||||
/tests/test_extrude_geometry.py
|
||||
/src/fluency/tests/test_feature_replay.py
|
||||
/src/fluency/tests/test_fillet.py
|
||||
/tests/test_mirror.py
|
||||
/test_modifier.py
|
||||
/src/fluency/tests/test_projection_constraints.py
|
||||
/tests/test_re_extrude.py
|
||||
/scripts/test_render_zoom.py
|
||||
/test_thread.py
|
||||
/test_thread_hole.step
|
||||
/test_thread_m3.step
|
||||
/test_thread_m5_hole.step
|
||||
/test_thread_shaft.step
|
||||
/test_thread_tilted.step
|
||||
/src/fluency/testpart.step
|
||||
|
||||
Generated
+238
-63
@@ -4,10 +4,17 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Added save file foramt - Split main.py refactor">
|
||||
<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$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/technical_drawing_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/technical_drawing_widget.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -24,7 +31,7 @@
|
||||
<component name="Git.Settings">
|
||||
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
||||
<map>
|
||||
<entry key="$PROJECT_DIR$" value="single_window" />
|
||||
<entry key="$PROJECT_DIR$" value="feature/surface-modifier-pyramid-patterns" />
|
||||
</map>
|
||||
</option>
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
@@ -51,18 +58,24 @@
|
||||
"Python.Unnamed.executor": "Run",
|
||||
"Python.base.executor": "Run",
|
||||
"Python.data_model.executor": "Run",
|
||||
"Python.debug_dragging.executor": "Run",
|
||||
"Python.draw_widget2d.executor": "Run",
|
||||
"Python.draw_widget_solve.executor": "Run",
|
||||
"Python.fluency.executor": "Run",
|
||||
"Python.fluencyb.executor": "Run",
|
||||
"Python.gl_widget.executor": "Run",
|
||||
"Python.gui_ui.executor": "Run",
|
||||
"Python.kernel.executor": "Run",
|
||||
"Python.main.executor": "Run",
|
||||
"Python.main_window.executor": "Run",
|
||||
"Python.meshtest.executor": "Run",
|
||||
"Python.occ_renderer.executor": "Run",
|
||||
"Python.occ_to_mesh.executor": "Run",
|
||||
"Python.render_backend.executor": "Run",
|
||||
"Python.side_fluency.executor": "Run",
|
||||
"Python.simple_mesh.executor": "Run",
|
||||
"Python.sketch.executor": "Run",
|
||||
"Python.technical_drawing_widget.executor": "Run",
|
||||
"Python.vtk_widget.executor": "Run",
|
||||
"Python.vulkan.executor": "Run",
|
||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||
@@ -89,8 +102,9 @@
|
||||
<key name="CopyFile.RECENT_KEYS">
|
||||
<recent name="$PROJECT_DIR$/src/fluency" />
|
||||
<recent name="$PROJECT_DIR$" />
|
||||
<recent name="$PROJECT_DIR$/src/fluency/Tesfiles" />
|
||||
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
|
||||
<recent name="$PROJECT_DIR$/drawing_modules" />
|
||||
<recent name="$PROJECT_DIR$/modules" />
|
||||
</key>
|
||||
<key name="MoveFile.RECENT_KEYS">
|
||||
<recent name="$PROJECT_DIR$" />
|
||||
@@ -112,54 +126,6 @@
|
||||
<option name="presentableId" value="Default" />
|
||||
<updated>1703867682707</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="init">
|
||||
<option name="closed" value="true" />
|
||||
<created>1703951701948</created>
|
||||
<option name="number" value="00001" />
|
||||
<option name="presentableId" value="LOCAL-00001" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1703951701948</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00002" summary="- Basic oop sketch widget implement">
|
||||
<option name="closed" value="true" />
|
||||
<created>1729958532384</created>
|
||||
<option name="number" value="00002" />
|
||||
<option name="presentableId" value="LOCAL-00002" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1729958532384</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00003" summary="- Sketch projection partly works again :)">
|
||||
<option name="closed" value="true" />
|
||||
<created>1735563255455</created>
|
||||
<option name="number" value="00003" />
|
||||
<option name="presentableId" value="LOCAL-00003" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1735563255455</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00004" summary="- Sketch projection partly works again :)">
|
||||
<option name="closed" value="true" />
|
||||
<created>1735585968733</created>
|
||||
<option name="number" value="00004" />
|
||||
<option name="presentableId" value="LOCAL-00004" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1735585968733</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00005" summary="- Added new componnt controls">
|
||||
<option name="closed" value="true" />
|
||||
<created>1735601610504</created>
|
||||
<option name="number" value="00005" />
|
||||
<option name="presentableId" value="LOCAL-00005" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1735601610504</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00006" summary="- Added new componnt controls">
|
||||
<option name="closed" value="true" />
|
||||
<created>1735601786207</created>
|
||||
<option name="number" value="00006" />
|
||||
<option name="presentableId" value="LOCAL-00006" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1735601786207</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00007" summary="- changing compos for sketches works">
|
||||
<option name="closed" value="true" />
|
||||
<created>1735652081552</created>
|
||||
@@ -344,7 +310,215 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783282570014</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="30" />
|
||||
<task id="LOCAL-00030" summary="- Added save file foramt - Split main.py refactor">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783453889199</created>
|
||||
<option name="number" value="00030" />
|
||||
<option name="presentableId" value="LOCAL-00030" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783453889199</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00031" summary="- Added save file foramt - Split main.py refactor">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783456842297</created>
|
||||
<option name="number" value="00031" />
|
||||
<option name="presentableId" value="LOCAL-00031" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783456842297</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00032" summary="- Added save file foramt - Split main.py refactor">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783755278516</created>
|
||||
<option name="number" value="00032" />
|
||||
<option name="presentableId" value="LOCAL-00032" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783755278516</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00033" summary="- Added save file foramt - Split main.py refactor">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783777171864</created>
|
||||
<option name="number" value="00033" />
|
||||
<option name="presentableId" value="LOCAL-00033" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783777171864</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00034" summary="- Working assembly multi :)">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783798201133</created>
|
||||
<option name="number" value="00034" />
|
||||
<option name="presentableId" value="LOCAL-00034" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783798201133</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00035" summary="- Working assembly multi :)">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783798929531</created>
|
||||
<option name="number" value="00035" />
|
||||
<option name="presentableId" value="LOCAL-00035" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783798929531</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00036" summary="- added renderer">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783887682052</created>
|
||||
<option name="number" value="00036" />
|
||||
<option name="presentableId" value="LOCAL-00036" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783887682052</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00037" summary="- added renderer - Added undo">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783887704766</created>
|
||||
<option name="number" value="00037" />
|
||||
<option name="presentableId" value="LOCAL-00037" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783887704766</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00038" summary="- added renderer - Added undo">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783891561116</created>
|
||||
<option name="number" value="00038" />
|
||||
<option name="presentableId" value="LOCAL-00038" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783891561116</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00039" summary="- added renderer - Added undo">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783918463034</created>
|
||||
<option name="number" value="00039" />
|
||||
<option name="presentableId" value="LOCAL-00039" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783918463034</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00040" summary="- Render improvements, camera plane, update">
|
||||
<option name="closed" value="true" />
|
||||
<created>1783976498520</created>
|
||||
<option name="number" value="00040" />
|
||||
<option name="presentableId" value="LOCAL-00040" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1783976498520</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00041" summary="- added "measurement lines"">
|
||||
<option name="closed" value="true" />
|
||||
<created>1785094789247</created>
|
||||
<option name="number" value="00041" />
|
||||
<option name="presentableId" value="LOCAL-00041" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1785094789248</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00042" summary="- added "measurement lines"">
|
||||
<option name="closed" value="true" />
|
||||
<created>1785697123545</created>
|
||||
<option name="number" value="00042" />
|
||||
<option name="presentableId" value="LOCAL-00042" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1785697123545</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00043" summary="- arc improvements, fillets, operations, bodys">
|
||||
<option name="closed" value="true" />
|
||||
<created>1785701099363</created>
|
||||
<option name="number" value="00043" />
|
||||
<option name="presentableId" value="LOCAL-00043" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1785701099363</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00044" summary="- arc improvements, fillets, operations, bodys">
|
||||
<option name="closed" value="true" />
|
||||
<created>1785915859137</created>
|
||||
<option name="number" value="00044" />
|
||||
<option name="presentableId" value="LOCAL-00044" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1785915859137</updated>
|
||||
</task>
|
||||
<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 />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -365,16 +539,6 @@
|
||||
<ignored-roots>
|
||||
<path value="$PROJECT_DIR$/pythonProject" />
|
||||
</ignored-roots>
|
||||
<MESSAGE value="init" />
|
||||
<MESSAGE value="- Basic oop sketch widget implement" />
|
||||
<MESSAGE value="- Renabled extrusion with new object system" />
|
||||
<MESSAGE value="- Sketch projection partly works again :)" />
|
||||
<MESSAGE value="- Added new componnt controls" />
|
||||
<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 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" />
|
||||
@@ -389,6 +553,17 @@
|
||||
<MESSAGE value="- UI refinement, button position ui file as source no dirty drafting anymore" />
|
||||
<MESSAGE value="- assembly draft" />
|
||||
<MESSAGE value="- Added save file foramt - Split main.py refactor" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="- Added save file foramt - Split main.py refactor" />
|
||||
<MESSAGE value="- Working assembly multi :)" />
|
||||
<MESSAGE value="- added renderer" />
|
||||
<MESSAGE value="- added renderer - Added undo" />
|
||||
<MESSAGE value="- Render improvements, camera plane, update" />
|
||||
<MESSAGE value="- added "measurement lines"" />
|
||||
<MESSAGE value="- arc improvements, fillets, operations, bodys" />
|
||||
<MESSAGE value="- Operation highlighting, body highlighting" />
|
||||
<MESSAGE value="- tech draw draft" />
|
||||
<MESSAGE value="- tech draw draft v2" />
|
||||
<MESSAGE value="- tech drawing and render improv" />
|
||||
<MESSAGE value="Fiexed highlighting of operations" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="Fiexed highlighting of operations" />
|
||||
</component>
|
||||
</project>
|
||||
+376
@@ -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.
@@ -0,0 +1,253 @@
|
||||
# Realistic Render View — Implementation Plan
|
||||
|
||||
## Context
|
||||
|
||||
Add a **"Render"** feature to Fluency CAD that opens a separate window for photorealistic rendering of the selected component or assembly (like KeyShot/Cacles).
|
||||
|
||||
**Constraints:**
|
||||
- Open in a **new window** — don't clutter the workspace
|
||||
- **Keep existing OCCRenderer** for the interactive 3D viewport — untouched
|
||||
- Render backend must be a **separate, swappable module** so we can change the renderer later
|
||||
- Use **Mitsuba 3** as the initial backend (`pip install mitsuba`, ~50MB)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Main Fluency Window (existing OCCRenderer — untouched) │
|
||||
│ │
|
||||
│ [Select body/assembly] → [Click "Render"] │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ RenderWindow (separate QMainWindow)│ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────────────────┐ │ │
|
||||
│ │ │ RenderBackend (ABC) │ │ │
|
||||
│ │ │ ├─ MitsubaBackend ← current │ │ │
|
||||
│ │ │ ├─ (future: BlenderBackend) │ │ │
|
||||
│ │ │ └─ (future: CyclesBackend) │ │ │
|
||||
│ │ └───────────────────────────────┘ │ │
|
||||
│ │ │ │\n│ │ [Image preview] [Progress bar] │ │
|
||||
│ │ [Material ▾] [Quality ▾] [Render] │ │
|
||||
│ │ [Export PNG] │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Swappable Backend Interface
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
|
||||
@dataclass
|
||||
class RenderMaterial:
|
||||
name: str
|
||||
color: tuple[float, float, float] = (0.7, 0.7, 0.7)
|
||||
metallic: float = 0.0 # 0.0–1.0
|
||||
roughness: float = 0.5 # 0.0–1.0
|
||||
bsdf_type: str = "diffuse" # diffuse | roughconductor | roughdielectric | plastic
|
||||
|
||||
@dataclass
|
||||
class RenderCamera:
|
||||
origin: tuple[float, float, float] = (100, 100, 100)
|
||||
target: tuple[float, float, float] = (0, 0, 0)
|
||||
up: tuple[float, float, float] = (0, 0, 1)
|
||||
fov: float = 45.0
|
||||
|
||||
@dataclass
|
||||
class RenderSettings:
|
||||
width: int = 1920
|
||||
height: int = 1080
|
||||
spp: int = 256 # samples per pixel
|
||||
max_depth: int = 8 # path tracer bounces
|
||||
|
||||
class RenderBackend(ABC):
|
||||
"""Swap this to change the rendering engine."""
|
||||
@abstractmethod
|
||||
def render(self, obj_path: str, material: RenderMaterial,
|
||||
camera: RenderCamera, settings: RenderSettings) -> np.ndarray: ...
|
||||
@abstractmethod
|
||||
def render_preview(self, obj_path: str, material: RenderMaterial,
|
||||
camera: RenderCamera, settings: RenderSettings) -> np.ndarray: ...
|
||||
@abstractmethod
|
||||
def name(self) -> str: ...
|
||||
```
|
||||
|
||||
Switching backends later = write a new class implementing `RenderBackend`. One import change.
|
||||
|
||||
---
|
||||
|
||||
## Mitsuba 3 Backend
|
||||
|
||||
### Why Mitsuba
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| `pip install mitsuba` | Single install, no system deps |
|
||||
| True path tracing | GI, caustics, spectral rendering |
|
||||
| PBR materials | `roughconductor`, `roughdielectric`, `diffuse`, `plastic` |
|
||||
| Python dict API | Build scenes programmatically, no XML |
|
||||
| CPU + GPU backends | `scalar_rgb` (CPU), `cuda_rgb` (NVIDIA) |
|
||||
| Output formats | PNG, EXR (HDR) with tonemapping |
|
||||
|
||||
### OCC → OBJ Conversion Path
|
||||
|
||||
```python
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.StlAPI import StlAPI_Writer
|
||||
from OCP.BRep import BRep_Builder
|
||||
import tempfile, os
|
||||
|
||||
def occ_shape_to_obj(shape, obj_path: str, linear_deflection: float = 0.1):
|
||||
"""Tessellate OCC shape and write as OBJ for Mitsuba."""
|
||||
tess = BRepMesh_IncrementalMesh(shape, linear_deflection, False, 0.5, True)
|
||||
tess.Perform()
|
||||
# Write STL (reliable), then convert to OBJ via trimesh or direct
|
||||
writer = StlAPI_Writer()
|
||||
writer.SetASCIIMode(False)
|
||||
stl_path = obj_path.replace(".obj", ".stl")
|
||||
writer.Write(shape, stl_path)
|
||||
# Mitsuba can read STL directly, or we convert to OBJ
|
||||
return stl_path
|
||||
```
|
||||
|
||||
### Mitsuba Scene Construction
|
||||
|
||||
```python
|
||||
import mitsuba as mi
|
||||
mi.set_variant("scalar_rgb")
|
||||
|
||||
def build_scene(mesh_path: str, material: RenderMaterial,
|
||||
camera: RenderCamera, settings: RenderSettings) -> mi.Scene:
|
||||
# Map our material to Mitsuba BSDF
|
||||
bsdf_map = {
|
||||
"diffuse": {"type": "diffuse", "reflectance": {"type": "rgb", "value": material.color}},
|
||||
"roughconductor": {
|
||||
"type": "roughconductor",
|
||||
"material": "copper", # or铝, 钢, etc.
|
||||
"alpha": material.roughness,
|
||||
},
|
||||
"roughdielectric": {
|
||||
"type": "roughdielectric",
|
||||
"int_ior": 1.5,
|
||||
"alpha": material.roughness,
|
||||
},
|
||||
"plastic": {
|
||||
"type": "plastic",
|
||||
"diffuse_reflectance": {"type": "rgb", "value": material.color},
|
||||
"int_ior": 1.5,
|
||||
},
|
||||
}
|
||||
|
||||
return mi.load_dict({
|
||||
"type": "scene",
|
||||
"integrator": {"type": "path", "max_depth": settings.max_depth},
|
||||
"sensor": {
|
||||
"type": "perspective",
|
||||
"fov": camera.fov,
|
||||
"to_world": mi.ScalarTransform4f.look_at(
|
||||
origin=camera.origin, target=camera.target, up=camera.up
|
||||
),
|
||||
"film": {"type": "hdrfilm", "width": settings.width, "height": settings.height},
|
||||
"sampler": {"type": "independent", "sample_count": settings.spp},
|
||||
},
|
||||
"emitter": {"type": "constant"},
|
||||
"shape": {
|
||||
"type": "stl", # or "obj"
|
||||
"filename": mesh_path,
|
||||
"bsdf": bsdf_map.get(material.bsdf_type, bsdf_map["diffuse"]),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `src/fluency/rendering/render_backend.py` | **NEW** | Abstract `RenderBackend`, `RenderMaterial`, `RenderCamera`, `RenderSettings` |
|
||||
| `src/fluency/rendering/mitsuba_backend.py` | **NEW** | `MitsubaBackend(RenderBackend)` implementation |
|
||||
| `src/fluency/rendering/occ_to_mesh.py` | **NEW** | OCC `TopoDS_Shape` → STL/OBJ tessellation |
|
||||
| `src/fluency/rendering/material_presets.py` | **NEW** | Preset library: Steel, Aluminum, Brass, Chrome, Plastic, Rubber, Wood |
|
||||
| `src/fluency/ui/render_window.py` | **NEW** | `RenderWindow(QMainWindow)` — image preview, material/quality controls, render/export |
|
||||
| `src/fluency/ui/main_window.py` | MODIFY | Add "Render" button → get selected shapes → open `RenderWindow` |
|
||||
|
||||
---
|
||||
|
||||
## UI: RenderWindow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Render — [Part Name] [─][□][×] │
|
||||
├──────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ Rendered Image Preview │ │
|
||||
│ │ (QLabel with QPixmap) │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Material: [Steel ▾] │
|
||||
│ Quality: [256 SPP ▾] │
|
||||
│ Resolution: [1920×1080 ▾] │
|
||||
│ │
|
||||
│ [▶ Render] [⏹ Cancel] [💾 Export PNG] │
|
||||
│ │
|
||||
│ ████████████████░░░░░░ 65% (23s left) │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Preview**: progressive refinement (low SPP first, then ramp)
|
||||
- **Cancel**: kill Mitsuba render thread
|
||||
- **Export**: save to PNG/EXR
|
||||
|
||||
---
|
||||
|
||||
## Material Presets
|
||||
|
||||
| Preset | Color | Metallic | Roughness | BSDF |
|
||||
|--------|-------|----------|-----------|------|
|
||||
| Brushed Steel | (0.65, 0.67, 0.72) | 0.9 | 0.35 | roughconductor |
|
||||
| Polished Chrome | (0.8, 0.8, 0.8) | 1.0 | 0.05 | roughconductor |
|
||||
| Brushed Aluminum | (0.75, 0.75, 0.75) | 0.85 | 0.25 | roughconductor |
|
||||
| Copper | (0.95, 0.64, 0.54) | 0.95 | 0.15 | roughconductor |
|
||||
| Gold | (1.0, 0.76, 0.33) | 1.0 | 0.1 | roughconductor |
|
||||
| Blackened Steel | (0.15, 0.15, 0.17) | 0.8 | 0.4 | roughconductor |
|
||||
| Matte Plastic | (0.2, 0.5, 0.8) | 0.0 | 0.6 | plastic |
|
||||
| Glossy Plastic | (0.2, 0.5, 0.8) | 0.0 | 0.1 | plastic |
|
||||
| White Nylon | (0.85, 0.85, 0.83) | 0.0 | 0.45 | plastic |
|
||||
| Black ABS | (0.05, 0.05, 0.05) | 0.0 | 0.35 | plastic |
|
||||
| Red PA12 | (0.75, 0.08, 0.08) | 0.0 | 0.4 | plastic |
|
||||
| Rubber | (0.1, 0.1, 0.1) | 0.0 | 0.9 | diffuse |
|
||||
| Ceramic White | (0.92, 0.91, 0.88) | 0.0 | 0.15 | dielectric |
|
||||
| Glass | (0.95, 0.95, 0.95) | 0.0 | 0.0 | dielectric |
|
||||
| Wood | (0.6, 0.4, 0.2) | 0.0 | 0.7 | diffuse |
|
||||
|
||||
**Note:** Mitsuba pip installs don't include spectral metal data files (iron.spd, copper.spd, etc.), so metal presets use `material="none"` with `specular_reflectance` set to the metal color instead.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| Mitsuba not installed | Graceful error: "pip install mitsuba" shown in UI |
|
||||
| Slow CPU rendering | Default to low SPP (64) for preview; offer GPU variant if CUDA available |
|
||||
| Large meshes slow to tessellate | Progress indicator; optional mesh decimation |
|
||||
| Mitsuba STL/OCC compatibility | Test tessellation quality; tune `linear_deflection` |
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
- **Phase 1** (abstract backend + OCC→mesh + Mitsuba impl): ~4-6 hours
|
||||
- **Phase 2** (render window UI + material presets): ~3-4 hours
|
||||
- **Phase 3** (polish, export, swap test): ~2-3 hours
|
||||
- **Total**: ~9-13 hours
|
||||
@@ -26,7 +26,12 @@ class Ui_fluencyCAD(object):
|
||||
def setupUi(self, fluencyCAD):
|
||||
if not fluencyCAD.objectName():
|
||||
fluencyCAD.setObjectName(u"fluencyCAD")
|
||||
fluencyCAD.resize(1941, 1155)
|
||||
fluencyCAD.resize(2551, 1265)
|
||||
sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(fluencyCAD.sizePolicy().hasHeightForWidth())
|
||||
fluencyCAD.setSizePolicy(sizePolicy)
|
||||
self.actionNew_Project = QAction(fluencyCAD)
|
||||
self.actionNew_Project.setObjectName(u"actionNew_Project")
|
||||
self.actionOpen_Project = QAction(fluencyCAD)
|
||||
@@ -51,9 +56,6 @@ class Ui_fluencyCAD(object):
|
||||
self.gridLayout.setObjectName(u"gridLayout")
|
||||
self.groupBox_5 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_5.setObjectName(u"groupBox_5")
|
||||
sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_5.setSizePolicy(sizePolicy)
|
||||
self.gridLayout_11 = QGridLayout(self.groupBox_5)
|
||||
@@ -135,54 +137,94 @@ class Ui_fluencyCAD(object):
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_5, 3, 0, 1, 1)
|
||||
|
||||
self.groupBox = QGroupBox(self.centralwidget)
|
||||
self.groupBox.setObjectName(u"groupBox")
|
||||
self.gridLayout_3 = QGridLayout(self.groupBox)
|
||||
self.gridLayout_3.setObjectName(u"gridLayout_3")
|
||||
self.pb_revop = QPushButton(self.groupBox)
|
||||
self.pb_revop.setObjectName(u"pb_revop")
|
||||
self.groupBox_4 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_4.setObjectName(u"groupBox_4")
|
||||
self.groupBox_4.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_9 = QGridLayout(self.groupBox_4)
|
||||
self.gridLayout_9.setObjectName(u"gridLayout_9")
|
||||
self.pushButton_2 = QPushButton(self.groupBox_4)
|
||||
self.pushButton_2.setObjectName(u"pushButton_2")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1)
|
||||
self.gridLayout_9.addWidget(self.pushButton_2, 0, 0, 1, 1)
|
||||
|
||||
self.pb_extrdop = QPushButton(self.groupBox)
|
||||
self.pb_extrdop.setObjectName(u"pb_extrdop")
|
||||
self.pb_export_iges = QPushButton(self.groupBox_4)
|
||||
self.pb_export_iges.setObjectName(u"pb_export_iges")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
|
||||
self.gridLayout_9.addWidget(self.pb_export_iges, 2, 0, 1, 1)
|
||||
|
||||
self.pb_arrayop = QPushButton(self.groupBox)
|
||||
self.pb_arrayop.setObjectName(u"pb_arrayop")
|
||||
self.pb_export_step = QPushButton(self.groupBox_4)
|
||||
self.pb_export_step.setObjectName(u"pb_export_step")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1)
|
||||
|
||||
self.pb_cutop = QPushButton(self.groupBox)
|
||||
self.pb_cutop.setObjectName(u"pb_cutop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
|
||||
|
||||
self.pb_combop = QPushButton(self.groupBox)
|
||||
self.pb_combop.setObjectName(u"pb_combop")
|
||||
|
||||
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.gridLayout_9.addWidget(self.pb_export_step, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1, Qt.AlignTop)
|
||||
self.gridLayout.addWidget(self.groupBox_4, 1, 3, 1, 1)
|
||||
|
||||
self.InputTab = QTabWidget(self.centralwidget)
|
||||
self.InputTab.setObjectName(u"InputTab")
|
||||
sizePolicy.setHeightForWidth(self.InputTab.sizePolicy().hasHeightForWidth())
|
||||
self.InputTab.setSizePolicy(sizePolicy)
|
||||
self.sketch_tab = QWidget()
|
||||
self.sketch_tab.setObjectName(u"sketch_tab")
|
||||
self.verticalLayout_4 = QVBoxLayout(self.sketch_tab)
|
||||
self.verticalLayout_4.setObjectName(u"verticalLayout_4")
|
||||
self.InputTab.addTab(self.sketch_tab, "")
|
||||
self.code_tab = QWidget()
|
||||
self.code_tab.setObjectName(u"code_tab")
|
||||
self.verticalLayout = QVBoxLayout(self.code_tab)
|
||||
self.verticalLayout.setObjectName(u"verticalLayout")
|
||||
self.textEdit = QTextEdit(self.code_tab)
|
||||
self.textEdit.setObjectName(u"textEdit")
|
||||
|
||||
self.verticalLayout.addWidget(self.textEdit)
|
||||
|
||||
self.groupBox_7 = QGroupBox(self.code_tab)
|
||||
self.groupBox_7.setObjectName(u"groupBox_7")
|
||||
self.gridLayout_5 = QGridLayout(self.groupBox_7)
|
||||
self.gridLayout_5.setObjectName(u"gridLayout_5")
|
||||
self.pushButton_5 = QPushButton(self.groupBox_7)
|
||||
self.pushButton_5.setObjectName(u"pushButton_5")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton_5, 2, 0, 1, 1)
|
||||
|
||||
self.pushButton_4 = QPushButton(self.groupBox_7)
|
||||
self.pushButton_4.setObjectName(u"pushButton_4")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton_4, 2, 1, 1, 1)
|
||||
|
||||
self.pb_apply_code = QPushButton(self.groupBox_7)
|
||||
self.pb_apply_code.setObjectName(u"pb_apply_code")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pb_apply_code, 1, 0, 1, 1)
|
||||
|
||||
self.pushButton = QPushButton(self.groupBox_7)
|
||||
self.pushButton.setObjectName(u"pushButton")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton, 1, 1, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout.addWidget(self.groupBox_7)
|
||||
|
||||
self.InputTab.addTab(self.code_tab, "")
|
||||
|
||||
self.gridLayout.addWidget(self.InputTab, 0, 1, 5, 1)
|
||||
|
||||
self.compo_tool_box = QGroupBox(self.centralwidget)
|
||||
self.compo_tool_box.setObjectName(u"compo_tool_box")
|
||||
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
|
||||
sizePolicy1.setHorizontalStretch(0)
|
||||
sizePolicy1.setVerticalStretch(0)
|
||||
sizePolicy1.setHeightForWidth(self.compo_tool_box.sizePolicy().hasHeightForWidth())
|
||||
self.compo_tool_box.setSizePolicy(sizePolicy1)
|
||||
self.compo_tool_box.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_9 = QGridLayout(self.compo_tool_box)
|
||||
self.gridLayout_9.setObjectName(u"gridLayout_9")
|
||||
self.horizontalLayout = QHBoxLayout(self.compo_tool_box)
|
||||
self.horizontalLayout.setObjectName(u"horizontalLayout")
|
||||
self.pb_new_compo = QPushButton(self.compo_tool_box)
|
||||
self.pb_new_compo.setObjectName(u"pb_new_compo")
|
||||
self.pb_new_compo.setMinimumSize(QSize(50, 50))
|
||||
self.pb_new_compo.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_9.addWidget(self.pb_new_compo, 0, 0, 1, 1)
|
||||
self.horizontalLayout.addWidget(self.pb_new_compo)
|
||||
|
||||
self.pb_del_compo = QPushButton(self.compo_tool_box)
|
||||
self.pb_del_compo.setObjectName(u"pb_del_compo")
|
||||
@@ -193,16 +235,113 @@ class Ui_fluencyCAD(object):
|
||||
self.pb_del_compo.setMaximumSize(QSize(50, 50))
|
||||
self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
|
||||
|
||||
self.gridLayout_9.addWidget(self.pb_del_compo, 0, 1, 1, 1)
|
||||
self.horizontalLayout.addWidget(self.pb_del_compo)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.compo_tool_box, 11, 0, 1, 1)
|
||||
self.gridLayout.addWidget(self.compo_tool_box, 7, 0, 1, 1)
|
||||
|
||||
self.compo_box = QGroupBox(self.centralwidget)
|
||||
self.compo_box.setObjectName(u"compo_box")
|
||||
self.compo_box.setMinimumSize(QSize(0, 50))
|
||||
self.groupBox_3 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_3.setObjectName(u"groupBox_3")
|
||||
sizePolicy.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_3.setSizePolicy(sizePolicy)
|
||||
self.groupBox_3.setMaximumSize(QSize(200, 16777213))
|
||||
self.gridLayout_4 = QGridLayout(self.groupBox_3)
|
||||
self.gridLayout_4.setObjectName(u"gridLayout_4")
|
||||
self.pb_con_ptpt = QPushButton(self.groupBox_3)
|
||||
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
|
||||
icon = QIcon()
|
||||
icon.addFile(u"icons/pt_pt.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
self.pb_con_ptpt.setIcon(icon)
|
||||
self.pb_con_ptpt.setCheckable(True)
|
||||
self.pb_con_ptpt.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout.addWidget(self.compo_box, 11, 1, 1, 2)
|
||||
self.gridLayout_4.addWidget(self.pb_con_ptpt, 1, 0, 1, 1)
|
||||
|
||||
self.pb_con_vert = QPushButton(self.groupBox_3)
|
||||
self.pb_con_vert.setObjectName(u"pb_con_vert")
|
||||
self.pb_con_vert.setCheckable(True)
|
||||
self.pb_con_vert.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_vert, 3, 1, 1, 1)
|
||||
|
||||
self.pb_con_sym = QPushButton(self.groupBox_3)
|
||||
self.pb_con_sym.setObjectName(u"pb_con_sym")
|
||||
self.pb_con_sym.setCheckable(True)
|
||||
self.pb_con_sym.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_sym, 4, 1, 1, 1)
|
||||
|
||||
self.pb_con_mid = QPushButton(self.groupBox_3)
|
||||
self.pb_con_mid.setObjectName(u"pb_con_mid")
|
||||
self.pb_con_mid.setCheckable(True)
|
||||
self.pb_con_mid.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_mid, 2, 0, 1, 1)
|
||||
|
||||
self.pb_con_line = QPushButton(self.groupBox_3)
|
||||
self.pb_con_line.setObjectName(u"pb_con_line")
|
||||
self.pb_con_line.setCheckable(True)
|
||||
self.pb_con_line.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_line, 1, 1, 1, 1)
|
||||
|
||||
self.pb_con_horiz = QPushButton(self.groupBox_3)
|
||||
self.pb_con_horiz.setObjectName(u"pb_con_horiz")
|
||||
self.pb_con_horiz.setCheckable(True)
|
||||
self.pb_con_horiz.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_horiz, 3, 0, 1, 1)
|
||||
|
||||
self.pb_con_dist = QPushButton(self.groupBox_3)
|
||||
self.pb_con_dist.setObjectName(u"pb_con_dist")
|
||||
self.pb_con_dist.setCheckable(True)
|
||||
self.pb_con_dist.setAutoExclusive(False)
|
||||
self.pb_con_dist.setAutoRepeatDelay(297)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_dist, 4, 0, 1, 1)
|
||||
|
||||
self.pb_con_perp = QPushButton(self.groupBox_3)
|
||||
self.pb_con_perp.setObjectName(u"pb_con_perp")
|
||||
self.pb_con_perp.setCheckable(True)
|
||||
self.pb_con_perp.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_perp, 2, 1, 1, 1)
|
||||
|
||||
self.pb_con_diameter = QPushButton(self.groupBox_3)
|
||||
self.pb_con_diameter.setObjectName(u"pb_con_diameter")
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_diameter, 5, 0, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
|
||||
|
||||
self.assembly_tools = QGroupBox(self.centralwidget)
|
||||
self.assembly_tools.setObjectName(u"assembly_tools")
|
||||
sizePolicy1.setHeightForWidth(self.assembly_tools.sizePolicy().hasHeightForWidth())
|
||||
self.assembly_tools.setSizePolicy(sizePolicy1)
|
||||
self.assembly_tools.setMinimumSize(QSize(113, 50))
|
||||
self.horizontalLayout_2 = QHBoxLayout(self.assembly_tools)
|
||||
self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
|
||||
self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
|
||||
self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
|
||||
self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
|
||||
self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.pb_compo_to_assembly)
|
||||
|
||||
self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
|
||||
self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
|
||||
self.pb_remove_compo_from_assembly.setEnabled(True)
|
||||
sizePolicy.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
|
||||
self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy)
|
||||
self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
|
||||
self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
|
||||
self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
|
||||
|
||||
self.horizontalLayout_2.addWidget(self.pb_remove_compo_from_assembly)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.assembly_tools, 8, 0, 1, 1)
|
||||
|
||||
self.groupBox_9 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_9.setObjectName(u"groupBox_9")
|
||||
@@ -253,67 +392,128 @@ class Ui_fluencyCAD(object):
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
|
||||
|
||||
self.groupBox_10 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_10.setObjectName(u"groupBox_10")
|
||||
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy1.setHorizontalStretch(0)
|
||||
sizePolicy1.setVerticalStretch(0)
|
||||
sizePolicy1.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_10.setSizePolicy(sizePolicy1)
|
||||
self.groupBox_10.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
|
||||
self.verticalLayout_6.setObjectName(u"verticalLayout_6")
|
||||
self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
|
||||
self.body_list = QListWidget(self.groupBox_10)
|
||||
self.body_list.setObjectName(u"body_list")
|
||||
self.body_list.setSelectionRectVisible(True)
|
||||
self.groupBox_12 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_12.setObjectName(u"groupBox_12")
|
||||
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy2.setHorizontalStretch(0)
|
||||
sizePolicy2.setVerticalStretch(0)
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_12.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_12.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_12.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_8 = QVBoxLayout(self.groupBox_12)
|
||||
self.verticalLayout_8.setObjectName(u"verticalLayout_8")
|
||||
self.verticalLayout_8.setContentsMargins(5, 5, 5, 5)
|
||||
self.connection_list = QListWidget(self.groupBox_12)
|
||||
self.connection_list.setObjectName(u"connection_list")
|
||||
self.connection_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_6.addWidget(self.body_list)
|
||||
self.verticalLayout_8.addWidget(self.connection_list)
|
||||
|
||||
self.groupBox_8 = QGroupBox(self.groupBox_10)
|
||||
self.groupBox_8.setObjectName(u"groupBox_8")
|
||||
sizePolicy.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_8.setSizePolicy(sizePolicy)
|
||||
self.groupBox_8.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_8 = QGridLayout(self.groupBox_8)
|
||||
self.gridLayout_8.setObjectName(u"gridLayout_8")
|
||||
self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_del_body = QPushButton(self.groupBox_8)
|
||||
self.pb_del_body.setObjectName(u"pb_del_body")
|
||||
self.groupBox_13 = QGroupBox(self.groupBox_12)
|
||||
self.groupBox_13.setObjectName(u"groupBox_13")
|
||||
sizePolicy.setHeightForWidth(self.groupBox_13.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_13.setSizePolicy(sizePolicy)
|
||||
self.groupBox_13.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_13 = QGridLayout(self.groupBox_13)
|
||||
self.gridLayout_13.setObjectName(u"gridLayout_13")
|
||||
self.gridLayout_13.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_del_connection = QPushButton(self.groupBox_13)
|
||||
self.pb_del_connection.setObjectName(u"pb_del_connection")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
|
||||
self.gridLayout_13.addWidget(self.pb_del_connection, 0, 2, 1, 1)
|
||||
|
||||
self.pb_update_body = QPushButton(self.groupBox_8)
|
||||
self.pb_update_body.setObjectName(u"pb_update_body")
|
||||
self.pb_update_connection = QPushButton(self.groupBox_13)
|
||||
self.pb_update_connection.setObjectName(u"pb_update_connection")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
|
||||
self.gridLayout_13.addWidget(self.pb_update_connection, 0, 0, 1, 1)
|
||||
|
||||
self.pb_edt_sktch_3 = QPushButton(self.groupBox_8)
|
||||
self.pb_edt_sktch_3.setObjectName(u"pb_edt_sktch_3")
|
||||
self.pb_edt_sktch_4 = QPushButton(self.groupBox_13)
|
||||
self.pb_edt_sktch_4.setObjectName(u"pb_edt_sktch_4")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_edt_sktch_3, 0, 1, 1, 1)
|
||||
self.gridLayout_13.addWidget(self.pb_edt_sktch_4, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_6.addWidget(self.groupBox_8)
|
||||
self.verticalLayout_8.addWidget(self.groupBox_13)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_10, 8, 3, 3, 1)
|
||||
self.gridLayout.addWidget(self.groupBox_12, 4, 3, 1, 1)
|
||||
|
||||
self.joint_tools = QGroupBox(self.centralwidget)
|
||||
self.joint_tools.setObjectName(u"joint_tools")
|
||||
self.joint_tools.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_10 = QGridLayout(self.joint_tools)
|
||||
self.gridLayout_10.setObjectName(u"gridLayout_10")
|
||||
self.pb_add_connector = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector.setObjectName(u"pb_add_connector")
|
||||
self.pb_add_connector.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector, 0, 0, 1, 1)
|
||||
|
||||
self.pb_remove_connector = QPushButton(self.joint_tools)
|
||||
self.pb_remove_connector.setObjectName(u"pb_remove_connector")
|
||||
self.pb_remove_connector.setMinimumSize(QSize(50, 50))
|
||||
self.pb_remove_connector.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 1, 1, 1)
|
||||
|
||||
self.pb_add_connector_3 = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
|
||||
self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 1, 1, 1)
|
||||
|
||||
self.pb_add_connector_2 = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
|
||||
self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 0, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.joint_tools, 7, 3, 2, 1)
|
||||
|
||||
self.gl_box = QGroupBox(self.centralwidget)
|
||||
self.gl_box.setObjectName(u"gl_box")
|
||||
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||
sizePolicy3.setHorizontalStretch(0)
|
||||
sizePolicy3.setVerticalStretch(4)
|
||||
sizePolicy3.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
|
||||
self.gl_box.setSizePolicy(sizePolicy3)
|
||||
font = QFont()
|
||||
font.setPointSize(12)
|
||||
self.gl_box.setFont(font)
|
||||
self.horizontalLayout_4 = QHBoxLayout(self.gl_box)
|
||||
#ifndef Q_OS_MAC
|
||||
self.horizontalLayout_4.setSpacing(-1)
|
||||
#endif
|
||||
self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
|
||||
self.horizontalLayout_4.setContentsMargins(12, -1, -1, -1)
|
||||
|
||||
self.gridLayout.addWidget(self.gl_box, 0, 2, 5, 1)
|
||||
|
||||
self.compo_box = QGroupBox(self.centralwidget)
|
||||
self.compo_box.setObjectName(u"compo_box")
|
||||
self.compo_box.setMinimumSize(QSize(0, 120))
|
||||
|
||||
self.gridLayout.addWidget(self.compo_box, 7, 1, 1, 2)
|
||||
|
||||
self.groupBox_11 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_11.setObjectName(u"groupBox_11")
|
||||
sizePolicy1.setHeightForWidth(self.groupBox_11.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_11.setSizePolicy(sizePolicy1)
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_11.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_11.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_11.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_7 = QVBoxLayout(self.groupBox_11)
|
||||
self.verticalLayout_7.setObjectName(u"verticalLayout_7")
|
||||
self.verticalLayout_7.setContentsMargins(5, 5, 5, 5)
|
||||
self.sketch_list = QListWidget(self.groupBox_11)
|
||||
self.sketch_list.setObjectName(u"sketch_list")
|
||||
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy2.setHorizontalStretch(0)
|
||||
sizePolicy2.setVerticalStretch(0)
|
||||
sizePolicy2.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
|
||||
self.sketch_list.setSizePolicy(sizePolicy2)
|
||||
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy4.setHorizontalStretch(0)
|
||||
sizePolicy4.setVerticalStretch(0)
|
||||
sizePolicy4.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
|
||||
self.sketch_list.setSizePolicy(sizePolicy4)
|
||||
self.sketch_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_7.addWidget(self.sketch_list)
|
||||
@@ -344,193 +544,7 @@ class Ui_fluencyCAD(object):
|
||||
self.verticalLayout_7.addWidget(self.groupBox_6)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_11, 8, 0, 3, 1)
|
||||
|
||||
self.groupBox_4 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_4.setObjectName(u"groupBox_4")
|
||||
self.verticalLayout_2 = QVBoxLayout(self.groupBox_4)
|
||||
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
|
||||
self.pushButton_2 = QPushButton(self.groupBox_4)
|
||||
self.pushButton_2.setObjectName(u"pushButton_2")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.pushButton_2)
|
||||
|
||||
self.pb_export_step = QPushButton(self.groupBox_4)
|
||||
self.pb_export_step.setObjectName(u"pb_export_step")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.pb_export_step)
|
||||
|
||||
self.pb_export_iges = QPushButton(self.groupBox_4)
|
||||
self.pb_export_iges.setObjectName(u"pb_export_iges")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.pb_export_iges)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_4, 2, 3, 1, 1)
|
||||
|
||||
self.InputTab = QTabWidget(self.centralwidget)
|
||||
self.InputTab.setObjectName(u"InputTab")
|
||||
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
sizePolicy3.setHorizontalStretch(0)
|
||||
sizePolicy3.setVerticalStretch(0)
|
||||
sizePolicy3.setHeightForWidth(self.InputTab.sizePolicy().hasHeightForWidth())
|
||||
self.InputTab.setSizePolicy(sizePolicy3)
|
||||
self.sketch_tab = QWidget()
|
||||
self.sketch_tab.setObjectName(u"sketch_tab")
|
||||
self.verticalLayout_4 = QVBoxLayout(self.sketch_tab)
|
||||
self.verticalLayout_4.setObjectName(u"verticalLayout_4")
|
||||
self.InputTab.addTab(self.sketch_tab, "")
|
||||
self.code_tab = QWidget()
|
||||
self.code_tab.setObjectName(u"code_tab")
|
||||
self.verticalLayout = QVBoxLayout(self.code_tab)
|
||||
self.verticalLayout.setObjectName(u"verticalLayout")
|
||||
self.textEdit = QTextEdit(self.code_tab)
|
||||
self.textEdit.setObjectName(u"textEdit")
|
||||
|
||||
self.verticalLayout.addWidget(self.textEdit)
|
||||
|
||||
self.groupBox_7 = QGroupBox(self.code_tab)
|
||||
self.groupBox_7.setObjectName(u"groupBox_7")
|
||||
self.gridLayout_5 = QGridLayout(self.groupBox_7)
|
||||
self.gridLayout_5.setObjectName(u"gridLayout_5")
|
||||
self.pushButton_5 = QPushButton(self.groupBox_7)
|
||||
self.pushButton_5.setObjectName(u"pushButton_5")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton_5, 2, 0, 1, 1)
|
||||
|
||||
self.pushButton_4 = QPushButton(self.groupBox_7)
|
||||
self.pushButton_4.setObjectName(u"pushButton_4")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton_4, 2, 1, 1, 1)
|
||||
|
||||
self.pb_apply_code = QPushButton(self.groupBox_7)
|
||||
self.pb_apply_code.setObjectName(u"pb_apply_code")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pb_apply_code, 1, 0, 1, 1)
|
||||
|
||||
self.pushButton = QPushButton(self.groupBox_7)
|
||||
self.pushButton.setObjectName(u"pushButton")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton, 1, 1, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout.addWidget(self.groupBox_7)
|
||||
|
||||
self.InputTab.addTab(self.code_tab, "")
|
||||
|
||||
self.gridLayout.addWidget(self.InputTab, 0, 1, 11, 1)
|
||||
|
||||
self.assembly_tools = QGroupBox(self.centralwidget)
|
||||
self.assembly_tools.setObjectName(u"assembly_tools")
|
||||
self.assembly_tools.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_12 = QGridLayout(self.assembly_tools)
|
||||
self.gridLayout_12.setObjectName(u"gridLayout_12")
|
||||
self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
|
||||
self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
|
||||
self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
|
||||
self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_12.addWidget(self.pb_compo_to_assembly, 0, 0, 1, 1)
|
||||
|
||||
self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
|
||||
self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
|
||||
self.pb_remove_compo_from_assembly.setEnabled(True)
|
||||
sizePolicy.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
|
||||
self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy)
|
||||
self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
|
||||
self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
|
||||
self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
|
||||
|
||||
self.gridLayout_12.addWidget(self.pb_remove_compo_from_assembly, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.assembly_tools, 12, 0, 1, 1)
|
||||
|
||||
self.gl_box = QGroupBox(self.centralwidget)
|
||||
self.gl_box.setObjectName(u"gl_box")
|
||||
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy4.setHorizontalStretch(0)
|
||||
sizePolicy4.setVerticalStretch(4)
|
||||
sizePolicy4.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
|
||||
self.gl_box.setSizePolicy(sizePolicy4)
|
||||
font = QFont()
|
||||
font.setPointSize(12)
|
||||
self.gl_box.setFont(font)
|
||||
self.horizontalLayout_4 = QHBoxLayout(self.gl_box)
|
||||
#ifndef Q_OS_MAC
|
||||
self.horizontalLayout_4.setSpacing(-1)
|
||||
#endif
|
||||
self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
|
||||
self.horizontalLayout_4.setContentsMargins(12, -1, -1, -1)
|
||||
|
||||
self.gridLayout.addWidget(self.gl_box, 0, 2, 11, 1)
|
||||
|
||||
self.groupBox_3 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_3.setObjectName(u"groupBox_3")
|
||||
sizePolicy.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_3.setSizePolicy(sizePolicy)
|
||||
self.groupBox_3.setMaximumSize(QSize(200, 16777213))
|
||||
self.gridLayout_4 = QGridLayout(self.groupBox_3)
|
||||
self.gridLayout_4.setObjectName(u"gridLayout_4")
|
||||
self.pb_con_vert = QPushButton(self.groupBox_3)
|
||||
self.pb_con_vert.setObjectName(u"pb_con_vert")
|
||||
self.pb_con_vert.setCheckable(True)
|
||||
self.pb_con_vert.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_vert, 3, 1, 1, 1)
|
||||
|
||||
self.pb_con_mid = QPushButton(self.groupBox_3)
|
||||
self.pb_con_mid.setObjectName(u"pb_con_mid")
|
||||
self.pb_con_mid.setCheckable(True)
|
||||
self.pb_con_mid.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_mid, 2, 0, 1, 1)
|
||||
|
||||
self.pb_con_dist = QPushButton(self.groupBox_3)
|
||||
self.pb_con_dist.setObjectName(u"pb_con_dist")
|
||||
self.pb_con_dist.setCheckable(True)
|
||||
self.pb_con_dist.setAutoExclusive(False)
|
||||
self.pb_con_dist.setAutoRepeatDelay(297)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_dist, 4, 0, 1, 1)
|
||||
|
||||
self.pb_con_line = QPushButton(self.groupBox_3)
|
||||
self.pb_con_line.setObjectName(u"pb_con_line")
|
||||
self.pb_con_line.setCheckable(True)
|
||||
self.pb_con_line.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_line, 1, 1, 1, 1)
|
||||
|
||||
self.pb_con_perp = QPushButton(self.groupBox_3)
|
||||
self.pb_con_perp.setObjectName(u"pb_con_perp")
|
||||
self.pb_con_perp.setCheckable(True)
|
||||
self.pb_con_perp.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_perp, 2, 1, 1, 1)
|
||||
|
||||
self.pb_con_sym = QPushButton(self.groupBox_3)
|
||||
self.pb_con_sym.setObjectName(u"pb_con_sym")
|
||||
self.pb_con_sym.setCheckable(True)
|
||||
self.pb_con_sym.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_sym, 4, 1, 1, 1)
|
||||
|
||||
self.pb_con_horiz = QPushButton(self.groupBox_3)
|
||||
self.pb_con_horiz.setObjectName(u"pb_con_horiz")
|
||||
self.pb_con_horiz.setCheckable(True)
|
||||
self.pb_con_horiz.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_horiz, 3, 0, 1, 1)
|
||||
|
||||
self.pb_con_ptpt = QPushButton(self.groupBox_3)
|
||||
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
|
||||
self.pb_con_ptpt.setCheckable(True)
|
||||
self.pb_con_ptpt.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_ptpt, 1, 0, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_3, 4, 0, 1, 1)
|
||||
self.gridLayout.addWidget(self.groupBox_11, 4, 0, 1, 1)
|
||||
|
||||
self.groupBox_2 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_2.setObjectName(u"groupBox_2")
|
||||
@@ -558,7 +572,7 @@ class Ui_fluencyCAD(object):
|
||||
self.pb_circtool.setCheckable(True)
|
||||
self.pb_circtool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_circtool, 1, 0, 1, 1, Qt.AlignTop)
|
||||
self.gridLayout_2.addWidget(self.pb_circtool, 1, 0, 1, 1)
|
||||
|
||||
self.pb_enable_construct = QPushButton(self.groupBox_2)
|
||||
self.pb_enable_construct.setObjectName(u"pb_enable_construct")
|
||||
@@ -586,7 +600,7 @@ class Ui_fluencyCAD(object):
|
||||
self.pb_slotool.setCheckable(True)
|
||||
self.pb_slotool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1, Qt.AlignTop)
|
||||
self.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1)
|
||||
|
||||
self.line = QFrame(self.groupBox_2)
|
||||
self.line.setObjectName(u"line")
|
||||
@@ -601,54 +615,126 @@ class Ui_fluencyCAD(object):
|
||||
self.gridLayout_2.addWidget(self.pb_offset_tool, 2, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_2, 2, 0, 1, 1)
|
||||
self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
|
||||
|
||||
self.assembly_box = QGroupBox(self.centralwidget)
|
||||
self.assembly_box.setObjectName(u"assembly_box")
|
||||
self.assembly_box.setMinimumSize(QSize(0, 50))
|
||||
self.assembly_box.setMinimumSize(QSize(0, 120))
|
||||
|
||||
self.gridLayout.addWidget(self.assembly_box, 12, 1, 1, 2)
|
||||
self.gridLayout.addWidget(self.assembly_box, 8, 1, 1, 2)
|
||||
|
||||
self.joint_tools = QGroupBox(self.centralwidget)
|
||||
self.joint_tools.setObjectName(u"joint_tools")
|
||||
self.joint_tools.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_10 = QGridLayout(self.joint_tools)
|
||||
self.gridLayout_10.setObjectName(u"gridLayout_10")
|
||||
self.pb_remove_connector = QPushButton(self.joint_tools)
|
||||
self.pb_remove_connector.setObjectName(u"pb_remove_connector")
|
||||
self.pb_remove_connector.setMinimumSize(QSize(50, 50))
|
||||
self.pb_remove_connector.setMaximumSize(QSize(50, 50))
|
||||
self.groupBox = QGroupBox(self.centralwidget)
|
||||
self.groupBox.setObjectName(u"groupBox")
|
||||
self.groupBox.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_3 = QGridLayout(self.groupBox)
|
||||
self.gridLayout_3.setObjectName(u"gridLayout_3")
|
||||
self.pb_chamfer_op = QPushButton(self.groupBox)
|
||||
self.pb_chamfer_op.setObjectName(u"pb_chamfer_op")
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 2, 1, 1)
|
||||
self.gridLayout_3.addWidget(self.pb_chamfer_op, 2, 1, 1, 1)
|
||||
|
||||
self.pb_add_connector = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector.setObjectName(u"pb_add_connector")
|
||||
self.pb_add_connector.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector.setMaximumSize(QSize(50, 50))
|
||||
self.pb_fillet_op = QPushButton(self.groupBox)
|
||||
self.pb_fillet_op.setObjectName(u"pb_fillet_op")
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector, 0, 1, 1, 1)
|
||||
self.gridLayout_3.addWidget(self.pb_fillet_op, 2, 0, 1, 1)
|
||||
|
||||
self.pb_add_connector_2 = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
|
||||
self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
|
||||
self.pb_thread = QPushButton(self.groupBox)
|
||||
self.pb_thread.setObjectName(u"pb_thread")
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 1, 1, 1)
|
||||
self.gridLayout_3.addWidget(self.pb_thread, 6, 0, 1, 1)
|
||||
|
||||
self.pb_add_connector_3 = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
|
||||
self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
|
||||
self.pb_extrdop = QPushButton(self.groupBox)
|
||||
self.pb_extrdop.setObjectName(u"pb_extrdop")
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 2, 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.setObjectName(u"pb_cutop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
|
||||
|
||||
self.pb_arrayop = QPushButton(self.groupBox)
|
||||
self.pb_arrayop.setObjectName(u"pb_arrayop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_arrayop, 4, 0, 1, 1)
|
||||
|
||||
self.pb_combop = QPushButton(self.groupBox)
|
||||
self.pb_combop.setObjectName(u"pb_combop")
|
||||
|
||||
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.joint_tools, 11, 3, 2, 1)
|
||||
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
|
||||
|
||||
self.line_3 = QFrame(self.centralwidget)
|
||||
self.line_3.setObjectName(u"line_3")
|
||||
self.line_3.setFrameShape(QFrame.Shape.HLine)
|
||||
self.line_3.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
|
||||
self.gridLayout.addWidget(self.line_3, 5, 0, 1, 4)
|
||||
|
||||
self.groupBox_10 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_10.setObjectName(u"groupBox_10")
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_10.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_10.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
|
||||
self.verticalLayout_6.setObjectName(u"verticalLayout_6")
|
||||
self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
|
||||
self.body_list = QListWidget(self.groupBox_10)
|
||||
self.body_list.setObjectName(u"body_list")
|
||||
self.body_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_6.addWidget(self.body_list)
|
||||
|
||||
self.groupBox_8 = QGroupBox(self.groupBox_10)
|
||||
self.groupBox_8.setObjectName(u"groupBox_8")
|
||||
sizePolicy.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_8.setSizePolicy(sizePolicy)
|
||||
self.groupBox_8.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_8 = QGridLayout(self.groupBox_8)
|
||||
self.gridLayout_8.setObjectName(u"gridLayout_8")
|
||||
self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_body_hide = QPushButton(self.groupBox_8)
|
||||
self.pb_body_hide.setObjectName(u"pb_body_hide")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_body_hide, 0, 1, 1, 1)
|
||||
|
||||
self.pb_update_body = QPushButton(self.groupBox_8)
|
||||
self.pb_update_body.setObjectName(u"pb_update_body")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
|
||||
|
||||
self.pb_del_body = QPushButton(self.groupBox_8)
|
||||
self.pb_del_body.setObjectName(u"pb_del_body")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_6.addWidget(self.groupBox_8)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_10, 2, 3, 2, 1)
|
||||
|
||||
fluencyCAD.setCentralWidget(self.centralwidget)
|
||||
self.menubar = QMenuBar(fluencyCAD)
|
||||
self.menubar.setObjectName(u"menubar")
|
||||
self.menubar.setGeometry(QRect(0, 0, 1941, 24))
|
||||
self.menubar.setGeometry(QRect(0, 0, 2551, 24))
|
||||
self.menuFile = QMenu(self.menubar)
|
||||
self.menuFile.setObjectName(u"menuFile")
|
||||
self.menuSettings = QMenu(self.menubar)
|
||||
@@ -718,17 +804,54 @@ class Ui_fluencyCAD(object):
|
||||
self.pushButton_8.setText(QCoreApplication.translate("fluencyCAD", u"Pnt", None))
|
||||
self.pb_snap_midp.setText(QCoreApplication.translate("fluencyCAD", u"MidP", None))
|
||||
self.pb_snap_angle.setText(QCoreApplication.translate("fluencyCAD", u"Angles", None))
|
||||
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
||||
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
||||
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
|
||||
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
|
||||
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
|
||||
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
||||
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
||||
self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
|
||||
self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
|
||||
self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
|
||||
self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", None))
|
||||
self.InputTab.setTabText(self.InputTab.indexOf(self.sketch_tab), QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||
self.groupBox_7.setTitle(QCoreApplication.translate("fluencyCAD", u"Executive", None))
|
||||
self.pushButton_5.setText(QCoreApplication.translate("fluencyCAD", u"Load Code", None))
|
||||
self.pushButton_4.setText(QCoreApplication.translate("fluencyCAD", u"Save code", None))
|
||||
self.pb_apply_code.setText(QCoreApplication.translate("fluencyCAD", u"Apply Code", None))
|
||||
self.pushButton.setText(QCoreApplication.translate("fluencyCAD", u"Delete Code", None))
|
||||
self.InputTab.setTabText(self.InputTab.indexOf(self.code_tab), QCoreApplication.translate("fluencyCAD", u"Code", None))
|
||||
self.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
|
||||
self.pb_new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
|
||||
self.pb_del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", None))
|
||||
self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_ptpt.setToolTip(QCoreApplication.translate("fluencyCAD", u"Poin to Point Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_ptpt.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Pt", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_vert.setToolTip(QCoreApplication.translate("fluencyCAD", u"Vertical Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||
self.pb_con_sym.setText(QCoreApplication.translate("fluencyCAD", u"Symetrc", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_mid.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Middle Point Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_mid.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Mid_L", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_line.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Line Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_line.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Lne", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_horiz.setToolTip(QCoreApplication.translate("fluencyCAD", u"Horizontal Constrain ", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_dist.setToolTip(QCoreApplication.translate("fluencyCAD", u"Dimension of Line of Distance from Point to Line", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_dist.setText(QCoreApplication.translate("fluencyCAD", u"Distnce", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_perp.setToolTip(QCoreApplication.translate("fluencyCAD", u"Constrain Line perpendicular to another line.", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_perp.setText(QCoreApplication.translate("fluencyCAD", u"Perp_Lne", None))
|
||||
self.pb_con_diameter.setText(QCoreApplication.translate("fluencyCAD", u"Diameter", None))
|
||||
self.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
|
||||
self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||
self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", None))
|
||||
self.groupBox_9.setTitle(QCoreApplication.translate("fluencyCAD", u"Workplanes", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_origin_wp.setToolTip(QCoreApplication.translate("fluencyCAD", u"<W>orking Plane at 0, 0, 0", None))
|
||||
@@ -770,61 +893,23 @@ class Ui_fluencyCAD(object):
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_wp_new.setShortcut(QCoreApplication.translate("fluencyCAD", u"Shift+W", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
||||
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||
self.pb_edt_sktch_3.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||
self.groupBox_12.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Connections", None))
|
||||
self.groupBox_13.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.pb_update_connection.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||
self.pb_edt_sktch_4.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||
self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
|
||||
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
|
||||
self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
|
||||
self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
|
||||
self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
|
||||
self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
|
||||
self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", None))
|
||||
self.groupBox_11.setTitle(QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||
self.groupBox_6.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_edt_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Edt", None))
|
||||
self.pb_nw_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||
self.pb_del_sketch.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
|
||||
self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
|
||||
self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", None))
|
||||
self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
|
||||
self.InputTab.setTabText(self.InputTab.indexOf(self.sketch_tab), QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||
self.groupBox_7.setTitle(QCoreApplication.translate("fluencyCAD", u"Executive", None))
|
||||
self.pushButton_5.setText(QCoreApplication.translate("fluencyCAD", u"Load Code", None))
|
||||
self.pushButton_4.setText(QCoreApplication.translate("fluencyCAD", u"Save code", None))
|
||||
self.pb_apply_code.setText(QCoreApplication.translate("fluencyCAD", u"Apply Code", None))
|
||||
self.pushButton.setText(QCoreApplication.translate("fluencyCAD", u"Delete Code", None))
|
||||
self.InputTab.setTabText(self.InputTab.indexOf(self.code_tab), QCoreApplication.translate("fluencyCAD", u"Code", None))
|
||||
self.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
|
||||
self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||
self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", None))
|
||||
self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
|
||||
self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_vert.setToolTip(QCoreApplication.translate("fluencyCAD", u"Vertical Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_mid.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Middle Point Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_mid.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Mid_L", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_dist.setToolTip(QCoreApplication.translate("fluencyCAD", u"Dimension of Line of Distance from Point to Line", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_dist.setText(QCoreApplication.translate("fluencyCAD", u"Distnce", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_line.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Line Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_line.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Lne", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_perp.setToolTip(QCoreApplication.translate("fluencyCAD", u"Constrain Line perpendicular to another line.", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_perp.setText(QCoreApplication.translate("fluencyCAD", u"Perp_Lne", None))
|
||||
self.pb_con_sym.setText(QCoreApplication.translate("fluencyCAD", u"Symetrc", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_horiz.setToolTip(QCoreApplication.translate("fluencyCAD", u"Horizontal Constrain ", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_ptpt.setToolTip(QCoreApplication.translate("fluencyCAD", u"Poin to Point Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_ptpt.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Pt", None))
|
||||
self.groupBox_2.setTitle(QCoreApplication.translate("fluencyCAD", u"Drawing", None))
|
||||
self.pb_arc_tool.setText(QCoreApplication.translate("fluencyCAD", u"Arc", None))
|
||||
self.pb_rectool.setText(QCoreApplication.translate("fluencyCAD", u"Rctgl", None))
|
||||
@@ -841,11 +926,22 @@ class Ui_fluencyCAD(object):
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
|
||||
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
|
||||
self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
|
||||
self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
|
||||
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
|
||||
self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
|
||||
self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
|
||||
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
||||
self.pb_chamfer_op.setText(QCoreApplication.translate("fluencyCAD", u"Chamfer", None))
|
||||
self.pb_fillet_op.setText(QCoreApplication.translate("fluencyCAD", u"Fillet", 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_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_body_hide.setText(QCoreApplication.translate("fluencyCAD", u"Hide", None))
|
||||
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
|
||||
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
|
||||
# retranslateUi
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 267 B |
@@ -406,6 +406,11 @@ class SketchInterface(ABC):
|
||||
"""Constrain two circles to have equal radius."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def constrain_diameter(self, circle: SketchEntity, diameter: float) -> bool:
|
||||
"""Set the diameter of a circle."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def constrain_fixed(self, entity: SketchEntity) -> bool:
|
||||
"""Fix an entity in place."""
|
||||
|
||||
@@ -5,6 +5,7 @@ This module provides a concrete implementation of the geometry kernel
|
||||
using OCP (OpenCASCADE Python bindings).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Tuple, Optional, Any, Dict
|
||||
import numpy as np
|
||||
|
||||
@@ -16,6 +17,35 @@ from fluency.geometry.base import (
|
||||
)
|
||||
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _curve_is_linear(occ_edge: Any) -> bool:
|
||||
"""Return True if *occ_edge* has a linear or chamferable curve type.
|
||||
|
||||
``BRepFilletAPI_MakeChamfer`` and ``BRepFilletAPI_MakeFillet`` crash
|
||||
(segfault) on circular/elliptical curves. We pre-filter those out.
|
||||
"""
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
||||
from OCP.GeomAbs import GeomAbs_CurveType
|
||||
|
||||
try:
|
||||
ad = BRepAdaptor_Curve(occ_edge)
|
||||
ct = ad.GetType()
|
||||
except Exception:
|
||||
# If we can't classify, assume it's safe (will be caught later).
|
||||
return True
|
||||
# Chamfer/fillet only support linear curves reliably.
|
||||
return ct in (
|
||||
GeomAbs_CurveType.GeomAbs_Line,
|
||||
GeomAbs_CurveType.GeomAbs_BSplineCurve,
|
||||
GeomAbs_CurveType.GeomAbs_BezierCurve,
|
||||
GeomAbs_CurveType.GeomAbs_OffsetCurve,
|
||||
GeomAbs_CurveType.GeomAbs_Parabola,
|
||||
GeomAbs_CurveType.GeomAbs_Hyperbola,
|
||||
)
|
||||
|
||||
|
||||
class OCCGeometryObject(GeometryObject):
|
||||
"""Geometry object wrapper for OpenCASCADE shapes."""
|
||||
|
||||
@@ -54,6 +84,7 @@ class OCGeometryKernel(GeometryKernel):
|
||||
def create_point(self, x: float, y: float) -> GeometryObject:
|
||||
"""Create a 2D point."""
|
||||
from OCP.gp import gp_Pnt
|
||||
|
||||
return OCCGeometryObject(gp_Pnt(x, y, 0))
|
||||
|
||||
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
|
||||
@@ -61,9 +92,7 @@ class OCGeometryKernel(GeometryKernel):
|
||||
from OCP.gp import gp_Pnt
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
|
||||
|
||||
edge = BRepBuilderAPI_MakeEdge(
|
||||
gp_Pnt(start.x, start.y, 0), gp_Pnt(end.x, end.y, 0)
|
||||
).Edge()
|
||||
edge = BRepBuilderAPI_MakeEdge(gp_Pnt(start.x, start.y, 0), gp_Pnt(end.x, end.y, 0)).Edge()
|
||||
return OCCGeometryObject(edge, {"type": "line"})
|
||||
|
||||
def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
|
||||
@@ -71,14 +100,12 @@ class OCGeometryKernel(GeometryKernel):
|
||||
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
|
||||
|
||||
circ = gp_Circ(
|
||||
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
|
||||
)
|
||||
circ = gp_Circ(gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius)
|
||||
edge = BRepBuilderAPI_MakeEdge(circ).Edge()
|
||||
return OCCGeometryObject(edge, {"type": "circle"})
|
||||
|
||||
def create_arc(
|
||||
self, center: Point2D, radius: float, start_angle: float, end_angle: float
|
||||
self, center: Point2D, radius: float , start_angle: float, end_angle: float
|
||||
) -> GeometryObject:
|
||||
"""Create a 2D arc."""
|
||||
import math
|
||||
@@ -88,9 +115,7 @@ class OCGeometryKernel(GeometryKernel):
|
||||
start_rad = math.radians(start_angle)
|
||||
end_rad = math.radians(end_angle)
|
||||
|
||||
circ = gp_Circ(
|
||||
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
|
||||
)
|
||||
circ = gp_Circ(gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius)
|
||||
edge = BRepBuilderAPI_MakeEdge(circ, start_rad, end_rad).Edge()
|
||||
return OCCGeometryObject(edge, {"type": "arc"})
|
||||
|
||||
@@ -159,8 +184,7 @@ class OCGeometryKernel(GeometryKernel):
|
||||
face = self._get_shape(sketch)
|
||||
if face is None:
|
||||
raise ValueError(
|
||||
"Cannot extrude: sketch has no geometry. "
|
||||
"Draw a closed profile before extruding."
|
||||
"Cannot extrude: sketch has no geometry. Draw a closed profile before extruding."
|
||||
)
|
||||
# If the wrapper class itself leaked through somehow, surface a
|
||||
# clear error instead of letting BRepPrimAPI_MakePrism raise an
|
||||
@@ -204,6 +228,7 @@ class OCGeometryKernel(GeometryKernel):
|
||||
def _sketch_normal(obj: GeometryObject) -> Tuple[float, float, float]:
|
||||
"""Return the normal stored on a sketch-derived geometry object, else +Z."""
|
||||
import numpy as np
|
||||
|
||||
meta = getattr(obj, "metadata", None) or {}
|
||||
n = meta.get("normal")
|
||||
if n is None:
|
||||
@@ -240,6 +265,93 @@ class OCGeometryKernel(GeometryKernel):
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def find_coplanar_face(
|
||||
shape: Any,
|
||||
origin: Tuple[float, float, float],
|
||||
normal: Tuple[float, float, float],
|
||||
ref_center: Optional[Tuple[float, float, float]] = None,
|
||||
angle_tol_deg: float = 5.0,
|
||||
dist_tol: float = 1e-3,
|
||||
) -> Optional[Tuple[Any, Tuple[float, float, float]]]:
|
||||
"""Find a planar face on *shape* coplanar with the given plane.
|
||||
|
||||
Iterates the faces of *shape* and returns the first planar face whose
|
||||
plane normal is parallel to *normal* (within *angle_tol_deg* degrees)
|
||||
and whose plane passes through *origin* (within *dist_tol* distance).
|
||||
When several faces match, the one whose surface centre is closest to
|
||||
*ref_center* (if provided) is preferred.
|
||||
|
||||
Returns ``(face, center)`` where *center* is the surface centroid as a
|
||||
3-tuple, or *None* if no matching face is found.
|
||||
"""
|
||||
import math
|
||||
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCP.GeomAbs import GeomAbs_Plane
|
||||
from OCP.BRepGProp import BRepGProp
|
||||
from OCP.GProp import GProp_GProps
|
||||
|
||||
import numpy as np
|
||||
|
||||
if shape is None:
|
||||
return None
|
||||
n = np.asarray(normal, dtype=float)
|
||||
n = n / (np.linalg.norm(n) + 1e-30)
|
||||
ox, oy, oz = origin
|
||||
|
||||
cos_tol = math.cos(math.radians(angle_tol_deg))
|
||||
candidates: list = []
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while explorer.More():
|
||||
face = TopoDS.Face_s(explorer.Current())
|
||||
try:
|
||||
surf = BRepAdaptor_Surface(face)
|
||||
if surf.GetType() != GeomAbs_Plane:
|
||||
explorer.Next()
|
||||
continue
|
||||
plane = surf.Plane()
|
||||
pn = np.array(
|
||||
[
|
||||
plane.Axis().Direction().X(),
|
||||
plane.Axis().Direction().Y(),
|
||||
plane.Axis().Direction().Z(),
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
# Check normals parallel (same or opposite direction)
|
||||
cos_angle = abs(float(np.dot(n, pn)))
|
||||
if cos_angle < cos_tol:
|
||||
explorer.Next()
|
||||
continue
|
||||
# Check distance from plane to origin
|
||||
pp = plane.Location()
|
||||
d = abs(float(np.dot(n, np.array([pp.X() - ox, pp.Y() - oy, pp.Z() - oz]))))
|
||||
if d > dist_tol:
|
||||
explorer.Next()
|
||||
continue
|
||||
# Surface centroid via GProp (SurfaceProperties for faces)
|
||||
props = GProp_GProps()
|
||||
BRepGProp.SurfaceProperties_s(face, props)
|
||||
c = props.CentreOfMass()
|
||||
center = (float(c.X()), float(c.Y()), float(c.Z()))
|
||||
candidates.append((face, center))
|
||||
except Exception:
|
||||
pass
|
||||
explorer.Next()
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
if ref_center is not None and len(candidates) > 1:
|
||||
rc = np.asarray(ref_center, dtype=float)
|
||||
best = min(candidates, key=lambda fc: float(np.linalg.norm(np.asarray(fc[1]) - rc)))
|
||||
return best
|
||||
return candidates[0]
|
||||
|
||||
def revolve(
|
||||
self,
|
||||
sketch: GeometryObject,
|
||||
@@ -361,50 +473,82 @@ class OCGeometryKernel(GeometryKernel):
|
||||
def fillet(
|
||||
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
|
||||
) -> GeometryObject:
|
||||
"""Apply fillet to edges."""
|
||||
shape = self._get_shape(body)
|
||||
"""Apply fillet to edges. Skips edges that cannot be filleted."""
|
||||
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
|
||||
|
||||
fillet = BRepFilletAPI_MakeFillet(shape)
|
||||
shape: Any = self._get_shape(body)
|
||||
if shape is None:
|
||||
return OCCGeometryObject(None, {"type": "fillet"})
|
||||
|
||||
if edges:
|
||||
for edge in edges:
|
||||
fillet.Add(radius, edge)
|
||||
# Collect candidate edges
|
||||
if edges is not None:
|
||||
candidates = list(edges)
|
||||
else:
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_EDGE
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||
candidates = []
|
||||
while explorer.More():
|
||||
fillet.Add(radius, explorer.Current())
|
||||
e = TopoDS.Edge_s(explorer.Current())
|
||||
if _curve_is_linear(e):
|
||||
candidates.append(e)
|
||||
explorer.Next()
|
||||
|
||||
fillet.Build()
|
||||
return OCCGeometryObject(fillet.Shape(), {"type": "fillet"})
|
||||
# Add all edges, then Build once — avoids OCC internal crashes
|
||||
fl = BRepFilletAPI_MakeFillet(shape)
|
||||
for edge in candidates:
|
||||
try:
|
||||
fl.Add(radius, edge)
|
||||
except Exception:
|
||||
pass # skip edges that fail to add
|
||||
|
||||
fl.Build()
|
||||
if fl.IsDone():
|
||||
return OCCGeometryObject(fl.Shape(), {"type": "fillet"})
|
||||
# If Build failed, return original shape (no fillet applied)
|
||||
return OCCGeometryObject(shape, {"type": "fillet"})
|
||||
|
||||
def chamfer(
|
||||
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
|
||||
) -> GeometryObject:
|
||||
"""Apply chamfer to edges."""
|
||||
shape = self._get_shape(body)
|
||||
"""Apply chamfer to edges. Skips edges that cannot be chamfered."""
|
||||
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
|
||||
|
||||
chamfer = BRepFilletAPI_MakeChamfer(shape)
|
||||
shape: Any = self._get_shape(body)
|
||||
if shape is None:
|
||||
return OCCGeometryObject(None, {"type": "chamfer"})
|
||||
|
||||
if edges:
|
||||
for edge in edges:
|
||||
chamfer.Add(size, edge)
|
||||
# Collect candidate edges
|
||||
if edges is not None:
|
||||
candidates = list(edges)
|
||||
else:
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_EDGE
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||
candidates = []
|
||||
while explorer.More():
|
||||
chamfer.Add(size, explorer.Current())
|
||||
e = TopoDS.Edge_s(explorer.Current())
|
||||
if _curve_is_linear(e):
|
||||
candidates.append(e)
|
||||
explorer.Next()
|
||||
|
||||
chamfer.Build()
|
||||
return OCCGeometryObject(chamfer.Shape(), {"type": "chamfer"})
|
||||
# Add all edges, then Build once — avoids OCC internal crashes
|
||||
mc = BRepFilletAPI_MakeChamfer(shape)
|
||||
for edge in candidates:
|
||||
try:
|
||||
mc.Add(size, edge)
|
||||
except Exception:
|
||||
pass # skip edges that fail to add
|
||||
|
||||
mc.Build()
|
||||
if mc.IsDone():
|
||||
return OCCGeometryObject(mc.Shape(), {"type": "chamfer"})
|
||||
# If Build failed, return original shape (no chamfer applied)
|
||||
return OCCGeometryObject(shape, {"type": "chamfer"})
|
||||
|
||||
def shell(
|
||||
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
|
||||
@@ -453,7 +597,6 @@ class OCGeometryKernel(GeometryKernel):
|
||||
origin: Tuple[float, float, float] = (0, 0, 0),
|
||||
) -> GeometryObject:
|
||||
"""Rotate a body around an axis."""
|
||||
import math
|
||||
shape = self._get_shape(body)
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir
|
||||
@@ -494,6 +637,67 @@ class OCGeometryKernel(GeometryKernel):
|
||||
|
||||
return OCCGeometryObject(transformer.Shape(), {"type": "mirrored"})
|
||||
|
||||
def pattern(
|
||||
self,
|
||||
body: GeometryObject,
|
||||
pattern_type: str = "linear",
|
||||
count: int = 2,
|
||||
direction: Tuple[float, float, float] = (1, 0, 0),
|
||||
spacing: float = 10.0,
|
||||
axis: Tuple[float, float, float] = (0, 0, 1),
|
||||
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0),
|
||||
angle: float = 360.0,
|
||||
) -> GeometryObject:
|
||||
"""Repeat *body* in a linear or circular array (pattern).
|
||||
|
||||
Linear: *count* copies spaced *spacing* mm apart along
|
||||
*direction* (a negative spacing reverses the direction).
|
||||
|
||||
Circular: *count* copies rotated evenly around *axis* passing
|
||||
through *origin*, distributed over a total angular span of
|
||||
*angle* degrees (step = angle / count). ``angle=360`` gives the
|
||||
classic evenly-spaced full-circle bolt pattern.
|
||||
|
||||
Returns the union (compound when the copies don't touch) of the
|
||||
original solid and all its copies — disjoint copies keep their
|
||||
separate volumes inside one result object, touching copies fuse.
|
||||
"""
|
||||
count = max(1, int(count))
|
||||
if count <= 1:
|
||||
return body
|
||||
|
||||
import math as _math
|
||||
|
||||
instances: list = [body]
|
||||
if pattern_type == "circular":
|
||||
# Normalize the rotation axis.
|
||||
ax = float(axis[0]), float(axis[1]), float(axis[2])
|
||||
norm = _math.sqrt(ax[0] * ax[0] + ax[1] * ax[1] + ax[2] * ax[2])
|
||||
if norm < 1e-12:
|
||||
ax = (0.0, 0.0, 1.0)
|
||||
else:
|
||||
ax = (ax[0] / norm, ax[1] / norm, ax[2] / norm)
|
||||
step = _math.radians(float(angle)) / count
|
||||
for i in range(1, count):
|
||||
instances.append(self.rotate(body, ax, step * i, origin))
|
||||
else:
|
||||
d = float(direction[0]), float(direction[1]), float(direction[2])
|
||||
norm = _math.sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2])
|
||||
if norm < 1e-12:
|
||||
d = (1.0, 0.0, 0.0)
|
||||
else:
|
||||
d = (d[0] / norm, d[1] / norm, d[2] / norm)
|
||||
step = float(spacing)
|
||||
for i in range(1, count):
|
||||
instances.append(
|
||||
self.translate(
|
||||
body,
|
||||
(d[0] * step * i, d[1] * step * i, d[2] * step * i),
|
||||
)
|
||||
)
|
||||
|
||||
return self.boolean_union(*instances)
|
||||
|
||||
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
|
||||
"""Export to STEP format."""
|
||||
try:
|
||||
@@ -562,6 +766,52 @@ class OCGeometryKernel(GeometryKernel):
|
||||
shape = reader.OneShape()
|
||||
return OCCGeometryObject(shape, {"type": "imported_step"})
|
||||
|
||||
def import_step_components(self, filepath: str) -> list:
|
||||
"""Import a STEP file and return each solid as a separate ``(name, shape)`` pair.
|
||||
|
||||
The STEP reader transfers the entire root shape, then we iterate
|
||||
over individual ``TopAbs_SOLID`` entities so that each solid gets
|
||||
its own ``OCCGeometryObject``. If the file contains only a single
|
||||
solid the list will have one entry.
|
||||
|
||||
Returns a list of ``(name, OCCGeometryObject)`` tuples.
|
||||
"""
|
||||
from OCP.STEPControl import STEPControl_Reader
|
||||
from OCP.IFSelect import IFSelect_RetDone
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_SOLID
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
reader = STEPControl_Reader()
|
||||
status = reader.ReadFile(filepath)
|
||||
|
||||
if status != IFSelect_RetDone:
|
||||
raise ValueError(f"Failed to read STEP file: {filepath}")
|
||||
|
||||
reader.TransferRoots()
|
||||
shape = reader.OneShape()
|
||||
|
||||
# Extract individual solids
|
||||
solids: list = []
|
||||
explorer = TopExp_Explorer(shape, TopAbs_SOLID)
|
||||
idx = 0
|
||||
while explorer.More():
|
||||
solid = TopoDS.Solid_s(explorer.Current())
|
||||
idx += 1
|
||||
solids.append(
|
||||
(
|
||||
f"Part {idx}",
|
||||
OCCGeometryObject(solid, {"type": "imported_step"}),
|
||||
)
|
||||
)
|
||||
explorer.Next()
|
||||
|
||||
# Fallback: no individual solids found — return the whole shape
|
||||
if not solids:
|
||||
solids = [("Imported", OCCGeometryObject(shape, {"type": "imported_step"}))]
|
||||
|
||||
return solids
|
||||
|
||||
def import_iges(self, filepath: str) -> GeometryObject:
|
||||
"""Import from IGES format."""
|
||||
from OCP.IGESControl import IGESControl_Reader
|
||||
@@ -643,10 +893,8 @@ class OCGeometryKernel(GeometryKernel):
|
||||
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_EDGE
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
||||
from OCP.GeomAbs import GeomAbs_Line, GeomAbs_Circle, GeomAbs_Ellipse, GeomAbs_BSplineCurve
|
||||
from OCP.GeomAbs import GeomAbs_Line
|
||||
|
||||
vertices_list: List[List[float]] = []
|
||||
edges_list: List[List[int]] = []
|
||||
@@ -678,6 +926,7 @@ class OCGeometryKernel(GeometryKernel):
|
||||
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||
while explorer.More():
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
edge = TopoDS.Edge_s(explorer.Current())
|
||||
edge_points = discretize_edge(edge)
|
||||
|
||||
@@ -741,3 +990,363 @@ class OCGeometryKernel(GeometryKernel):
|
||||
|
||||
cg = props.CentreOfMass()
|
||||
return Point3D(cg.X(), cg.Y(), cg.Z())
|
||||
|
||||
def create_thread(
|
||||
self,
|
||||
body: GeometryObject,
|
||||
cylindrical_face: Any,
|
||||
nominal_diameter: float,
|
||||
pitch: float,
|
||||
thread_length: Optional[float] = None,
|
||||
internal: bool = False,
|
||||
) -> Optional[GeometryObject]:
|
||||
"""Cut (or add) an ISO metric thread on the cylindrical face of *body*.
|
||||
|
||||
The geometry is driven by the PICKED face's actual radius and axis
|
||||
(``nominal_diameter`` is only metadata used for the feature record).
|
||||
External threads cut the ISO groove trapezoid (7P/8 at the surface,
|
||||
P/4 at the root, 5H/8 deep) out of the shaft; internal threads fuse
|
||||
the ISO ridge trapezoid (3P/4 at the wall, P/8 crest) into the hole.
|
||||
"""
|
||||
import math
|
||||
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCP.GeomAbs import GeomAbs_Cylinder
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.gp import gp_Pnt, gp_Pnt2d, gp_Dir2d
|
||||
from OCP.BRepBuilderAPI import (
|
||||
BRepBuilderAPI_MakeEdge,
|
||||
BRepBuilderAPI_MakeWire,
|
||||
)
|
||||
|
||||
# ── 1. Cylinder parameters from the picked face ─────────────────
|
||||
try:
|
||||
surf = BRepAdaptor_Surface(cylindrical_face)
|
||||
except Exception:
|
||||
try:
|
||||
surf = BRepAdaptor_Surface(TopoDS.Face_s(cylindrical_face))
|
||||
except Exception as exc:
|
||||
logger.warning(f"create_thread: cannot adapt face: {exc}")
|
||||
return None
|
||||
|
||||
if surf.GetType() != GeomAbs_Cylinder:
|
||||
logger.warning("create_thread: face is not cylindrical")
|
||||
return None
|
||||
|
||||
cyl = surf.Cylinder() # gp_Cylinder
|
||||
radius = cyl.Radius() # ACTUAL picked radius
|
||||
ax3 = cyl.Position() # gp_Ax3 (location, Z, X)
|
||||
loc = ax3.Location()
|
||||
zdir = ax3.Direction()
|
||||
xdir = ax3.XDirection()
|
||||
axis_origin = np.array([loc.X(), loc.Y(), loc.Z()])
|
||||
axis_dir = np.array([zdir.X(), zdir.Y(), zdir.Z()])
|
||||
axis_dir = axis_dir / np.linalg.norm(axis_dir)
|
||||
axis_x = np.array([xdir.X(), xdir.Y(), xdir.Z()])
|
||||
axis_x = axis_x / np.linalg.norm(axis_x)
|
||||
axis_y = np.cross(axis_dir, axis_x)
|
||||
|
||||
u_start = surf.FirstUParameter() # angular start of face
|
||||
v1, v2 = surf.FirstVParameter(), surf.LastVParameter()
|
||||
v_lo, v_hi = min(v1, v2), max(v1, v2)
|
||||
face_height = v_hi - v_lo
|
||||
|
||||
if not thread_length or thread_length <= 0:
|
||||
thread_length = face_height
|
||||
thread_length = min(thread_length, face_height)
|
||||
|
||||
num_turns = thread_length / pitch
|
||||
if num_turns < 0.05:
|
||||
logger.warning("create_thread: thread too short for one turn")
|
||||
return None
|
||||
|
||||
# ── 2. ISO metric profile dimensions ────────────────────────────
|
||||
# Basic profile (H = P·√3/2, thread engagement depth 5H/8):
|
||||
# • external shaft: groove cut is a trapezoid 7P/8 wide at the
|
||||
# surface narrowing to P/4 at the root.
|
||||
# • internal hole: ridge fused onto the wall is a trapezoid 3P/4
|
||||
# wide at the wall narrowing to P/8 at the inner crest, leaving
|
||||
# the 7P/8-wide groove open at the bore.
|
||||
H = pitch * math.sqrt(3.0) / 2.0
|
||||
depth = (5.0 / 8.0) * H
|
||||
overcut = max(0.1 * depth, 0.02) # overhang past the surface
|
||||
if internal:
|
||||
w_surf = 3.0 * pitch / 4.0
|
||||
w_deep = pitch / 8.0
|
||||
else:
|
||||
w_surf = 7.0 * pitch / 8.0
|
||||
w_deep = pitch / 4.0
|
||||
|
||||
# ── 3. Helix spine ON the picked cylinder's surface ─────────────
|
||||
# The swept profile sits in the helix's normal plane, tilted by the
|
||||
# lead angle; its end caps therefore stick out past the spine ends
|
||||
# by roughly half the profile width along the axis. For a CUT that
|
||||
# is harmless (the groove simply runs to the part edge), but a FUSE
|
||||
# would leave the protruding cap as floating material outside the
|
||||
# part, so inset the internal helix by exactly that amount.
|
||||
lead = math.atan2(pitch, 2.0 * math.pi * radius)
|
||||
cap_axial = (w_surf / 2.0) * math.cos(lead) # cap half-extent along axis
|
||||
|
||||
if internal:
|
||||
v_start = v_lo + cap_axial
|
||||
v_end = min(v_lo + thread_length, v_hi) - cap_axial
|
||||
else:
|
||||
# extend one pitch past each face end so the groove runs off
|
||||
# the part edges cleanly
|
||||
v_start = v_lo - pitch
|
||||
v_end = min(v_lo + thread_length + pitch, v_hi + pitch)
|
||||
thread_span = v_end - v_start
|
||||
if thread_span < 0.5 * pitch:
|
||||
logger.warning("create_thread: part too short for a thread")
|
||||
return None
|
||||
turns_ext = thread_span / pitch
|
||||
|
||||
spine_wire = None
|
||||
|
||||
# 3a. TRUE helix: a 2D straight line on the cylinder surface.
|
||||
#
|
||||
# NOTE 1: gp_Dir2d NORMALIZES its argument, so the 2D line
|
||||
# parameter t advances the point by t·|(2π, pitch)| in (u, v)
|
||||
# space — scale the trim range so t = n turns covers exactly
|
||||
# n revolutions plus n·pitch of axial travel.
|
||||
# NOTE 2: the edge from a pcurve has no 3D curve; the pipe sweep
|
||||
# needs one, so force it with BRepLib.BuildCurves3d.
|
||||
spine_wire = None
|
||||
try:
|
||||
from OCP.Geom import Geom_CylindricalSurface
|
||||
from OCP.Geom2d import Geom2d_Line, Geom2d_TrimmedCurve
|
||||
from OCP.BRepLib import BRepLib
|
||||
|
||||
dir_len = math.hypot(2.0 * math.pi, pitch)
|
||||
cyl_surf = Geom_CylindricalSurface(cyl)
|
||||
line2d = Geom2d_Line(
|
||||
gp_Pnt2d(u_start, v_start), gp_Dir2d(2.0 * math.pi, pitch)
|
||||
)
|
||||
seg = Geom2d_TrimmedCurve(line2d, 0.0, turns_ext * dir_len)
|
||||
helix_edge = BRepBuilderAPI_MakeEdge(seg, cyl_surf).Edge()
|
||||
BRepLib.BuildCurves3d_s(helix_edge)
|
||||
spine_wire = BRepBuilderAPI_MakeWire(helix_edge).Wire()
|
||||
logger.info("create_thread: using exact helix spine")
|
||||
except Exception as exc:
|
||||
logger.info(f"create_thread: exact helix failed ({exc})")
|
||||
|
||||
# 3b. Fallback: smooth BSpline through sampled helix points
|
||||
# (only if the exact construction is unavailable).
|
||||
if spine_wire is None:
|
||||
try:
|
||||
from OCP.GeomAPI import GeomAPI_PointsToBSpline
|
||||
from OCP.TColgp import TColgp_Array1OfPnt
|
||||
from OCP.GeomAbs import GeomAbs_C2
|
||||
|
||||
pts_per_turn = 96
|
||||
n_total = max(int(turns_ext * pts_per_turn) + 1, 2)
|
||||
arr = TColgp_Array1OfPnt(1, n_total)
|
||||
for i in range(1, n_total + 1):
|
||||
u = u_start + ((i - 1) / pts_per_turn) * 2.0 * math.pi
|
||||
v = v_start + ((i - 1) / pts_per_turn) * pitch
|
||||
p = (
|
||||
axis_origin
|
||||
+ radius * (math.cos(u) * axis_x + math.sin(u) * axis_y)
|
||||
+ v * axis_dir
|
||||
)
|
||||
arr.SetValue(i, gp_Pnt(float(p[0]), float(p[1]), float(p[2])))
|
||||
bspline = GeomAPI_PointsToBSpline(arr, 3, 8, GeomAbs_C2, 1e-5)
|
||||
bs_edge = BRepBuilderAPI_MakeEdge(bspline.Curve()).Edge()
|
||||
spine_wire = BRepBuilderAPI_MakeWire(bs_edge).Wire()
|
||||
logger.info("create_thread: using BSpline helix fallback")
|
||||
except Exception as exc:
|
||||
logger.warning(f"create_thread: BSpline helix failed ({exc})")
|
||||
|
||||
if spine_wire is None:
|
||||
logger.warning("create_thread: no usable helix spine")
|
||||
return None
|
||||
|
||||
# Start frame (same for both spine types — computed analytically).
|
||||
def _cyl_pt(u: float, v: float) -> np.ndarray:
|
||||
return (
|
||||
axis_origin
|
||||
+ radius * (math.cos(u) * axis_x + math.sin(u) * axis_y)
|
||||
+ v * axis_dir
|
||||
)
|
||||
|
||||
start_S = _cyl_pt(u_start, v_start)
|
||||
start_T = (
|
||||
2.0 * math.pi * radius
|
||||
* (-math.sin(u_start) * axis_x + math.cos(u_start) * axis_y)
|
||||
+ pitch * axis_dir
|
||||
)
|
||||
start_T = start_T / np.linalg.norm(start_T)
|
||||
start_R = math.cos(u_start) * axis_x + math.sin(u_start) * axis_y # outward
|
||||
|
||||
# Profile width direction: perpendicular to tangent in the surface
|
||||
# plane (≈ axial direction). Trapezoid is symmetric so sign is fine.
|
||||
binormal = np.cross(start_T, start_R)
|
||||
binormal = binormal / np.linalg.norm(binormal)
|
||||
|
||||
# ── 4. Trapezoidal profile at the spine start ───────────────────
|
||||
# Built directly in world coords: base sits *overcut* OUTSIDE the
|
||||
# surface so the boolean fuses/cuts cleanly across it; the working
|
||||
# end reaches *depth* INSIDE the surface.
|
||||
def _mk(b: float, r: float) -> gp_Pnt:
|
||||
p = start_S + b * binormal + r * start_R
|
||||
return gp_Pnt(float(p[0]), float(p[1]), float(p[2]))
|
||||
|
||||
p0 = _mk(-w_surf / 2.0, overcut)
|
||||
p1 = _mk(-w_deep / 2.0, -depth)
|
||||
p2 = _mk(+w_deep / 2.0, -depth)
|
||||
p3 = _mk(+w_surf / 2.0, overcut)
|
||||
|
||||
prof_wb = BRepBuilderAPI_MakeWire()
|
||||
for a, b in ((p0, p1), (p1, p2), (p2, p3), (p3, p0)):
|
||||
prof_wb.Add(BRepBuilderAPI_MakeEdge(a, b).Edge())
|
||||
profile_wire = prof_wb.Wire()
|
||||
|
||||
# ── 5. Sweep the profile along the helix ────────────────────────
|
||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell
|
||||
|
||||
try:
|
||||
pipe = BRepOffsetAPI_MakePipeShell(spine_wire)
|
||||
pipe.SetMode(True) # Frenet frame
|
||||
pipe.Add(profile_wire, False, False)
|
||||
pipe.Build()
|
||||
if not pipe.IsDone():
|
||||
logger.warning("create_thread: pipe sweep failed")
|
||||
return None
|
||||
solid_ok = False
|
||||
try:
|
||||
solid_ok = bool(pipe.MakeSolid()) # cap the tube ends
|
||||
except Exception as exc:
|
||||
logger.info(f"create_thread: MakeSolid unavailable ({exc})")
|
||||
tool_shape = pipe.Shape()
|
||||
if not solid_ok:
|
||||
logger.warning("create_thread: sweep is not a solid")
|
||||
except Exception as exc:
|
||||
logger.warning(f"create_thread: sweep failed: {exc}")
|
||||
return None
|
||||
|
||||
# ── 6. Boolean cut (shaft) or fuse (hole) ───────────────────────
|
||||
body_shape = self._get_shape(body)
|
||||
if body_shape is None:
|
||||
logger.warning("create_thread: body has no shape")
|
||||
return None
|
||||
|
||||
tool = OCCGeometryObject(tool_shape)
|
||||
vol_before = self.get_volume(body)
|
||||
|
||||
if internal:
|
||||
result = self.boolean_union(body, tool)
|
||||
else:
|
||||
result = self.boolean_difference(body, tool)
|
||||
|
||||
if result is None or self._get_shape(result) is None:
|
||||
logger.warning("create_thread: boolean op produced no shape")
|
||||
return None
|
||||
|
||||
try:
|
||||
vol_after = self.get_volume(result)
|
||||
except Exception:
|
||||
vol_after = -1.0
|
||||
|
||||
if internal and vol_after <= vol_before:
|
||||
logger.warning(
|
||||
f"create_thread: fuse did not add volume "
|
||||
f"({vol_before:.4f} → {vol_after:.4f}) — tool missed the body?"
|
||||
)
|
||||
return None
|
||||
if not internal and vol_after >= vol_before:
|
||||
logger.warning(
|
||||
f"create_thread: cut did not remove volume "
|
||||
f"({vol_before:.4f} → {vol_after:.4f}) — tool missed the body?"
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f"create_thread: {'internal' if internal else 'external'} thread OK, "
|
||||
f"volume {vol_before:.4f} → {vol_after:.4f}"
|
||||
)
|
||||
return result
|
||||
|
||||
def detect_cylindrical_face(
|
||||
self,
|
||||
face: Any,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Check if *face* is cylindrical and return its parameters.
|
||||
|
||||
The *face* can be a ``TopoDS_Face`` (from the picker) or a
|
||||
``TopoDS_Shape`` that contains a face. We try several paths to
|
||||
extract the underlying cylindrical surface.
|
||||
|
||||
Returns a dict with keys ``radius``, ``axis_origin``, ``axis_dir``,
|
||||
``height``, or *None* if the face isn't cylindrical.
|
||||
"""
|
||||
import logging
|
||||
import numpy as np
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCP.GeomAbs import GeomAbs_Cylinder
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# ── Resolve the actual face from whatever the caller handed us ──
|
||||
actual_face: Any = None
|
||||
|
||||
# Try direct BRepAdaptor_Surface first — the picker already returns
|
||||
# a valid TopoDS_Face, and calling TopoDS.Face_s() again on an
|
||||
# already-downcast face can fail in some OCP versions.
|
||||
try:
|
||||
surf = BRepAdaptor_Surface(face)
|
||||
surf_type_test = surf.GetType()
|
||||
actual_face = face
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if actual_face is None:
|
||||
# Fallback: try the explicit TopoDS.Face_s downcast path.
|
||||
try:
|
||||
candidate = TopoDS.Face_s(face)
|
||||
_ = BRepAdaptor_Surface(candidate)
|
||||
actual_face = candidate
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if actual_face is None:
|
||||
_log.warning("detect_cylindrical_face: could not resolve face from pick result")
|
||||
return None
|
||||
|
||||
# ── Probe the surface type ──
|
||||
try:
|
||||
surf = BRepAdaptor_Surface(actual_face)
|
||||
surf_type = surf.GetType()
|
||||
if surf_type != GeomAbs_Cylinder:
|
||||
type_names = {
|
||||
0: "Plane", 1: "Cylinder", 2: "Cone", 3: "Sphere",
|
||||
4: "Torus", 5: "Bezier", 6: "BSpline", 7: "Revolution",
|
||||
8: "Extrusion", 9: "Offset", 10: "Other",
|
||||
}
|
||||
type_name = type_names.get(int(surf_type), f"Unknown({int(surf_type)})")
|
||||
_log.warning(
|
||||
f"detect_cylindrical_face: face is {type_name}, not a Cylinder"
|
||||
)
|
||||
return None
|
||||
|
||||
cyl = surf.Cylinder()
|
||||
radius = cyl.Radius()
|
||||
axis = cyl.Axis()
|
||||
origin = axis.Location()
|
||||
direction = axis.Direction()
|
||||
# BRepAdaptor_Surface uses FirstUParameter/LastUParameter etc.
|
||||
u1 = surf.FirstUParameter()
|
||||
u2 = surf.LastUParameter()
|
||||
v1 = surf.FirstVParameter()
|
||||
v2 = surf.LastVParameter()
|
||||
height = abs(v2 - v1)
|
||||
return {
|
||||
"radius": radius,
|
||||
"diameter": 2.0 * radius,
|
||||
"axis_origin": (origin.X(), origin.Y(), origin.Z()),
|
||||
"axis_dir": (direction.X(), direction.Y(), direction.Z()),
|
||||
"height": height,
|
||||
}
|
||||
except Exception as exc:
|
||||
_log.warning(f"detect_cylindrical_face: surface probe failed: {exc}")
|
||||
return None
|
||||
|
||||
+1482
-179
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,385 @@
|
||||
"""Surface modifier for OpenCASCADE geometry.
|
||||
|
||||
Applies geometric patterns (pyramids, bumps, grooves) to 3D surfaces using boolean operations.
|
||||
This enables grip-enhancing textures and visual surface modifications on CAD models.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
# OCC imports at module level for common types
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SurfaceModifier:
|
||||
"""Applies geometric patterns to 3D surfaces using OCC boolean operations."""
|
||||
|
||||
def __init__(self):
|
||||
self._patterns_applied = []
|
||||
|
||||
def apply_pyramid_pattern(
|
||||
self,
|
||||
face_shape,
|
||||
pyramid_height: float = 1.0,
|
||||
base_radius: float = 2.0,
|
||||
spacing: float = 5.0,
|
||||
num_rings: Optional[int] = None,
|
||||
direction: Tuple[float, float, float] = (0, 0, 1),
|
||||
) -> Optional[Any]:
|
||||
"""Apply a pyramid pattern to a face surface.
|
||||
|
||||
Args:
|
||||
face_shape: OCC TopoDS_Shape representing the face or solid
|
||||
pyramid_height: Height of each pyramid
|
||||
base_radius: Radius of pyramid base
|
||||
spacing: Distance between pyramids
|
||||
num_rings: Number of concentric rings (auto-calculated if None)
|
||||
direction: Normal direction for pyramids
|
||||
|
||||
Returns:
|
||||
Modified shape on success, None on failure
|
||||
"""
|
||||
try:
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
from OCP.TopoDS import TopoDS_Face, TopoDS_Shape
|
||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
|
||||
# Validate face shape
|
||||
if not isinstance(face_shape, (TopoDS_Shape, TopoDS_Face)):
|
||||
logger.error("Invalid face shape type")
|
||||
return None
|
||||
|
||||
# Extract the first face for surface parameterization
|
||||
if isinstance(face_shape, TopoDS_Shape):
|
||||
explorer = TopExp_Explorer(face_shape, TopAbs_FACE)
|
||||
if not explorer.More():
|
||||
logger.error("No faces found in shape")
|
||||
return None
|
||||
from OCP import TopoDS
|
||||
face = TopoDS.TopoDS.Face_s(explorer.Current())
|
||||
else:
|
||||
face = face_shape
|
||||
|
||||
# Get face surface for UV parameterization
|
||||
surf = BRepAdaptor_Surface(face)
|
||||
|
||||
u_min, u_max = surf.FirstUParameter(), surf.LastUParameter()
|
||||
v_min, v_max = surf.FirstVParameter(), surf.LastVParameter()
|
||||
|
||||
# Calculate number of rings if not specified
|
||||
if num_rings is None:
|
||||
# Estimate based on face area and spacing
|
||||
u_range = u_max - u_min
|
||||
v_range = v_max - v_min
|
||||
avg_dim = (u_range + v_range) / 2.0
|
||||
num_rings = max(1, min(int(avg_dim / spacing), 5))
|
||||
|
||||
logger.info(
|
||||
f"Applying pyramid pattern: {num_rings} rings, "
|
||||
f"{base_radius:.2f} radius, {pyramid_height:.2f} height"
|
||||
)
|
||||
|
||||
# Create pyramids distributed across the face UV space
|
||||
result_shape = face_shape
|
||||
pyramid_count = 0
|
||||
|
||||
for ring_idx in range(num_rings):
|
||||
# Distribute rings evenly across UV parameter space
|
||||
u_fraction = (ring_idx + 1) / (num_rings + 1)
|
||||
v_fraction = 0.5 # Center vertically
|
||||
|
||||
# Map to actual UV coordinates on the face
|
||||
u_pos = u_min + u_fraction * (u_max - u_min)
|
||||
v_pos = v_min + v_fraction * (v_max - v_min)
|
||||
|
||||
# Get 3D position and tangent vectors at this UV point
|
||||
from OCP.gp import gp_Pnt, gp_Vec
|
||||
|
||||
center_pt = gp_Pnt()
|
||||
d1u = gp_Vec()
|
||||
d1v = gp_Vec()
|
||||
surf.D1(u_pos, v_pos, center_pt, d1u, d1v)
|
||||
# Normal is cross product of tangent vectors
|
||||
normal = d1u.Crossed(d1v)
|
||||
normal.Normalize()
|
||||
|
||||
# Calculate number of pyramids in this ring based on spacing
|
||||
if ring_idx == 0:
|
||||
num_pyramids = 1 # Center pyramid
|
||||
else:
|
||||
circumference = 2.0 * math.pi * (ring_idx * spacing)
|
||||
num_pyramids = max(3, int(circumference / spacing))
|
||||
|
||||
for i in range(num_pyramids):
|
||||
if ring_idx == 0:
|
||||
# Center pyramid - place at face center
|
||||
place_u = u_pos
|
||||
place_v = v_pos
|
||||
else:
|
||||
angle = (2.0 * math.pi * i) / num_pyramids
|
||||
# Offset in UV space based on ring radius
|
||||
offset_u = (ring_idx * spacing / (u_max - u_min)) * math.cos(angle)
|
||||
offset_v = (ring_idx * spacing / (v_max - v_min)) * math.sin(angle)
|
||||
place_u = max(u_min, min(u_max, u_pos + offset_u))
|
||||
place_v = max(v_min, min(v_max, v_pos + offset_v))
|
||||
|
||||
try:
|
||||
# Get 3D position and normal for this pyramid
|
||||
pyramid_pt = gp_Pnt()
|
||||
pd1u = gp_Vec()
|
||||
pd1v = gp_Vec()
|
||||
surf.D1(place_u, place_v, pyramid_pt, pd1u, pd1v)
|
||||
pyramid_normal = pd1u.Crossed(pd1v)
|
||||
pyramid_normal.Normalize()
|
||||
|
||||
# Create solid pyramid at this position
|
||||
pyramid_shape = self._create_solid_pyramid(
|
||||
pyramid_pt,
|
||||
pyramid_normal,
|
||||
pyramid_height,
|
||||
base_radius,
|
||||
)
|
||||
|
||||
if pyramid_shape is not None:
|
||||
# Fuse with existing geometry
|
||||
fuse = BRepAlgoAPI_Fuse(result_shape, pyramid_shape)
|
||||
fuse.Build()
|
||||
|
||||
if fuse.IsDone():
|
||||
result_shape = fuse.Shape()
|
||||
pyramid_count += 1
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to fuse pyramid at ({place_u:.2f}, {place_v:.2f})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Error creating pyramid at ring {ring_idx}, pyramid {i}: {e}"
|
||||
)
|
||||
|
||||
self._patterns_applied.append(
|
||||
{
|
||||
"type": "pyramid",
|
||||
"parameters": {
|
||||
"height": pyramid_height,
|
||||
"base_radius": base_radius,
|
||||
"spacing": spacing,
|
||||
"num_rings": num_rings,
|
||||
"direction": direction,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Successfully applied {pyramid_count} pyramids")
|
||||
|
||||
return result_shape
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying pyramid pattern: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def _create_solid_pyramid(
|
||||
self,
|
||||
base_point, # gp_Pnt - position on the face
|
||||
normal_vec, # gp_Dir or gp_Vec - surface normal direction
|
||||
height: float,
|
||||
base_radius: float,
|
||||
) -> Optional[Any]:
|
||||
"""Create a solid pyramid at the specified position and orientation.
|
||||
|
||||
Uses BRepPrimAPI_MakePrism to extrude a square base into a solid pyramid.
|
||||
|
||||
Args:
|
||||
base_point: 3D point where pyramid base is centered
|
||||
normal_vec: Direction vector for pyramid growth (surface normal)
|
||||
height: Height of the pyramid from base to apex
|
||||
base_radius: Half-width of the square base
|
||||
|
||||
Returns:
|
||||
OCC solid shape for the pyramid, or None on failure
|
||||
"""
|
||||
try:
|
||||
from OCP.gp import gp_Dir, gp_Ax2, gp_Vec
|
||||
from OCP.BRepBuilderAPI import (
|
||||
BRepBuilderAPI_MakeEdge,
|
||||
BRepBuilderAPI_MakeWire,
|
||||
)
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
||||
|
||||
half = base_radius / 2.0
|
||||
|
||||
# Build orthonormal basis from normal vector
|
||||
if isinstance(normal_vec, gp_Vec):
|
||||
n_dir = gp_Dir(normal_vec.XYZ())
|
||||
else:
|
||||
n_dir = normal_vec
|
||||
|
||||
# Create a local coordinate system at the base point
|
||||
local_ax2 = gp_Ax2(base_point, n_dir)
|
||||
|
||||
# Get X and Y axes from the local coordinate system
|
||||
x_dir = local_ax2.XDirection()
|
||||
y_dir = local_ax2.YDirection()
|
||||
|
||||
# Create 4 corners of the square base in the local plane
|
||||
corner_points = [
|
||||
base_point + gp_Vec(x_dir).Multiplied(half) + gp_Vec(y_dir).Multiplied(half),
|
||||
base_point + gp_Vec(x_dir).Multiplied(-half) + gp_Vec(y_dir).Multiplied(half),
|
||||
base_point + gp_Vec(x_dir).Multiplied(-half) + gp_Vec(y_dir).Multiplied(-half),
|
||||
base_point + gp_Vec(x_dir).Multiplied(half) + gp_Vec(y_dir).Multiplied(-half),
|
||||
]
|
||||
|
||||
# Create edges connecting the corners
|
||||
wire_maker = BRepBuilderAPI_MakeWire()
|
||||
for idx in range(4):
|
||||
next_idx = (idx + 1) % 4
|
||||
edge = BRepBuilderAPI_MakeEdge(
|
||||
corner_points[idx], corner_points[next_idx]
|
||||
).Edge()
|
||||
wire_maker.Add(edge)
|
||||
|
||||
if not wire_maker.IsDone():
|
||||
logger.warning("Failed to create pyramid base wire")
|
||||
return None
|
||||
|
||||
# Extrude the base wire in the normal direction by height to form a prism
|
||||
extrusion_vec = gp_Vec(n_dir).Multiplied(height)
|
||||
prism_maker = BRepPrimAPI_MakePrism(
|
||||
wire_maker.Wire(), extrusion_vec, False # no check intersection
|
||||
)
|
||||
prism_maker.Build()
|
||||
|
||||
if not prism_maker.IsDone():
|
||||
logger.warning("Failed to create pyramid prism")
|
||||
return None
|
||||
|
||||
return prism_maker.Shape()
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error creating solid pyramid: {e}")
|
||||
return None
|
||||
|
||||
def apply_bump_pattern(
|
||||
self,
|
||||
face_shape,
|
||||
bump_height: float = 1.0,
|
||||
bump_radius: float = 2.0,
|
||||
spacing: float = 5.0,
|
||||
num_rings: Optional[int] = None,
|
||||
) -> Optional[Any]:
|
||||
"""Apply a simple bump pattern to a face surface.
|
||||
|
||||
Args:
|
||||
face_shape: OCC TopoDS_Shape representing the face
|
||||
bump_height: Height of each bump
|
||||
bump_radius: Radius of each bump base
|
||||
spacing: Distance between bumps
|
||||
num_rings: Number of concentric rings
|
||||
|
||||
Returns:
|
||||
Modified shape on success, None on failure
|
||||
"""
|
||||
return self.apply_pyramid_pattern(
|
||||
face_shape,
|
||||
pyramid_height=bump_height,
|
||||
base_radius=bump_radius,
|
||||
spacing=spacing,
|
||||
num_rings=num_rings,
|
||||
)
|
||||
|
||||
|
||||
def apply_surface_modifier_to_body(
|
||||
body_geometry, modifier_type: str = "pyramid", **parameters
|
||||
) -> Optional[Any]:
|
||||
"""Apply a surface modifier to a body geometry.
|
||||
|
||||
Args:
|
||||
body_geometry: OCCGeometryObject or similar geometry object
|
||||
modifier_type: Type of modifier ('pyramid', 'bump')
|
||||
**parameters: Modifier-specific parameters
|
||||
|
||||
Returns:
|
||||
Modified shape, or None on failure
|
||||
"""
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||
|
||||
kernel = OCGeometryKernel()
|
||||
shape = kernel._get_shape(body_geometry)
|
||||
|
||||
if shape is None:
|
||||
logger.error("No geometry found in body")
|
||||
return None
|
||||
|
||||
modifier = SurfaceModifier()
|
||||
|
||||
try:
|
||||
if modifier_type == "pyramid":
|
||||
success = modifier.apply_pyramid_pattern(shape, **parameters)
|
||||
elif modifier_type == "bump":
|
||||
success = modifier.apply_bump_pattern(shape, **parameters)
|
||||
else:
|
||||
logger.error(f"Unknown modifier type: {modifier_type}")
|
||||
return None
|
||||
|
||||
if not success:
|
||||
logger.error("Surface modifier application failed")
|
||||
return None
|
||||
|
||||
# Return the modified shape wrapped in OCCGeometryObject
|
||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||
|
||||
return OCCGeometryObject(shape)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying surface modifier: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
# Example usage and testing
|
||||
if __name__ == "__main__":
|
||||
# Create a simple test case
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
|
||||
# Create a box to modify
|
||||
box_maker = BRepPrimAPI_MakeBox(50, 50, 10)
|
||||
box_maker.Build()
|
||||
|
||||
if box_maker.IsDone():
|
||||
print("Created test box")
|
||||
|
||||
# Apply pyramid pattern to top face (Z direction)
|
||||
modifier = SurfaceModifier()
|
||||
success = modifier.apply_pyramid_pattern(
|
||||
box_maker.Shape(),
|
||||
pyramid_height=2.0,
|
||||
base_radius=3.0,
|
||||
spacing=8.0,
|
||||
num_rings=2,
|
||||
direction=(0, 0, 1),
|
||||
)
|
||||
|
||||
if success:
|
||||
print("Successfully applied pyramid pattern")
|
||||
|
||||
# Export modified shape
|
||||
from OCP.StlAPI import StlAPI_Writer
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
|
||||
tess = BRepMesh_IncrementalMesh(box_maker.Shape(), 0.1)
|
||||
tess.Perform()
|
||||
|
||||
writer = StlAPI_Writer()
|
||||
writer.SetASCIIMode(False)
|
||||
writer.Write(box_maker.Shape(), "/tmp/test_pyramid_pattern.stl")
|
||||
print("Exported modified shape to STL")
|
||||
else:
|
||||
print("Failed to apply pyramid pattern")
|
||||
else:
|
||||
print("Failed to create test box")
|
||||
+468
-58
@@ -28,6 +28,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import datetime
|
||||
@@ -42,8 +43,12 @@ from fluency.models.data_model import (
|
||||
Body,
|
||||
Component,
|
||||
Connector,
|
||||
DrawingAnnotation,
|
||||
DrawingView,
|
||||
Feature,
|
||||
Project,
|
||||
Sketch,
|
||||
TechnicalDrawing,
|
||||
Workplane,
|
||||
)
|
||||
from fluency.geometry_occ.kernel import OCCGeometryObject, OCGeometryKernel
|
||||
@@ -65,7 +70,7 @@ def _json_default(obj: Any) -> Any:
|
||||
return sorted(obj)
|
||||
if isinstance(obj, tuple):
|
||||
return list(obj)
|
||||
if is_dataclass(obj):
|
||||
if is_dataclass(obj) and not isinstance(obj, type):
|
||||
return asdict(obj)
|
||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||
|
||||
@@ -85,42 +90,77 @@ def _coerce_listlike(value: Any) -> List[Any]:
|
||||
return list(value)
|
||||
|
||||
|
||||
def _to_float(value: Any, default: float = 0.0) -> float:
|
||||
"""Safely coerce a saved value to float, falling back to *default*.
|
||||
|
||||
Corrupt archives may store a string or None where a number is expected;
|
||||
the loaders must not crash on them.
|
||||
"""
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _saved_id(data: Dict[str, Any]) -> str:
|
||||
"""Return a saved entity id, or a fresh UUID for corrupt/legacy data.
|
||||
|
||||
Old files always wrote an ``id``; a missing/empty value means the
|
||||
archive is damaged, and the model's uuid factory only kicks in when the
|
||||
constructor argument is omitted — so we generate here to keep ids valid
|
||||
non-empty strings.
|
||||
"""
|
||||
v = data.get("id")
|
||||
if isinstance(v, str) and v:
|
||||
return v
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _to_3tuple(value: Any) -> Tuple[float, float, float]:
|
||||
"""Coerce a saved 3-vector to a tuple of floats (for OCC)."""
|
||||
if value is None:
|
||||
return (0.0, 0.0, 0.0)
|
||||
if isinstance(value, np.ndarray):
|
||||
seq = value.tolist()
|
||||
else:
|
||||
seq = list(value)
|
||||
if len(seq) < 3:
|
||||
seq = list(seq) + [0.0] * (3 - len(seq))
|
||||
return (float(seq[0]), float(seq[1]), float(seq[2]))
|
||||
try:
|
||||
if isinstance(value, np.ndarray):
|
||||
seq = value.tolist()
|
||||
else:
|
||||
seq = list(value)
|
||||
if len(seq) < 3:
|
||||
seq = list(seq) + [0.0] * (3 - len(seq))
|
||||
return (float(seq[0]), float(seq[1]), float(seq[2]))
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
def _to_3vec(value: Any) -> np.ndarray:
|
||||
"""Coerce a saved 3-vector to a 3-element numpy array."""
|
||||
if isinstance(value, np.ndarray):
|
||||
return value.astype(float).reshape(3)
|
||||
if value is None:
|
||||
try:
|
||||
if isinstance(value, np.ndarray):
|
||||
return value.astype(float).reshape(3)
|
||||
if value is None:
|
||||
return np.zeros(3, dtype=float)
|
||||
seq = list(value)
|
||||
if len(seq) < 3:
|
||||
seq = list(seq) + [0.0] * (3 - len(seq))
|
||||
return np.array([float(seq[0]), float(seq[1]), float(seq[2])], dtype=float)
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return np.zeros(3, dtype=float)
|
||||
seq = list(value)
|
||||
if len(seq) < 3:
|
||||
seq = list(seq) + [0.0] * (3 - len(seq))
|
||||
return np.array([float(seq[0]), float(seq[1]), float(seq[2])], dtype=float)
|
||||
|
||||
|
||||
def _to_mat3(value: Any) -> np.ndarray:
|
||||
"""Coerce a saved 3×3 matrix (flat 9-list or nested) to np.ndarray."""
|
||||
if isinstance(value, np.ndarray):
|
||||
arr = value.astype(float)
|
||||
return arr.reshape(3, 3)
|
||||
if value is None:
|
||||
try:
|
||||
if isinstance(value, np.ndarray):
|
||||
arr = value.astype(float)
|
||||
return arr.reshape(3, 3)
|
||||
if value is None:
|
||||
return np.eye(3, dtype=float)
|
||||
flat = list(np.asarray(value, dtype=float).flatten())
|
||||
if len(flat) < 9:
|
||||
flat = flat + [0.0] * (9 - len(flat))
|
||||
return np.array(flat[:9], dtype=float).reshape(3, 3)
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return np.eye(3, dtype=float)
|
||||
flat = list(np.asarray(value, dtype=float).flatten())
|
||||
if len(flat) < 9:
|
||||
flat = flat + [0.0] * (9 - len(flat))
|
||||
return np.array(flat[:9], dtype=float).reshape(3, 3)
|
||||
|
||||
|
||||
def _parse_iso(value: Optional[str]) -> datetime:
|
||||
@@ -151,11 +191,11 @@ def _workplane_to_dict(wp: Workplane) -> Dict[str, Any]:
|
||||
|
||||
def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
|
||||
wp = Workplane(
|
||||
id=data.get("id") or None, # Workplane generates uuid if None
|
||||
id=_saved_id(data),
|
||||
name=data.get("name", "Untitled Workplane"),
|
||||
origin=tuple(data.get("origin", (0.0, 0.0, 0.0))),
|
||||
normal=tuple(data.get("normal", (0.0, 0.0, 1.0))),
|
||||
x_dir=tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
|
||||
origin=_to_3tuple(data.get("origin", (0.0, 0.0, 0.0))),
|
||||
normal=_to_3tuple(data.get("normal", (0.0, 0.0, 1.0))),
|
||||
x_dir=_to_3tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
|
||||
visible=bool(data.get("visible", True)),
|
||||
)
|
||||
wp.created_at = _parse_iso(data.get("created_at"))
|
||||
@@ -163,6 +203,90 @@ def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
|
||||
return wp
|
||||
|
||||
|
||||
def _feature_to_dict(feat: Feature) -> Dict[str, Any]:
|
||||
"""Serialize one parametric feature (sketch id + params).
|
||||
|
||||
"base" snapshot features are NOT serialized here — their frozen
|
||||
geometry is written as a separate STEP member (``base_geometry_ref``)
|
||||
and the ``features_base_snapshot`` flag on the body marks that the
|
||||
list starts with one.
|
||||
"""
|
||||
return {
|
||||
"id": feat.id,
|
||||
"operation": feat.operation,
|
||||
"sketch_id": feat.sketch.id if feat.sketch is not None else None,
|
||||
"length": feat.length,
|
||||
"symmetric": bool(feat.symmetric),
|
||||
"invert": bool(feat.invert),
|
||||
"through_all": bool(feat.through_all),
|
||||
"cut_all_bodies": bool(feat.cut_all_bodies),
|
||||
"face_index": feat.face_index,
|
||||
"angle": _to_float(feat.angle, 360.0),
|
||||
"axis": [float(v) for v in (feat.axis or (0, 0, 1))],
|
||||
"origin": [float(v) for v in (feat.origin or (0.0, 0.0, 0.0))],
|
||||
"axis_line_id": feat.axis_line_id,
|
||||
"radius": feat.radius,
|
||||
"tangent_propagation": bool(feat.tangent_propagation),
|
||||
"scope": feat.scope,
|
||||
"edge_refs": list(feat.edge_refs),
|
||||
"face_keys": json.dumps(feat.face_keys) if feat.face_keys is not None else None,
|
||||
"face_keys_sketch_id": feat.face_keys_sketch_id,
|
||||
"pattern_type": feat.pattern_type,
|
||||
"count": feat.count,
|
||||
"spacing": feat.spacing,
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Feature:
|
||||
"""Deserialize a feature, resolving its sketch reference against the
|
||||
component's already-loaded sketches."""
|
||||
feat = Feature(
|
||||
id=_saved_id(data),
|
||||
operation=data.get("operation", "extrude"),
|
||||
length=data.get("length"),
|
||||
symmetric=bool(data.get("symmetric", False)),
|
||||
invert=bool(data.get("invert", False)),
|
||||
through_all=bool(data.get("through_all", False)),
|
||||
cut_all_bodies=bool(data.get("cut_all_bodies", False)),
|
||||
face_index=data.get("face_index"),
|
||||
angle=_to_float(data.get("angle"), 360.0),
|
||||
axis=tuple(float(v) for v in (data.get("axis") or (0, 0, 1))),
|
||||
origin=tuple(float(v) for v in (data.get("origin") or (0.0, 0.0, 0.0))),
|
||||
axis_line_id=data.get("axis_line_id"),
|
||||
radius=data.get("radius"),
|
||||
tangent_propagation=bool(data.get("tangent_propagation", False)),
|
||||
scope=data.get("scope", "selected"),
|
||||
edge_refs=list(data.get("edge_refs") or []),
|
||||
face_keys=None,
|
||||
face_keys_sketch_id=data.get("face_keys_sketch_id"),
|
||||
pattern_type=data.get("pattern_type", "linear"),
|
||||
count=int(data.get("count") or 2),
|
||||
spacing=_to_float(data.get("spacing"), 10.0),
|
||||
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")
|
||||
if sid and sid in sketches:
|
||||
feat.sketch = sketches[sid]
|
||||
return feat
|
||||
|
||||
|
||||
def _body_to_dict(body: Body) -> Dict[str, Any]:
|
||||
"""Body serialization. ``geometry_ref`` is set later by the ZIP writer
|
||||
once the STEP file is written."""
|
||||
@@ -171,10 +295,22 @@ def _body_to_dict(body: Body) -> Dict[str, Any]:
|
||||
"name": body.name,
|
||||
"source_sketch_id": body.source_sketch.id if body.source_sketch else None,
|
||||
"source_operation": body.source_operation,
|
||||
"extrude_length": body.extrude_length,
|
||||
"extrude_symmetric": body.extrude_symmetric,
|
||||
"extrude_invert": body.extrude_invert,
|
||||
"extrude_cut": body.extrude_cut,
|
||||
"extrude_union": body.extrude_union,
|
||||
"extrude_through_all": body.extrude_through_all,
|
||||
"extrude_cut_all_bodies": body.extrude_cut_all_bodies,
|
||||
"extrude_face_index": body.extrude_face_index,
|
||||
"extrude_target_body_id": body.extrude_target_body_id,
|
||||
"features": [_feature_to_dict(f) for f in body.features if f.operation != "base"],
|
||||
"features_base_snapshot": bool(body.features and body.features[0].operation == "base"),
|
||||
"base_geometry_ref": None, # filled in by save_project
|
||||
"position": _coerce_listlike(body.position),
|
||||
"rotation": _coerce_listlike(body.rotation),
|
||||
"color": list(body.color) if body.color else [0.2, 0.4, 0.8],
|
||||
"opacity": float(body.opacity),
|
||||
"opacity": _to_float(body.opacity, 1.0),
|
||||
"visible": bool(body.visible),
|
||||
"has_geometry": body.geometry is not None,
|
||||
"geometry_ref": None, # filled in by save_project
|
||||
@@ -193,15 +329,24 @@ def _body_from_dict(
|
||||
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
||||
|
||||
body = Body(
|
||||
id=data.get("id") or None,
|
||||
id=_saved_id(data),
|
||||
name=data.get("name", "Untitled Body"),
|
||||
geometry=geometry,
|
||||
source_sketch=source_sketch,
|
||||
source_operation=data.get("source_operation", "extrude"),
|
||||
extrude_length=data.get("extrude_length"),
|
||||
extrude_symmetric=bool(data.get("extrude_symmetric", False)),
|
||||
extrude_invert=bool(data.get("extrude_invert", False)),
|
||||
extrude_cut=bool(data.get("extrude_cut", False)),
|
||||
extrude_union=bool(data.get("extrude_union", False)),
|
||||
extrude_through_all=bool(data.get("extrude_through_all", False)),
|
||||
extrude_cut_all_bodies=bool(data.get("extrude_cut_all_bodies", False)),
|
||||
extrude_face_index=data.get("extrude_face_index"),
|
||||
extrude_target_body_id=data.get("extrude_target_body_id"),
|
||||
position=_to_3vec(data.get("position")),
|
||||
rotation=_to_mat3(data.get("rotation")),
|
||||
color=tuple(data.get("color", [0.2, 0.4, 0.8])),
|
||||
opacity=float(data.get("opacity", 1.0)),
|
||||
opacity=_to_float(data.get("opacity"), 1.0),
|
||||
visible=bool(data.get("visible", True)),
|
||||
)
|
||||
body.created_at = _parse_iso(data.get("created_at"))
|
||||
@@ -251,9 +396,9 @@ def _sketch_from_dict(
|
||||
# Re-apply the workplane (from_dict already does this internally, but be
|
||||
# defensive in case the saved dict didn't carry the workplane fields).
|
||||
occ_sketch.set_workplane(
|
||||
tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
|
||||
tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
|
||||
tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
|
||||
_to_3tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
|
||||
_to_3tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
|
||||
_to_3tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
|
||||
)
|
||||
|
||||
geometry: Optional[OCCGeometryObject] = None
|
||||
@@ -261,7 +406,7 @@ def _sketch_from_dict(
|
||||
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
||||
|
||||
sk = Sketch(
|
||||
id=data.get("id") or None,
|
||||
id=_saved_id(data),
|
||||
name=data.get("name", "Untitled Sketch"),
|
||||
occ_sketch=occ_sketch,
|
||||
geometry=geometry,
|
||||
@@ -298,7 +443,7 @@ def _component_from_dict(
|
||||
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||
) -> Component:
|
||||
comp = Component(
|
||||
id=data.get("id") or None,
|
||||
id=_saved_id(data),
|
||||
name=data.get("name", "Untitled Component"),
|
||||
description=data.get("description", ""),
|
||||
active_sketch=data.get("active_sketch"),
|
||||
@@ -319,7 +464,22 @@ def _component_from_dict(
|
||||
src_id = body_data.get("source_sketch_id")
|
||||
if src_id and src_id in comp.sketches:
|
||||
src_sketch = comp.sketches[src_id]
|
||||
comp.bodies[bid] = _body_from_dict(body_data, body_geometry_loader, src_sketch)
|
||||
body = _body_from_dict(body_data, body_geometry_loader, src_sketch)
|
||||
# Parametric feature history (new files). Old files carry no
|
||||
# "features" key — the body keeps an empty list and is migrated
|
||||
# lazily at update time (see ``_ensure_feature_history``).
|
||||
for f_data in body_data.get("features") or []:
|
||||
body.features.append(_feature_from_dict(f_data, comp.sketches))
|
||||
if body_data.get("features_base_snapshot") and body.features:
|
||||
# The list was saved WITHOUT its leading "base" snapshot;
|
||||
# restore it from the dedicated STEP member.
|
||||
base_geom: Optional[OCCGeometryObject] = None
|
||||
base_ref = body_data.get("base_geometry_ref")
|
||||
if base_ref and body_geometry_loader is not None:
|
||||
base_geom = body_geometry_loader(base_ref)
|
||||
if base_geom is not None:
|
||||
body.features.insert(0, Feature(operation="base", geometry=base_geom))
|
||||
comp.bodies[bid] = body
|
||||
|
||||
return comp
|
||||
|
||||
@@ -331,13 +491,16 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
|
||||
"position": list(conn.position),
|
||||
"normal": list(conn.normal),
|
||||
"x_dir": list(conn.x_dir),
|
||||
"axis_rotation": float(conn.axis_rotation),
|
||||
"offset": float(conn.offset),
|
||||
"axis_rotation": _to_float(conn.axis_rotation, 0.0),
|
||||
"offset": _to_float(conn.offset, 0.0),
|
||||
"assembly_component_id": conn.assembly_component_id,
|
||||
"source_obj_id": conn.source_obj_id,
|
||||
"entity_type": conn.entity_type,
|
||||
"normal_flip": bool(conn.normal_flip),
|
||||
"partner_ac_id": conn.partner_ac_id,
|
||||
"partner_connector_id": conn.partner_connector_id,
|
||||
"is_grounded": bool(conn.is_grounded),
|
||||
"is_invalid": bool(conn.is_invalid),
|
||||
"created_at": conn.created_at.isoformat() if conn.created_at else None,
|
||||
"modified_at": conn.modified_at.isoformat() if conn.modified_at else None,
|
||||
}
|
||||
@@ -345,19 +508,22 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
|
||||
|
||||
def _connector_from_dict(data: Dict[str, Any]) -> Connector:
|
||||
conn = Connector(
|
||||
id=data.get("id") or None,
|
||||
id=_saved_id(data),
|
||||
name=data.get("name", "Untitled Connector"),
|
||||
position=_to_3tuple(data.get("position")),
|
||||
normal=_to_3tuple(data.get("normal")),
|
||||
x_dir=_to_3tuple(data.get("x_dir")),
|
||||
axis_rotation=float(data.get("axis_rotation", 0.0)),
|
||||
offset=float(data.get("offset", 0.0)),
|
||||
axis_rotation=_to_float(data.get("axis_rotation"), 0.0),
|
||||
offset=_to_float(data.get("offset"), 0.0),
|
||||
assembly_component_id=data.get("assembly_component_id", ""),
|
||||
source_obj_id=data.get("source_obj_id", ""),
|
||||
entity_type=data.get("entity_type", ""),
|
||||
normal_flip=bool(data.get("normal_flip", False)),
|
||||
)
|
||||
conn.partner_ac_id = data.get("partner_ac_id")
|
||||
conn.partner_connector_id = data.get("partner_connector_id")
|
||||
conn.is_grounded = bool(data.get("is_grounded", False))
|
||||
conn.is_invalid = bool(data.get("is_invalid", False))
|
||||
conn.created_at = _parse_iso(data.get("created_at"))
|
||||
conn.modified_at = _parse_iso(data.get("modified_at"))
|
||||
return conn
|
||||
@@ -371,14 +537,24 @@ def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
|
||||
"position": _coerce_listlike(ac.position),
|
||||
"rotation": _coerce_listlike(ac.rotation),
|
||||
"connectors": {cid: _connector_to_dict(c) for cid, c in ac.connectors.items()},
|
||||
# Instance-local sketches + per-body modifier ops (kept apart from
|
||||
# the shared component so save/load never mutates the base model).
|
||||
"sketches": {sid: _sketch_to_dict(sk) for sid, sk in ac.sketches.items()},
|
||||
"modifiers": {
|
||||
bid: [_feature_to_dict(f) for f in mods]
|
||||
for bid, mods in ac.modifiers.items()
|
||||
},
|
||||
"created_at": ac.created_at.isoformat() if ac.created_at else None,
|
||||
"modified_at": ac.modified_at.isoformat() if ac.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
|
||||
def _assembly_component_from_dict(
|
||||
data: Dict[str, Any],
|
||||
component: Optional[Component] = None,
|
||||
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||
) -> AssemblyComponent:
|
||||
ac = AssemblyComponent(
|
||||
id=data.get("id") or None,
|
||||
id=_saved_id(data),
|
||||
component_id=data.get("component_id", ""),
|
||||
name=data.get("name", "Untitled Instance"),
|
||||
position=_to_3vec(data.get("position")),
|
||||
@@ -388,6 +564,36 @@ def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
|
||||
ac.modified_at = _parse_iso(data.get("modified_at"))
|
||||
for cid, c_data in (data.get("connectors") or {}).items():
|
||||
ac.connectors[cid] = _connector_from_dict(c_data)
|
||||
|
||||
# Instance-local sketches first, so modifier sketch references can
|
||||
# resolve against them (they live in component-local coordinates).
|
||||
for sid, sk_data in (data.get("sketches") or {}).items():
|
||||
try:
|
||||
ac.sketches[sid] = _sketch_from_dict(sk_data, sketch_geometry_loader)
|
||||
except Exception:
|
||||
logger.warning("Skipping corrupt instance sketch %s", sid)
|
||||
|
||||
# Modifiers resolve sketch refs against the owning component's sketches
|
||||
# first, then this instance's own sketches.
|
||||
registry: Dict[str, Sketch] = {}
|
||||
if component is not None:
|
||||
registry.update(component.sketches)
|
||||
registry.update(ac.sketches)
|
||||
for bid, f_list in (data.get("modifiers") or {}).items():
|
||||
kept: List[Feature] = []
|
||||
for f_data in f_list or []:
|
||||
try:
|
||||
feat = _feature_from_dict(f_data, registry)
|
||||
except Exception:
|
||||
logger.warning("Skipping corrupt instance modifier on body %s", bid)
|
||||
continue
|
||||
# A sketch-based op whose sketch failed to load can never
|
||||
# replay — dropping it keeps the rest of the chain usable.
|
||||
if feat.operation in ("extrude", "cut", "union", "revolve") and feat.sketch is None:
|
||||
continue
|
||||
kept.append(feat)
|
||||
if kept:
|
||||
ac.modifiers[bid] = kept
|
||||
return ac
|
||||
|
||||
|
||||
@@ -404,7 +610,7 @@ def _assembly_connection_to_dict(c: AssemblyConnection) -> Dict[str, Any]:
|
||||
|
||||
def _assembly_connection_from_dict(data: Dict[str, Any]) -> AssemblyConnection:
|
||||
conn = AssemblyConnection(
|
||||
id=data.get("id") or None,
|
||||
id=_saved_id(data),
|
||||
first_ac_id=data.get("first_ac_id", ""),
|
||||
second_ac_id=data.get("second_ac_id", ""),
|
||||
first_connector_id=data.get("first_connector_id"),
|
||||
@@ -425,22 +631,146 @@ def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
|
||||
"modified_at": asm.modified_at.isoformat() if asm.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
|
||||
def _assembly_from_dict(
|
||||
data: Dict[str, Any],
|
||||
components: Optional[Dict[str, Component]] = None,
|
||||
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||
) -> Assembly:
|
||||
asm = Assembly(
|
||||
id=data.get("id") or None,
|
||||
id=_saved_id(data),
|
||||
name=data.get("name", "Untitled Assembly"),
|
||||
active_assembly_component=data.get("active_assembly_component"),
|
||||
)
|
||||
asm.created_at = _parse_iso(data.get("created_at"))
|
||||
asm.modified_at = _parse_iso(data.get("modified_at"))
|
||||
for cid, ac_data in (data.get("components") or {}).items():
|
||||
asm.components[cid] = _assembly_component_from_dict(ac_data)
|
||||
for c_data in (data.get("connections") or []):
|
||||
comp = (components or {}).get(ac_data.get("component_id", ""))
|
||||
asm.components[cid] = _assembly_component_from_dict(
|
||||
ac_data, component=comp, sketch_geometry_loader=sketch_geometry_loader
|
||||
)
|
||||
for c_data in data.get("connections") or []:
|
||||
asm.connections.append(_assembly_connection_from_dict(c_data))
|
||||
return asm
|
||||
|
||||
|
||||
def _drawing_view_to_dict(v: DrawingView) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": v.id,
|
||||
"kind": v.kind,
|
||||
"name": v.name,
|
||||
"direction": _coerce_listlike(v.direction),
|
||||
"up_vector": _coerce_listlike(v.up_vector),
|
||||
"show_hidden_lines": bool(v.show_hidden_lines),
|
||||
"show_centerlines": bool(v.show_centerlines),
|
||||
"scale": _to_float(v.scale, 1.0),
|
||||
"sheet_origin": _coerce_listlike(v.sheet_origin),
|
||||
}
|
||||
|
||||
|
||||
def _drawing_view_from_dict(data: Dict[str, Any]) -> DrawingView:
|
||||
view = DrawingView(
|
||||
id=_saved_id(data),
|
||||
kind=data.get("kind", "front"),
|
||||
name=data.get("name"),
|
||||
direction=_to_3tuple(data.get("direction")) or None,
|
||||
up_vector=_to_3tuple(data.get("up_vector")) or None,
|
||||
show_hidden_lines=bool(data.get("show_hidden_lines", False)),
|
||||
show_centerlines=bool(data.get("show_centerlines", False)),
|
||||
scale=_to_float(data.get("scale"), 1.0),
|
||||
)
|
||||
origin = data.get("sheet_origin")
|
||||
if isinstance(origin, (list, tuple)) and len(origin) >= 2:
|
||||
view.sheet_origin = (_to_float(origin[0]), _to_float(origin[1]))
|
||||
return view
|
||||
|
||||
|
||||
def _drawing_annotation_to_dict(a: DrawingAnnotation) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": a.id,
|
||||
"kind": a.kind,
|
||||
"text": a.text,
|
||||
"visible": bool(a.visible),
|
||||
"references": list(a.references),
|
||||
"sheet_position": _coerce_listlike(a.sheet_position),
|
||||
"view_id": a.view_id,
|
||||
"anchors": [list(pt) for pt in a.anchors],
|
||||
"dimension_kind": a.dimension_kind,
|
||||
"direction": _coerce_listlike(a.direction),
|
||||
}
|
||||
|
||||
|
||||
def _drawing_annotation_from_dict(data: Dict[str, Any]) -> DrawingAnnotation:
|
||||
ann = DrawingAnnotation(
|
||||
id=_saved_id(data),
|
||||
kind=data.get("kind", "dimension"),
|
||||
text=data.get("text"),
|
||||
visible=bool(data.get("visible", True)),
|
||||
references=list(data.get("references") or []),
|
||||
view_id=data.get("view_id", ""),
|
||||
dimension_kind=data.get("dimension_kind", ""),
|
||||
)
|
||||
pos = data.get("sheet_position")
|
||||
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
|
||||
ann.sheet_position = (_to_float(pos[0]), _to_float(pos[1]))
|
||||
anchors = data.get("anchors") or []
|
||||
for pt in anchors:
|
||||
if isinstance(pt, (list, tuple)) and len(pt) >= 2:
|
||||
ann.anchors.append((_to_float(pt[0]), _to_float(pt[1])))
|
||||
direction = data.get("direction")
|
||||
if isinstance(direction, (list, tuple)) and len(direction) >= 2:
|
||||
ann.direction = (_to_float(direction[0]), _to_float(direction[1]))
|
||||
return ann
|
||||
|
||||
|
||||
def _technical_drawing_to_dict(d: TechnicalDrawing) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": d.id,
|
||||
"name": d.name,
|
||||
"source_kind": d.source_kind,
|
||||
"source_id": d.source_id,
|
||||
"views": [_drawing_view_to_dict(v) for v in d.views],
|
||||
"annotations": [_drawing_annotation_to_dict(a) for a in d.annotations],
|
||||
"title": d.title,
|
||||
"part_number": d.part_number,
|
||||
"material": d.material,
|
||||
"revision": d.revision,
|
||||
"notes": d.notes,
|
||||
"sheet_size": d.sheet_size,
|
||||
"units": d.units,
|
||||
"auto_dimensions": bool(d.auto_dimensions),
|
||||
"auto_views": bool(d.auto_views),
|
||||
"created_at": d.created_at.isoformat() if d.created_at else None,
|
||||
"modified_at": d.modified_at.isoformat() if d.modified_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _technical_drawing_from_dict(data: Dict[str, Any]) -> TechnicalDrawing:
|
||||
drawing = TechnicalDrawing(
|
||||
id=_saved_id(data),
|
||||
name=data.get("name", "Untitled Drawing"),
|
||||
source_kind=data.get("source_kind", "component"),
|
||||
source_id=data.get("source_id", ""),
|
||||
title=data.get("title", ""),
|
||||
part_number=data.get("part_number", ""),
|
||||
material=data.get("material", ""),
|
||||
revision=data.get("revision", ""),
|
||||
notes=data.get("notes", ""),
|
||||
# The renderer only supports the A4 ISO 5457 sheet; normalize
|
||||
# legacy drawings (stored as A3) instead of surfacing stale sizes.
|
||||
sheet_size="A4",
|
||||
units=data.get("units", "mm"),
|
||||
auto_dimensions=bool(data.get("auto_dimensions", False)),
|
||||
auto_views=bool(data.get("auto_views", True)),
|
||||
)
|
||||
drawing.created_at = _parse_iso(data.get("created_at"))
|
||||
drawing.modified_at = _parse_iso(data.get("modified_at"))
|
||||
for v_data in data.get("views") or []:
|
||||
drawing.views.append(_drawing_view_from_dict(v_data))
|
||||
for a_data in data.get("annotations") or []:
|
||||
drawing.annotations.append(_drawing_annotation_from_dict(a_data))
|
||||
return drawing
|
||||
|
||||
|
||||
def _project_to_dict(
|
||||
project: Project,
|
||||
view_state: Optional[Dict[str, Any]] = None,
|
||||
@@ -453,6 +783,7 @@ def _project_to_dict(
|
||||
"active_assembly": project.active_assembly,
|
||||
"components": {cid: _component_to_dict(c) for cid, c in project.components.items()},
|
||||
"assemblies": {aid: _assembly_to_dict(a) for aid, a in project.assemblies.items()},
|
||||
"drawings": [_technical_drawing_to_dict(d) for d in project.drawings],
|
||||
"created_at": project.created_at.isoformat() if project.created_at else None,
|
||||
"modified_at": project.modified_at.isoformat() if project.modified_at else None,
|
||||
"view_state": view_state or {},
|
||||
@@ -499,7 +830,9 @@ def _read_step_bytes(
|
||||
with open(tmp_path, "wb") as f:
|
||||
f.write(data)
|
||||
geom = kernel.import_step(tmp_path)
|
||||
return geom
|
||||
from typing import cast
|
||||
|
||||
return cast(OCCGeometryObject, geom)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to read STEP: %s", exc)
|
||||
return None
|
||||
@@ -562,6 +895,20 @@ def save_project(
|
||||
arcname = f"bodies/{body_id}.step"
|
||||
body_files.append((arcname, step_bytes))
|
||||
manifest["components"][comp_id]["bodies"][body_id]["geometry_ref"] = arcname
|
||||
# Base-snapshot STEP for migrated legacy bodies whose feature
|
||||
# list starts with a frozen "base" geometry snapshot.
|
||||
if (
|
||||
body.features
|
||||
and body.features[0].operation == "base"
|
||||
and body.features[0].geometry is not None
|
||||
):
|
||||
base_bytes = _write_step_for_body(kernel, body.features[0].geometry)
|
||||
if base_bytes is not None:
|
||||
base_arcname = f"bodies/{body_id}_base.step"
|
||||
body_files.append((base_arcname, base_bytes))
|
||||
manifest["components"][comp_id]["bodies"][body_id]["base_geometry_ref"] = (
|
||||
base_arcname
|
||||
)
|
||||
|
||||
# Per-sketch STEP files (solved face geometry).
|
||||
sketch_files: List[Tuple[str, bytes]] = []
|
||||
@@ -597,6 +944,40 @@ def save_project(
|
||||
sketch_files.append((arcname, step_bytes))
|
||||
manifest["components"][comp_id]["sketches"][sketch_id]["geometry_ref"] = arcname
|
||||
|
||||
# Instance-local sketches (assembly components) get the same sidecar
|
||||
# treatment as component sketches; the manifest nodes are patched in
|
||||
# place under the assembly's component entry.
|
||||
for asm_id, asm in project.assemblies.items():
|
||||
for ac_id, ac in asm.components.items():
|
||||
for sketch_id, sketch in ac.sketches.items():
|
||||
node = manifest["assemblies"][asm_id]["components"][ac_id]["sketches"].get(sketch_id)
|
||||
if node is None:
|
||||
continue
|
||||
occ = sketch.occ_sketch.to_dict() if sketch.occ_sketch is not None else None
|
||||
meta = {
|
||||
"id": sketch.id,
|
||||
"name": sketch.name,
|
||||
"workplane_origin": _coerce_listlike(sketch.workplane_origin),
|
||||
"workplane_normal": _coerce_listlike(sketch.workplane_normal),
|
||||
"workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
|
||||
"is_solved": bool(sketch.is_solved),
|
||||
"is_fully_constrained": bool(sketch.is_fully_constrained),
|
||||
"occ_sketch": occ,
|
||||
}
|
||||
meta_arc = f"sketches/{sketch_id}/meta.json"
|
||||
sketch_meta_files.append((meta_arc, _to_json(meta).encode("utf-8")))
|
||||
node["occ_sketch"] = None
|
||||
node["occ_sketch_ref"] = meta_arc
|
||||
|
||||
if sketch.geometry is None:
|
||||
continue
|
||||
step_bytes = _write_step_for_body(kernel, sketch.geometry)
|
||||
if step_bytes is None:
|
||||
continue
|
||||
arcname = f"sketches/{sketch_id}/solved.step"
|
||||
sketch_files.append((arcname, step_bytes))
|
||||
node["geometry_ref"] = arcname
|
||||
|
||||
# Write the ZIP. Use a temp file + rename so a partial write can't
|
||||
# clobber an existing good file.
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".fluency")
|
||||
@@ -654,13 +1035,17 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
|
||||
|
||||
with zipfile.ZipFile(filepath, "r") as zipf:
|
||||
manifest_raw = zipf.read("project.json")
|
||||
manifest = json.loads(manifest_raw.decode("utf-8"))
|
||||
try:
|
||||
manifest = json.loads(manifest_raw.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise RuntimeError(f"Corrupt project file (bad JSON): {filepath}") from exc
|
||||
view_state: Dict[str, Any] = manifest.get("view_state") or {}
|
||||
|
||||
# If a sketch's occ_sketch is referenced as a separate file, read
|
||||
# it in now and patch the manifest so _sketch_from_dict sees it.
|
||||
for comp_id, comp_data in (manifest.get("components") or {}).items():
|
||||
for sk_id, sk_data in (comp_data.get("sketches") or {}).items():
|
||||
# Applies to both component sketches and instance-local sketches.
|
||||
def _patch_sketch_sidecars(sketches_dict: Dict[str, Any]) -> None:
|
||||
for sk_id, sk_data in (sketches_dict or {}).items():
|
||||
ref = sk_data.get("occ_sketch_ref")
|
||||
if not ref:
|
||||
continue
|
||||
@@ -669,15 +1054,30 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
|
||||
except KeyError:
|
||||
logger.warning("Sketch meta missing in archive: %s", ref)
|
||||
continue
|
||||
meta = json.loads(meta_bytes.decode("utf-8"))
|
||||
try:
|
||||
meta = json.loads(meta_bytes.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
logger.warning("Sketch meta corrupt in archive: %s (%s)", ref, exc)
|
||||
continue
|
||||
sk_data["occ_sketch"] = meta.get("occ_sketch")
|
||||
# Workplane fields on the sketch-level file override the
|
||||
# embedded ones (source of truth lives in the sidecar).
|
||||
for k in ("workplane_origin", "workplane_normal", "workplane_x_dir",
|
||||
"is_solved", "is_fully_constrained"):
|
||||
for k in (
|
||||
"workplane_origin",
|
||||
"workplane_normal",
|
||||
"workplane_x_dir",
|
||||
"is_solved",
|
||||
"is_fully_constrained",
|
||||
):
|
||||
if k in meta:
|
||||
sk_data[k] = meta[k]
|
||||
|
||||
for comp_id, comp_data in (manifest.get("components") or {}).items():
|
||||
_patch_sketch_sidecars(comp_data.get("sketches"))
|
||||
for aid, a_data in (manifest.get("assemblies") or {}).items():
|
||||
for ac_id, ac_data in (a_data.get("components") or {}).items():
|
||||
_patch_sketch_sidecars(ac_data.get("sketches"))
|
||||
|
||||
project = Project(
|
||||
name=manifest.get("name", "Untitled Project"),
|
||||
description=manifest.get("description", ""),
|
||||
@@ -697,7 +1097,17 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
|
||||
)
|
||||
|
||||
for aid, a_data in (manifest.get("assemblies") or {}).items():
|
||||
project.assemblies[aid] = _assembly_from_dict(a_data)
|
||||
project.assemblies[aid] = _assembly_from_dict(
|
||||
a_data,
|
||||
components=project.components,
|
||||
sketch_geometry_loader=sketch_geometry_loader,
|
||||
)
|
||||
|
||||
for d_data in manifest.get("drawings") or []:
|
||||
try:
|
||||
project.drawings.append(_technical_drawing_from_dict(d_data))
|
||||
except Exception as exc:
|
||||
logger.warning("Skipping corrupt drawing in archive: %s", exc)
|
||||
|
||||
# After all components are loaded, re-wire connector partner ids so
|
||||
# they point to the freshly-loaded AssemblyComponents. (The dict
|
||||
|
||||
@@ -24,6 +24,7 @@ from fluency.ui.dialogs import (
|
||||
ExtrudeDialog,
|
||||
OffsetDialog,
|
||||
RevolveDialog,
|
||||
ThreadDialog,
|
||||
WorkplaneOrientationDialog,
|
||||
)
|
||||
from fluency.ui.main_window import MainWindow
|
||||
@@ -37,6 +38,7 @@ __all__ = [
|
||||
"ExtrudeDialog",
|
||||
"RevolveDialog",
|
||||
"OffsetDialog",
|
||||
"ThreadDialog",
|
||||
"WorkplaneOrientationDialog",
|
||||
"main",
|
||||
]
|
||||
|
||||
@@ -13,9 +13,7 @@ import numpy as np
|
||||
|
||||
from fluency.geometry.base import (
|
||||
Point2D,
|
||||
Point3D,
|
||||
GeometryObject,
|
||||
SketchInterface,
|
||||
)
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
@@ -48,6 +46,7 @@ class Workplane:
|
||||
def __post_init__(self):
|
||||
# Normalise normal and x_dir on construction.
|
||||
import numpy as np
|
||||
|
||||
n = np.asarray(self.normal, dtype=float)
|
||||
n = n / np.linalg.norm(n)
|
||||
x = np.asarray(self.x_dir, dtype=float)
|
||||
@@ -61,9 +60,9 @@ class Workplane:
|
||||
x = x / x_norm
|
||||
y = np.cross(n, x)
|
||||
y = y / np.linalg.norm(y)
|
||||
self.normal = tuple(float(v) for v in n)
|
||||
self.x_dir = tuple(float(v) for v in x)
|
||||
self._y_dir = tuple(float(v) for v in y)
|
||||
self.normal = (float(n[0]), float(n[1]), float(n[2]))
|
||||
self.x_dir = (float(x[0]), float(x[1]), float(x[2]))
|
||||
self._y_dir = (float(y[0]), float(y[1]), float(y[2]))
|
||||
|
||||
@property
|
||||
def y_dir(self) -> Tuple[float, float, float]:
|
||||
@@ -84,6 +83,7 @@ class Workplane:
|
||||
def world_to_uv(self, p: Tuple[float, float, float]) -> Tuple[float, float]:
|
||||
"""Map a 3D world point to UV coordinates on this plane."""
|
||||
import numpy as np
|
||||
|
||||
ox, oy, oz = self.origin
|
||||
v = np.array([p[0] - ox, p[1] - oy, p[2] - oz])
|
||||
xd = np.array(self.x_dir, dtype=float)
|
||||
@@ -206,6 +206,101 @@ class Sketch:
|
||||
self.modified_at = datetime.now()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Feature:
|
||||
"""
|
||||
One operation in a body's parametric feature history.
|
||||
|
||||
Bodies rebuild their geometry by replaying their ordered feature
|
||||
list from scratch (see ``Body.features``). This is what makes
|
||||
sketch edits propagate: a moved circle re-cuts at the new position
|
||||
on a freshly rebuilt base instead of adding to the previous result.
|
||||
|
||||
``operation`` is one of:
|
||||
- "extrude": base solid, ``kernel.extrude`` of the sketch profile
|
||||
- "revolve": base solid, ``kernel.revolve`` of the sketch profile
|
||||
- "cut": boolean difference of the running geometry with the
|
||||
extruded sketch profile
|
||||
- "union": boolean union of the running geometry with the
|
||||
extruded sketch profile
|
||||
- "fillet": round a set of edges of the running geometry
|
||||
(``radius``, ``tangent_propagation``, ``scope``,
|
||||
``edge_refs`` — see below)
|
||||
- "chamfer": bevel a set of edges of the running geometry
|
||||
(same fields as "fillet"; ``radius`` holds the
|
||||
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
|
||||
circular array. ``pattern_type`` is "linear" or
|
||||
"circular"; ``count`` is the total number of items
|
||||
(original + copies). Linear uses ``direction``
|
||||
(unit vector) and ``spacing`` (mm between adjacent
|
||||
items); circular reuses ``axis`` + ``origin`` for the
|
||||
rotation axis and ``angle`` for the total angular
|
||||
span in degrees (copies evenly distributed).
|
||||
- "base": frozen geometry snapshot (``geometry`` field) — used
|
||||
to migrate legacy bodies whose original base feature
|
||||
is unknown. Never the result of a user operation.
|
||||
"""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
operation: str = "extrude"
|
||||
|
||||
sketch: Optional[Sketch] = None # runtime ref; serialized as sketch_id
|
||||
length: Optional[float] = None
|
||||
symmetric: bool = False
|
||||
invert: bool = False
|
||||
through_all: bool = False
|
||||
cut_all_bodies: bool = False
|
||||
face_index: Optional[int] = None # which sketch face was selected
|
||||
angle: float = 360.0 # revolve only (degrees)
|
||||
axis: Tuple[float, float, float] = (0, 0, 1) # revolve only: unit axis vector
|
||||
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0) # revolve only: axis point
|
||||
axis_line_id: Optional[int] = None # revolve only: sketch line entity used as axis
|
||||
|
||||
# "base" features only: frozen pre-feature geometry snapshot.
|
||||
geometry: Optional[OCCGeometryObject] = None
|
||||
|
||||
# "fillet" / "chamfer" features only: radius (mm) of the round or the
|
||||
# chamfer size, whether the op should extend along edges tangent to the
|
||||
# picked ones, the edge scope ("selected" = edges between the two
|
||||
# picked faces, "all" = every edge of the body), and stable
|
||||
# fingerprints of the selected edges so the replay can re-find them
|
||||
# after the base geometry is rebuilt.
|
||||
radius: Optional[float] = None
|
||||
tangent_propagation: bool = False
|
||||
scope: str = "selected"
|
||||
edge_refs: List[str] = field(default_factory=list)
|
||||
|
||||
# FaceKey references for fillet/chamfer: stable face classification
|
||||
# that survives sketch dimension changes. ``face_keys`` is a list of
|
||||
# (FaceKey, FaceKey) pairs — one pair per user-picked face;
|
||||
# ``face_keys_sketch_id`` tracks which sketch produced the body these
|
||||
# faces belong to.
|
||||
face_keys: Optional[List[Tuple[Dict[str, Any], Dict[str, Any]]]] = None
|
||||
face_keys_sketch_id: Optional[str] = None
|
||||
|
||||
# "array" / "pattern" features only: repeat the running solid.
|
||||
# ``pattern_type`` is "linear" or "circular"; ``count`` is the total
|
||||
# number of items including the original. Linear arrays use
|
||||
# ``direction`` (unit vector) and ``spacing`` (mm); circular arrays
|
||||
# reuse ``axis`` / ``origin`` / ``angle`` (total angular span, deg).
|
||||
pattern_type: str = "linear"
|
||||
count: int = 2
|
||||
spacing: float = 10.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)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Body:
|
||||
"""
|
||||
@@ -222,6 +317,27 @@ class Body:
|
||||
source_sketch: Optional[Sketch] = None
|
||||
source_operation: str = "extrude"
|
||||
|
||||
# Parametric feature history. When non-empty, the body is rebuilt
|
||||
# from scratch by replaying these features in order; the flat
|
||||
# extrude_* / source_* fields below then only mirror the LAST
|
||||
# feature for backward compatibility (old files, old code paths).
|
||||
features: List[Feature] = field(default_factory=list)
|
||||
|
||||
# Re-extrusion parameters — stored so the body can be rebuilt from
|
||||
# its source sketch when the sketch is edited. None means the body
|
||||
# was not created by an extrude-type operation and cannot be auto-
|
||||
# rebuilt.
|
||||
extrude_length: Optional[float] = None
|
||||
extrude_symmetric: bool = False
|
||||
extrude_invert: bool = False
|
||||
extrude_cut: bool = False
|
||||
extrude_union: bool = False
|
||||
extrude_through_all: bool = False
|
||||
extrude_cut_all_bodies: bool = False # cut through all bodies in component
|
||||
extrude_face_index: Optional[int] = None # which sketch face was selected
|
||||
extrude_target_body_id: Optional[str] = None # for cut/union: target body id
|
||||
needs_update: bool = False # True when source sketch changed since last extrude
|
||||
|
||||
position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
|
||||
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
|
||||
|
||||
@@ -230,6 +346,7 @@ class Body:
|
||||
visible: bool = True
|
||||
|
||||
render_object: Any = None
|
||||
render_material: Optional[str] = None # material preset name for render backend
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
modified_at: datetime = field(default_factory=datetime.now)
|
||||
@@ -351,11 +468,14 @@ class Connector:
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str = "Untitled Connector"
|
||||
|
||||
# 3D position of the connection point (world coords).
|
||||
# 3D position of the connection point (component-local coords).
|
||||
# Transformed to world coords at render time using the parent
|
||||
# AssemblyComponent's position/rotation, so connectors move with
|
||||
# their component automatically.
|
||||
position: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
# Normal direction of the connection (e.g. hole axis).
|
||||
# Normal direction of the connection (e.g. hole axis) in local coords.
|
||||
normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||
# In-plane X direction for defining the reference frame.
|
||||
# In-plane X direction for defining the reference frame (local coords).
|
||||
x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||
|
||||
# Rotation around the normal axis (degrees).
|
||||
@@ -367,6 +487,16 @@ class Connector:
|
||||
assembly_component_id: str = ""
|
||||
# Which body/face this connector was placed on (renderer obj_id).
|
||||
source_obj_id: str = ""
|
||||
# Entity class the connector was picked on ("planar_face",
|
||||
# "cylindrical_face", "edge", "vertex"). Used to re-locate the
|
||||
# connector on rebuilt geometry: only features of the same class are
|
||||
# considered, so a hole connector can never jump onto a flat face.
|
||||
# Empty for legacy files (all classes are then searched).
|
||||
entity_type: str = ""
|
||||
# Flip chosen in the placement dialog (bolt enters from the opposite
|
||||
# side). Re-applied when a mated pair is re-solved so the original
|
||||
# mate pose is reproduced exactly.
|
||||
normal_flip: bool = False
|
||||
|
||||
# --- Rigid-group pairing (set when two connectors are mated) ---
|
||||
# The id of the partner AssemblyComponent this connector is mated to.
|
||||
@@ -379,9 +509,23 @@ class Connector:
|
||||
# True on the first-picked (grounded) connector of a mated pair.
|
||||
is_grounded: bool = False
|
||||
|
||||
# True when the source geometry this connector was placed on has
|
||||
# changed or disappeared — the connector needs re-placement.
|
||||
is_invalid: bool = False
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
modified_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Legacy files predate the entity_type field: recover it from the
|
||||
# auto-generated connector name ("Conn cylindrical_face anchor") so
|
||||
# relocation can restrict its search to the same feature class.
|
||||
if not self.entity_type:
|
||||
for t in ("cylindrical_face", "planar_face", "edge", "vertex"):
|
||||
if self.name in (f"Conn {t} anchor", f"Conn {t} mover"):
|
||||
self.entity_type = t
|
||||
break
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssemblyComponent:
|
||||
@@ -405,6 +549,20 @@ class AssemblyComponent:
|
||||
# Connectors defined on this component instance.
|
||||
connectors: Dict[str, Connector] = field(default_factory=dict)
|
||||
|
||||
# Instance-local (per-instantiation) state. Kept separate from the
|
||||
# shared component so per-instance work never leaks back into the base
|
||||
# model. ``sketches`` are instance-local sketches stored in
|
||||
# component-local coordinates (so they stay valid when the instance is
|
||||
# moved / rotated); ``modifiers`` maps body_id to an ordered list of
|
||||
# Feature ops applied ON TOP of the live component body feature history
|
||||
# when the instance geometry is rebuilt.
|
||||
sketches: Dict[str, Sketch] = field(default_factory=dict)
|
||||
modifiers: Dict[str, List[Feature]] = field(default_factory=dict)
|
||||
|
||||
# Runtime-only cache of rebuilt instance geometry (body_id -> geometry).
|
||||
# Never serialized; invalidated on component updates and modifier edits.
|
||||
geom_cache: Dict[str, Any] = field(default_factory=dict, repr=False)
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
modified_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
@@ -415,6 +573,8 @@ class AssemblyComponent:
|
||||
x_dir: Tuple[float, float, float],
|
||||
source_obj_id: str = "",
|
||||
name: Optional[str] = None,
|
||||
entity_type: str = "",
|
||||
normal_flip: bool = False,
|
||||
) -> Connector:
|
||||
"""Add a connector to this component instance."""
|
||||
conn = Connector(
|
||||
@@ -424,6 +584,8 @@ class AssemblyComponent:
|
||||
x_dir=x_dir,
|
||||
assembly_component_id=self.id,
|
||||
source_obj_id=source_obj_id,
|
||||
entity_type=entity_type,
|
||||
normal_flip=normal_flip,
|
||||
)
|
||||
self.connectors[conn.id] = conn
|
||||
self.modified_at = datetime.now()
|
||||
@@ -437,6 +599,45 @@ class AssemblyComponent:
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_instance_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
|
||||
"""Add an instance-local sketch (component-local coordinates)."""
|
||||
if sketch is None:
|
||||
sketch = Sketch(name=f"Instance Sketch {len(self.sketches) + 1}")
|
||||
self.sketches[sketch.id] = sketch
|
||||
self.modified_at = datetime.now()
|
||||
return sketch
|
||||
|
||||
def remove_instance_sketch(self, sketch_id: str) -> bool:
|
||||
"""Remove an instance sketch and every modifier that references it."""
|
||||
if sketch_id not in self.sketches:
|
||||
return False
|
||||
del self.sketches[sketch_id]
|
||||
for body_id in list(self.modifiers.keys()):
|
||||
kept = [
|
||||
f for f in self.modifiers[body_id]
|
||||
if not (f.sketch is not None and f.sketch.id == sketch_id)
|
||||
]
|
||||
if kept:
|
||||
self.modifiers[body_id] = kept
|
||||
else:
|
||||
del self.modifiers[body_id]
|
||||
self.geom_cache.pop(body_id, None)
|
||||
self.modified_at = datetime.now()
|
||||
return True
|
||||
|
||||
def add_modifier(self, body_id: str, feat: Feature) -> Feature:
|
||||
"""Append a modifier op to *body_id*'s instance-local history."""
|
||||
self.modifiers.setdefault(body_id, []).append(feat)
|
||||
self.geom_cache.pop(body_id, None)
|
||||
self.modified_at = datetime.now()
|
||||
return feat
|
||||
|
||||
def invalidate_geom_cache(self, body_id: Optional[str] = None) -> None:
|
||||
"""Drop cached rebuilt instance geometry (one body, or all)."""
|
||||
if body_id is None:
|
||||
self.geom_cache.clear()
|
||||
else:
|
||||
self.geom_cache.pop(body_id, None)
|
||||
|
||||
@dataclass
|
||||
class AssemblyConnection:
|
||||
@@ -450,8 +651,8 @@ class AssemblyConnection:
|
||||
"""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
first_ac_id: str = "" # grounded reference side
|
||||
second_ac_id: str = "" # solved side
|
||||
first_ac_id: str = "" # grounded reference side
|
||||
second_ac_id: str = "" # solved side
|
||||
first_connector_id: Optional[str] = None
|
||||
second_connector_id: Optional[str] = None
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
@@ -486,9 +687,15 @@ class Assembly:
|
||||
"""Record a mated connector pair between two component instances.
|
||||
|
||||
The first-picked component (``first_ac_id``) is treated as the
|
||||
grounded reference of the pair. Returns the AssemblyConnection for
|
||||
further bookkeeping (e.g. attaching partner connector ids).
|
||||
grounded reference of the pair. Guards against duplicate entries.
|
||||
Returns the AssemblyConnection for further bookkeeping.
|
||||
"""
|
||||
# Guard: deduplicate — same pair in either order
|
||||
for c in self.connections:
|
||||
if (c.first_ac_id == first_ac_id and c.second_ac_id == second_ac_id) or (
|
||||
c.first_ac_id == second_ac_id and c.second_ac_id == first_ac_id
|
||||
):
|
||||
return c
|
||||
conn = AssemblyConnection(
|
||||
first_ac_id=first_ac_id,
|
||||
second_ac_id=second_ac_id,
|
||||
@@ -500,8 +707,7 @@ class Assembly:
|
||||
def remove_connections_for(self, ac_id: str) -> None:
|
||||
"""Drop every connection that involves *ac_id* (e.g. on removal)."""
|
||||
self.connections = [
|
||||
c for c in self.connections
|
||||
if c.first_ac_id != ac_id and c.second_ac_id != ac_id
|
||||
c for c in self.connections if c.first_ac_id != ac_id and c.second_ac_id != ac_id
|
||||
]
|
||||
|
||||
def get_rigid_group(self, ac_id: str) -> List[str]:
|
||||
@@ -534,6 +740,10 @@ class Assembly:
|
||||
"""True if *ac_id* is the grounded (first-picked) side of any pair."""
|
||||
return any(c.first_ac_id == ac_id for c in self.connections)
|
||||
|
||||
def get_group_size(self, ac_id: str) -> int:
|
||||
"""Number of components rigidly linked to *ac_id* (including itself)."""
|
||||
return len(self.get_rigid_group(ac_id))
|
||||
|
||||
def add_component_instance(
|
||||
self, component_id: str, name: Optional[str] = None
|
||||
) -> AssemblyComponent:
|
||||
@@ -561,19 +771,14 @@ class Assembly:
|
||||
# the rigid-group graph and point at a missing component.
|
||||
self.remove_connections_for(assembly_component_id)
|
||||
if self.active_assembly_component == assembly_component_id:
|
||||
self.active_assembly_component = next(
|
||||
iter(self.components.keys()), None
|
||||
)
|
||||
self.active_assembly_component = next(iter(self.components.keys()), None)
|
||||
self.modified_at = datetime.now()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_active_instance(self) -> Optional[AssemblyComponent]:
|
||||
"""Get the currently active assembly component instance."""
|
||||
if (
|
||||
self.active_assembly_component
|
||||
and self.active_assembly_component in self.components
|
||||
):
|
||||
if self.active_assembly_component and self.active_assembly_component in self.components:
|
||||
return self.components[self.active_assembly_component]
|
||||
return None
|
||||
|
||||
@@ -596,6 +801,10 @@ class Project:
|
||||
assemblies: Dict[str, Assembly] = field(default_factory=dict)
|
||||
active_assembly: Optional[str] = None
|
||||
|
||||
# Technical drawings keyed to their source component/assembly.
|
||||
# Manual dimensions and view options added in the drawing workbench
|
||||
# are persisted here so they survive save/load.
|
||||
drawings: List["TechnicalDrawing"] = field(default_factory=list)
|
||||
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
@@ -672,36 +881,21 @@ class Project:
|
||||
"""Look up a component by id across all project components."""
|
||||
return self.components.get(component_id)
|
||||
|
||||
def add_component(self, component: Optional[Component] = None) -> Component:
|
||||
"""Add a component to the project."""
|
||||
if component is None:
|
||||
component = Component(name=f"Component {len(self.components) + 1}")
|
||||
self.components[component.id] = component
|
||||
if self.active_component is None:
|
||||
self.active_component = component.id
|
||||
self.modified_at = datetime.now()
|
||||
return component
|
||||
# ── Drawing helpers ──
|
||||
|
||||
def remove_component(self, component_id: str) -> bool:
|
||||
"""Remove a component from the project."""
|
||||
if component_id in self.components:
|
||||
del self.components[component_id]
|
||||
if self.active_component == component_id:
|
||||
self.active_component = next(iter(self.components.keys()), None)
|
||||
self.modified_at = datetime.now()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_active_component(self) -> Optional[Component]:
|
||||
"""Get the currently active component."""
|
||||
if self.active_component and self.active_component in self.components:
|
||||
return self.components[self.active_component]
|
||||
def get_drawing_for(self, source_kind: str, source_id: str) -> Optional["TechnicalDrawing"]:
|
||||
"""Return the drawing bound to *(source_kind, source_id)*, if any."""
|
||||
for drawing in self.drawings:
|
||||
if drawing.source_kind == source_kind and drawing.source_id == source_id:
|
||||
return drawing
|
||||
return None
|
||||
|
||||
def set_active_component(self, component_id: Optional[str]) -> None:
|
||||
"""Set the active component."""
|
||||
self.active_component = component_id
|
||||
def add_drawing(self, drawing: "TechnicalDrawing") -> "TechnicalDrawing":
|
||||
"""Register *drawing* with the project and return it."""
|
||||
self.drawings.append(drawing)
|
||||
self.modified_at = datetime.now()
|
||||
return drawing
|
||||
|
||||
|
||||
def export_step(self, filepath: str) -> bool:
|
||||
"""Export all visible bodies to STEP."""
|
||||
@@ -770,3 +964,102 @@ class Project:
|
||||
for comp in self.components.values():
|
||||
sketches.extend(comp.sketches.values())
|
||||
return sketches
|
||||
|
||||
def compute_source_fingerprint(
|
||||
self, source_kind: str, source_id: str
|
||||
) -> str:
|
||||
"""Compute a simple fingerprint for a source reference."""
|
||||
import hashlib
|
||||
data = f"{source_kind}:{source_id}"
|
||||
return hashlib.sha256(data.encode()).hexdigest()[:16]
|
||||
# ── Technical Drawing models ───────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class DrawingView:
|
||||
"""One projected view in a technical drawing."""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
kind: str = "front" # front, back, top, bottom, right, left, isometric, custom
|
||||
name: Optional[str] = None # human-readable label; defaults from kind
|
||||
|
||||
# View direction and up vector in world coords (for custom views).
|
||||
# Ignored when kind is one of the standard presets.
|
||||
direction: Optional[Tuple[float, float, float]] = None
|
||||
up_vector: Optional[Tuple[float, float, float]] = None
|
||||
|
||||
show_hidden_lines: bool = False
|
||||
show_centerlines: bool = False
|
||||
scale: float = 1.0
|
||||
|
||||
# Sheet position (mm from sheet origin) — set by layout engine.
|
||||
sheet_origin: Tuple[float, float] = (0.0, 0.0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DrawingAnnotation:
|
||||
"""One annotation (dimension or note) on a technical drawing."""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
kind: str = "dimension" # dimension, note, tolerance, surface_finish, weld_symbol
|
||||
text: Optional[str] = None
|
||||
visible: bool = True
|
||||
|
||||
# References to DrawingCandidate keys this annotation is bound to.
|
||||
references: List[str] = field(default_factory=list)
|
||||
|
||||
# Sheet position (mm). For dimensions, anchor point; for notes, placement.
|
||||
sheet_position: Tuple[float, float] = (0.0, 0.0)
|
||||
|
||||
# Associated view id (empty means global/note block).
|
||||
view_id: str = ""
|
||||
|
||||
# Manual-dimension geometry in view-plane model coordinates (model
|
||||
# units in the view's projection plane). Populated for user-placed
|
||||
# dimensions, empty for reference-based annotations:
|
||||
# "length": (point_on_edge1, point_on_edge2) — closest points
|
||||
# "diameter": (left, right) — antipodal points across the centre
|
||||
# "angle": (vertex, arm1_point, arm2_point)
|
||||
anchors: List[Tuple[float, float]] = field(default_factory=list)
|
||||
|
||||
# Sub-kind of the manual dimension: "length" | "diameter" | "angle".
|
||||
# Empty for non-dimension annotations.
|
||||
dimension_kind: str = ""
|
||||
|
||||
# Unit vector along the measured distance in view-plane coordinates
|
||||
# (length dimensions only). The model→sheet transform is a uniform
|
||||
# scale + translation, so the direction is valid in sheet space too.
|
||||
direction: Optional[Tuple[float, float]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TechnicalDrawing:
|
||||
"""A complete technical drawing definition."""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str = "Untitled Drawing"
|
||||
|
||||
# Source geometry reference.
|
||||
source_kind: str = "component" # component, assembly
|
||||
source_id: str = ""
|
||||
|
||||
views: List[DrawingView] = field(default_factory=list)
|
||||
annotations: List[DrawingAnnotation] = field(default_factory=list)
|
||||
|
||||
# Title block metadata.
|
||||
title: str = ""
|
||||
part_number: str = ""
|
||||
material: str = ""
|
||||
revision: str = ""
|
||||
notes: str = ""
|
||||
|
||||
# Sheet size (A0..A4 or custom mm). Default A4 (ISO 5457 template).
|
||||
sheet_size: str = "A4"
|
||||
units: str = "mm" # mm, in
|
||||
|
||||
# Auto-generation flags. Auto dimensions are opt-in: the drawing
|
||||
# workbench shows them only while the user has the toggle enabled.
|
||||
auto_dimensions: bool = False
|
||||
auto_views: bool = True
|
||||
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
modified_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Rendering module."""
|
||||
|
||||
# OCC/OpenGL viewport renderers
|
||||
from fluency.rendering.base import (
|
||||
Renderer,
|
||||
RenderObject,
|
||||
@@ -8,10 +9,22 @@ from fluency.rendering.base import (
|
||||
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
|
||||
from fluency.rendering.occ_renderer import OCCRenderer, OCCRenderObject
|
||||
|
||||
# Photorealistic render backends
|
||||
from fluency.rendering.render_backend import (
|
||||
RenderBackend,
|
||||
RenderMaterial,
|
||||
RenderCamera,
|
||||
RenderSettings,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Renderer",
|
||||
"RenderObject",
|
||||
"RenderColor",
|
||||
"PygfxRenderer",
|
||||
"PygfxRenderObject",
|
||||
"RenderBackend",
|
||||
"RenderMaterial",
|
||||
"RenderCamera",
|
||||
"RenderSettings",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Material presets for the render backend.
|
||||
|
||||
Each preset is a RenderMaterial with physically-plausible values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from .render_backend import RenderMaterial
|
||||
|
||||
# ── Preset library ──────────────────────────────────────────────────────
|
||||
# Note: Mitsuba pip installs don't include spectral metal data files,
|
||||
# so metal_preset is not used. Instead, metals use material="none" with
|
||||
# specular_reflectance set to the metal color.
|
||||
|
||||
PRESETS: Dict[str, RenderMaterial] = {
|
||||
# ── Metals ──────────────────────────────────────────────────────
|
||||
"Brushed Steel": RenderMaterial(
|
||||
name="Brushed Steel",
|
||||
color=(0.65, 0.67, 0.72),
|
||||
metallic=0.9,
|
||||
roughness=0.35,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
"Polished Chrome": RenderMaterial(
|
||||
name="Polished Chrome",
|
||||
color=(0.8, 0.8, 0.8),
|
||||
metallic=1.0,
|
||||
roughness=0.05,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
"Brushed Aluminum": RenderMaterial(
|
||||
name="Brushed Aluminum",
|
||||
color=(0.75, 0.75, 0.75),
|
||||
metallic=0.85,
|
||||
roughness=0.25,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
"Copper": RenderMaterial(
|
||||
name="Copper",
|
||||
color=(0.95, 0.64, 0.54),
|
||||
metallic=0.95,
|
||||
roughness=0.15,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
"Gold": RenderMaterial(
|
||||
name="Gold",
|
||||
color=(1.0, 0.76, 0.33),
|
||||
metallic=1.0,
|
||||
roughness=0.1,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
"Blackened Steel": RenderMaterial(
|
||||
name="Blackened Steel",
|
||||
color=(0.15, 0.15, 0.17),
|
||||
metallic=0.8,
|
||||
roughness=0.4,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
# ── Plastics ────────────────────────────────────────────────────
|
||||
"Matte Plastic": RenderMaterial(
|
||||
name="Matte Plastic",
|
||||
color=(0.2, 0.5, 0.8),
|
||||
metallic=0.0,
|
||||
roughness=0.6,
|
||||
bsdf_type="plastic",
|
||||
int_ior=1.5,
|
||||
),
|
||||
"Glossy Plastic": RenderMaterial(
|
||||
name="Glossy Plastic",
|
||||
color=(0.2, 0.5, 0.8),
|
||||
metallic=0.0,
|
||||
roughness=0.1,
|
||||
bsdf_type="plastic",
|
||||
int_ior=1.5,
|
||||
),
|
||||
"White Nylon": RenderMaterial(
|
||||
name="White Nylon",
|
||||
color=(0.85, 0.85, 0.83),
|
||||
metallic=0.0,
|
||||
roughness=0.45,
|
||||
bsdf_type="plastic",
|
||||
int_ior=1.53,
|
||||
),
|
||||
"Black ABS": RenderMaterial(
|
||||
name="Black ABS",
|
||||
color=(0.05, 0.05, 0.05),
|
||||
metallic=0.0,
|
||||
roughness=0.35,
|
||||
bsdf_type="plastic",
|
||||
int_ior=1.54,
|
||||
),
|
||||
"Red PA12": RenderMaterial(
|
||||
name="Red PA12",
|
||||
color=(0.75, 0.08, 0.08),
|
||||
metallic=0.0,
|
||||
roughness=0.4,
|
||||
bsdf_type="plastic",
|
||||
int_ior=1.53,
|
||||
),
|
||||
# ── Other ───────────────────────────────────────────────────────
|
||||
"Rubber": RenderMaterial(
|
||||
name="Rubber",
|
||||
color=(0.1, 0.1, 0.1),
|
||||
metallic=0.0,
|
||||
roughness=0.9,
|
||||
bsdf_type="diffuse",
|
||||
),
|
||||
"Ceramic White": RenderMaterial(
|
||||
name="Ceramic White",
|
||||
color=(0.92, 0.91, 0.88),
|
||||
metallic=0.0,
|
||||
roughness=0.15,
|
||||
bsdf_type="dielectric",
|
||||
int_ior=1.55,
|
||||
),
|
||||
"Glass": RenderMaterial(
|
||||
name="Glass",
|
||||
color=(0.95, 0.95, 0.95),
|
||||
metallic=0.0,
|
||||
roughness=0.0,
|
||||
bsdf_type="dielectric",
|
||||
int_ior=1.52,
|
||||
),
|
||||
"Wood": RenderMaterial(
|
||||
name="Wood",
|
||||
color=(0.6, 0.4, 0.2),
|
||||
metallic=0.0,
|
||||
roughness=0.7,
|
||||
bsdf_type="diffuse",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_preset(name: str) -> RenderMaterial:
|
||||
"""Get a material preset by name. Falls back to default if not found."""
|
||||
if name in PRESETS:
|
||||
return PRESETS[name]
|
||||
return default_material()
|
||||
|
||||
|
||||
def default_material() -> RenderMaterial:
|
||||
"""Return the default grey material."""
|
||||
return RenderMaterial(
|
||||
name="Default",
|
||||
color=(0.7, 0.7, 0.7),
|
||||
metallic=0.0,
|
||||
roughness=0.5,
|
||||
bsdf_type="diffuse",
|
||||
)
|
||||
|
||||
|
||||
def preset_names() -> List[str]:
|
||||
"""Return sorted list of available preset names."""
|
||||
return sorted(PRESETS.keys())
|
||||
@@ -0,0 +1,425 @@
|
||||
"""Mitsuba 3 photorealistic render backend.
|
||||
|
||||
Requires: ``pip install mitsuba``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .render_backend import RenderBackend, RenderCamera, RenderMaterial, RenderSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MitsubaBackend(RenderBackend):
|
||||
"""Render backend using Mitsuba 3 path tracer."""
|
||||
|
||||
def name(self) -> str:
|
||||
return "Mitsuba 3"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
import sys
|
||||
import io
|
||||
|
||||
old_stderr = sys.stderr
|
||||
sys.stderr = io.StringIO()
|
||||
try:
|
||||
import mitsuba # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
|
||||
# ── Scene construction ──────────────────────────────────────────
|
||||
|
||||
def _base_scene_dict(
|
||||
self,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
first_mesh_path: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Return a scene dict with everything *except* the shape entries.
|
||||
|
||||
When *first_mesh_path* is given the ground plane / backdrop is sized
|
||||
from its bounding box; otherwise a large default is used.
|
||||
"""
|
||||
import mitsuba as mi
|
||||
|
||||
lighting = settings.lighting
|
||||
ground = settings.ground_plane
|
||||
|
||||
cam_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=list(camera.origin),
|
||||
target=list(camera.target),
|
||||
up=list(camera.up),
|
||||
)
|
||||
|
||||
scene: dict = {
|
||||
"type": "scene",
|
||||
"integrator": {"type": "path", "max_depth": settings.max_depth},
|
||||
"sensor": {
|
||||
"type": "perspective",
|
||||
"fov": camera.fov,
|
||||
"to_world": cam_to_world,
|
||||
"film": {
|
||||
"type": "hdrfilm",
|
||||
"width": settings.width,
|
||||
"height": settings.height,
|
||||
"rfilter": {"type": "gaussian"},
|
||||
},
|
||||
"sampler": {
|
||||
"type": "independent",
|
||||
"sample_count": settings.spp,
|
||||
},
|
||||
},
|
||||
"emitter": {
|
||||
"type": "constant",
|
||||
"radiance": {
|
||||
"type": "rgb",
|
||||
"value": [
|
||||
lighting.ambient_intensity,
|
||||
lighting.ambient_intensity * 0.97,
|
||||
lighting.ambient_intensity * 0.94,
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# ── 3-point lighting ──────────────────────────────────────────
|
||||
key_rgb = [c * lighting.key_intensity for c in lighting.key_color]
|
||||
scene["key_light"] = {
|
||||
"type": "directional",
|
||||
"to_world": mi.ScalarTransform4f.look_at(
|
||||
origin=[1.0, -0.8, 1.2],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
up=[0.0, 0.0, 1.0],
|
||||
),
|
||||
"irradiance": {"type": "rgb", "value": key_rgb},
|
||||
}
|
||||
|
||||
fill_rgb = [c * lighting.fill_intensity for c in lighting.fill_color]
|
||||
scene["fill_light"] = {
|
||||
"type": "directional",
|
||||
"to_world": mi.ScalarTransform4f.look_at(
|
||||
origin=[-1.0, 0.6, 0.8],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
up=[0.0, 0.0, 1.0],
|
||||
),
|
||||
"irradiance": {"type": "rgb", "value": fill_rgb},
|
||||
}
|
||||
|
||||
rim_rgb = [c * lighting.rim_intensity for c in lighting.rim_color]
|
||||
scene["rim_light"] = {
|
||||
"type": "directional",
|
||||
"to_world": mi.ScalarTransform4f.look_at(
|
||||
origin=[-0.3, 1.2, -0.8],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
up=[0.0, 0.0, 1.0],
|
||||
),
|
||||
"irradiance": {"type": "rgb", "value": rim_rgb},
|
||||
}
|
||||
|
||||
# ── Ground plane / backdrop ───────────────────────────────────
|
||||
if ground.enabled:
|
||||
try:
|
||||
ext = os.path.splitext(first_mesh_path)[1].lower() if first_mesh_path else ""
|
||||
shape_type = "ply" if ext == ".ply" else "obj"
|
||||
mesh_shape = mi.load_dict({"type": shape_type, "filename": first_mesh_path})
|
||||
bbox = mesh_shape.bbox()
|
||||
bbox_min, bbox_max = bbox[0], bbox[1]
|
||||
model_height = bbox_max[2] - bbox_min[2]
|
||||
ground_z = bbox_min[2] - 0.001 * model_height
|
||||
dx = bbox_max[0] - bbox_min[0]
|
||||
dy = bbox_max[1] - bbox_min[1]
|
||||
dz = bbox_max[2] - bbox_min[2]
|
||||
diag = float((dx * dx + dy * dy + dz * dz) ** 0.5)
|
||||
except Exception:
|
||||
ground_z = -ground.distance_below
|
||||
diag = 1000.0
|
||||
|
||||
bsdf_ground = {
|
||||
"type": "diffuse",
|
||||
"reflectance": {"type": "rgb", "value": list(ground.color)},
|
||||
}
|
||||
|
||||
if ground.curved_backdrop:
|
||||
half_size = diag * 50.0
|
||||
radius = diag * 3.0
|
||||
cyl_height = diag * 20.0
|
||||
scene["ground_floor"] = {
|
||||
"type": "rectangle",
|
||||
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
|
||||
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
|
||||
"bsdf": bsdf_ground,
|
||||
}
|
||||
scene["ground_backdrop"] = {
|
||||
"type": "cylinder",
|
||||
"radius": radius,
|
||||
"p0": [-cyl_height / 2, -radius, ground_z],
|
||||
"p1": [cyl_height / 2, -radius, ground_z],
|
||||
"to_world": mi.ScalarTransform4f.rotate([1, 0, 0], 90)
|
||||
@ mi.ScalarTransform4f.translate([0.0, 0.0, -radius]),
|
||||
"bsdf": bsdf_ground,
|
||||
}
|
||||
else:
|
||||
half_size = diag * 50.0
|
||||
scene["ground_plane"] = {
|
||||
"type": "rectangle",
|
||||
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
|
||||
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
|
||||
"bsdf": bsdf_ground,
|
||||
}
|
||||
|
||||
return scene
|
||||
|
||||
def _build_scene_dict(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
) -> dict:
|
||||
"""Build a single-shape Mitsuba scene dictionary."""
|
||||
ext = os.path.splitext(mesh_path)[1].lower()
|
||||
shape_type = "ply" if ext == ".ply" else "obj"
|
||||
|
||||
scene = self._base_scene_dict(camera, settings, first_mesh_path=mesh_path)
|
||||
scene["shape"] = {
|
||||
"type": shape_type,
|
||||
"filename": mesh_path,
|
||||
"bsdf": self._make_bsdf(material),
|
||||
}
|
||||
return scene
|
||||
|
||||
def _build_assembly_scene_dict(
|
||||
self,
|
||||
parts: list,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
) -> dict:
|
||||
"""Build a multi-shape Mitsuba scene dictionary.
|
||||
|
||||
*parts* is a list of ``(mesh_path, RenderMaterial)`` tuples.
|
||||
"""
|
||||
first_path = parts[0][0] if parts else None
|
||||
scene = self._base_scene_dict(camera, settings, first_mesh_path=first_path)
|
||||
|
||||
for i, (mesh_path, material) in enumerate(parts):
|
||||
ext = os.path.splitext(mesh_path)[1].lower()
|
||||
shape_type = "ply" if ext == ".ply" else "obj"
|
||||
scene[f"shape_{i}"] = {
|
||||
"type": shape_type,
|
||||
"filename": mesh_path,
|
||||
"bsdf": self._make_bsdf(material),
|
||||
}
|
||||
return scene
|
||||
|
||||
def _make_bsdf(self, material: RenderMaterial) -> dict:
|
||||
"""Convert a RenderMaterial to a Mitsuba BSDF dict."""
|
||||
mt = material.bsdf_type
|
||||
|
||||
if mt == "roughconductor":
|
||||
# Use material="none" with specular_reflectance set to the
|
||||
# metal color. The pip-installed Mitsuba doesn't include
|
||||
# spectral metal data files (iron.spd, copper.spd, etc.).
|
||||
return {
|
||||
"type": "roughconductor",
|
||||
"material": "none",
|
||||
"alpha": max(material.roughness, 0.01),
|
||||
"specular_reflectance": {
|
||||
"type": "rgb",
|
||||
"value": list(material.color),
|
||||
},
|
||||
}
|
||||
|
||||
if mt == "roughdielectric":
|
||||
return {
|
||||
"type": "roughdielectric",
|
||||
"int_ior": material.int_ior,
|
||||
"ext_ior": 1.0,
|
||||
"alpha": max(material.roughness, 0.01),
|
||||
}
|
||||
|
||||
if mt == "dielectric":
|
||||
return {
|
||||
"type": "dielectric",
|
||||
"int_ior": material.int_ior,
|
||||
"ext_ior": 1.0,
|
||||
}
|
||||
|
||||
if mt == "plastic":
|
||||
return {
|
||||
"type": "plastic",
|
||||
"diffuse_reflectance": {
|
||||
"type": "rgb",
|
||||
"value": list(material.color),
|
||||
},
|
||||
"int_ior": material.int_ior,
|
||||
}
|
||||
|
||||
# Default: diffuse
|
||||
return {
|
||||
"type": "diffuse",
|
||||
"reflectance": {
|
||||
"type": "rgb",
|
||||
"value": list(material.color),
|
||||
},
|
||||
}
|
||||
|
||||
# ── Rendering ───────────────────────────────────────────────────
|
||||
|
||||
def render(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
progress_callback: Optional[Callable[[float], None]] = None,
|
||||
) -> np.ndarray:
|
||||
"""Render a mesh file and return (H, W, 3) float32 RGB array."""
|
||||
self._set_variant()
|
||||
import mitsuba as mi
|
||||
|
||||
scene_dict = self._build_scene_dict(mesh_path, material, camera, settings)
|
||||
scene = mi.load_dict(scene_dict)
|
||||
|
||||
logger.info(f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp")
|
||||
|
||||
try:
|
||||
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0))
|
||||
except Exception as e:
|
||||
logger.error(f"Mitsuba render failed: {e}")
|
||||
raise
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(1.0)
|
||||
|
||||
arr = np.array(image, dtype=np.float32)
|
||||
arr = np.clip(arr, 0.0, None)
|
||||
arr = np.power(arr, 1.0 / 2.2)
|
||||
arr = np.clip(arr, 0.0, 1.0)
|
||||
return arr
|
||||
|
||||
def render_assembly(
|
||||
self,
|
||||
parts: list,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
progress_callback: Optional[Callable[[float], None]] = None,
|
||||
) -> np.ndarray:
|
||||
"""Render multiple meshes with individual materials.
|
||||
|
||||
*parts* is a list of ``(mesh_path, RenderMaterial)`` tuples.
|
||||
Returns (H, W, 3) float32 RGB array.
|
||||
"""
|
||||
self._set_variant()
|
||||
import mitsuba as mi
|
||||
|
||||
scene_dict = self._build_assembly_scene_dict(parts, camera, settings)
|
||||
scene = mi.load_dict(scene_dict)
|
||||
|
||||
logger.info(
|
||||
f"Rendering assembly ({len(parts)} parts) "
|
||||
f"{settings.width}x{settings.height} @ {settings.spp} spp"
|
||||
)
|
||||
|
||||
try:
|
||||
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0))
|
||||
except Exception as e:
|
||||
logger.error(f"Mitsuba assembly render failed: {e}")
|
||||
raise
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(1.0)
|
||||
|
||||
arr = np.array(image, dtype=np.float32)
|
||||
arr = np.clip(arr, 0.0, None)
|
||||
arr = np.power(arr, 1.0 / 2.2)
|
||||
arr = np.clip(arr, 0.0, 1.0)
|
||||
return arr
|
||||
|
||||
def render_preview(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
) -> np.ndarray:
|
||||
"""Quick low-quality preview (4x fewer spp)."""
|
||||
preview_settings = RenderSettings(
|
||||
width=settings.width // 2,
|
||||
height=settings.height // 2,
|
||||
spp=max(settings.spp // 4, 16),
|
||||
max_depth=min(settings.max_depth, 4),
|
||||
seed=settings.seed,
|
||||
lighting=settings.lighting,
|
||||
ground_plane=settings.ground_plane,
|
||||
)
|
||||
return self.render(mesh_path, material, camera, preview_settings)
|
||||
|
||||
# ── Export ──────────────────────────────────────────────────────
|
||||
|
||||
def export_image(self, image: np.ndarray, path: str) -> None:
|
||||
"""Save a rendered image to PNG or EXR."""
|
||||
from PIL import Image
|
||||
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
|
||||
if ext == ".exr":
|
||||
# Save as EXR (HDR) — no tonemapping
|
||||
try:
|
||||
import OpenEXR # type: ignore[import-not-found]
|
||||
import Imath # type: ignore[import-not-found]
|
||||
|
||||
h, w = image.shape[:2]
|
||||
header = OpenEXR.Header(w, h)
|
||||
header["channels"] = {
|
||||
"R": Imath.PixelType(Imath.PixelType.FLOAT),
|
||||
"G": Imath.PixelType(Imath.PixelType.FLOAT),
|
||||
"B": Imath.PixelType(Imath.PixelType.FLOAT),
|
||||
}
|
||||
exr = OpenEXR.OutputFile(path, header)
|
||||
exr.write(
|
||||
{
|
||||
"R": image[:, :, 0].tobytes(),
|
||||
"G": image[:, :, 1].tobytes(),
|
||||
"B": image[:, :, 2].tobytes(),
|
||||
}
|
||||
)
|
||||
exr.close()
|
||||
except ImportError:
|
||||
# Fallback: save as 16-bit PNG
|
||||
logger.warning("OpenEXR not available, saving as 16-bit PNG")
|
||||
img = Image.fromarray((image * 65535).astype(np.uint16), "RGB")
|
||||
img.save(path)
|
||||
else:
|
||||
# PNG / JPEG — already tonemapped
|
||||
img = Image.fromarray((image * 255).astype(np.uint8), "RGB")
|
||||
img.save(path)
|
||||
|
||||
logger.info(f"Exported render to {path}")
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _set_variant(self) -> None:
|
||||
"""Set the Mitsuba variant (called once)."""
|
||||
import sys
|
||||
import io
|
||||
|
||||
# Suppress the harmless "LLVM API initialization failed" warning
|
||||
# that drjit emits on macOS ARM when scalar variant is used.
|
||||
old_stderr = sys.stderr
|
||||
sys.stderr = io.StringIO()
|
||||
try:
|
||||
import mitsuba as mi
|
||||
|
||||
mi.set_variant("scalar_rgb")
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
+1527
-224
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
||||
"""Convert OCC BRep shapes to mesh files for render backends.
|
||||
|
||||
Outputs PLY files (preferred by Mitsuba) or STL files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def occ_shape_to_ply(
|
||||
shape,
|
||||
output_path: Optional[str] = None,
|
||||
linear_deflection: float = 0.1,
|
||||
angular_deflection: float = 0.15,
|
||||
) -> str:
|
||||
"""Tessellate an OCC TopoDS_Shape and write as PLY.
|
||||
|
||||
Returns the path to the written PLY file.
|
||||
"""
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
|
||||
# Tessellate
|
||||
tess = BRepMesh_IncrementalMesh(
|
||||
shape, linear_deflection, False, angular_deflection, True
|
||||
)
|
||||
tess.Perform()
|
||||
|
||||
# Extract triangulation from all faces
|
||||
all_vertices: List[List[float]] = []
|
||||
all_faces: List[List[int]] = []
|
||||
vertex_offset = 0
|
||||
|
||||
from OCP.TopAbs import TopAbs_FORWARD
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while explorer.More():
|
||||
face = TopoDS.Face_s(explorer.Current())
|
||||
location = TopLoc_Location()
|
||||
triangulation = BRep_Tool.Triangulation_s(face, location)
|
||||
|
||||
if triangulation is None:
|
||||
explorer.Next()
|
||||
continue
|
||||
|
||||
# Transform
|
||||
trsf = location.Transformation()
|
||||
|
||||
# Check face orientation: FORWARD means the surface normal points
|
||||
# outward from the solid; REVERSED means it points inward.
|
||||
is_forward = (face.Orientation() == TopAbs_FORWARD)
|
||||
|
||||
# Extract vertices (apply location transform to positions)
|
||||
nb_nodes = triangulation.NbNodes()
|
||||
for i in range(1, nb_nodes + 1):
|
||||
node = triangulation.Node(i)
|
||||
pnt = node.Transformed(trsf)
|
||||
all_vertices.append([pnt.X(), pnt.Y(), pnt.Z()])
|
||||
|
||||
# Extract triangles
|
||||
# For REVERSED faces, swap winding order (n1, n3, n2) so that
|
||||
# the computed normal points outward consistently.
|
||||
nb_triangles = triangulation.NbTriangles()
|
||||
for i in range(1, nb_triangles + 1):
|
||||
tri = triangulation.Triangle(i)
|
||||
n1, n2, n3 = tri.Get()
|
||||
if is_forward:
|
||||
all_faces.append([
|
||||
n1 - 1 + vertex_offset,
|
||||
n2 - 1 + vertex_offset,
|
||||
n3 - 1 + vertex_offset,
|
||||
])
|
||||
else:
|
||||
# Swap winding for REVERSED faces
|
||||
all_faces.append([
|
||||
n1 - 1 + vertex_offset,
|
||||
n3 - 1 + vertex_offset,
|
||||
n2 - 1 + vertex_offset,
|
||||
])
|
||||
|
||||
vertex_offset += nb_nodes
|
||||
explorer.Next()
|
||||
|
||||
if not all_vertices:
|
||||
raise ValueError("Tessellation produced no vertices")
|
||||
|
||||
vertices = np.array(all_vertices, dtype=np.float32)
|
||||
faces = np.array(all_faces, dtype=np.uint32)
|
||||
|
||||
logger.info(
|
||||
f"Tessellation: {len(vertices)} vertices, {len(faces)} triangles"
|
||||
)
|
||||
|
||||
# Compute smooth vertex normals from face normals.
|
||||
# Winding is already corrected during tessellation using OCC face orientation.
|
||||
normals, corrected_faces = _compute_outward_normals(vertices, faces, shape)
|
||||
|
||||
# Write PLY with corrected faces and normals
|
||||
if output_path is None:
|
||||
fd, output_path = tempfile.mkstemp(suffix=".ply", prefix="fluency_render_")
|
||||
os.close(fd)
|
||||
|
||||
_write_ply(output_path, vertices, corrected_faces, normals)
|
||||
logger.info(f"Wrote PLY: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def _compute_outward_normals(
|
||||
vertices: np.ndarray,
|
||||
faces: np.ndarray,
|
||||
shape,
|
||||
) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""Compute outward-facing vertex normals and correct face winding.
|
||||
|
||||
The winding is already corrected during tessellation using OCC's face
|
||||
orientation (TopAbs_FORWARD/REVERSED). This function computes smooth
|
||||
vertex normals by averaging face normals at shared vertices.
|
||||
|
||||
Returns (normals, corrected_faces) for PLY export.
|
||||
"""
|
||||
n_verts = len(vertices)
|
||||
v_normals = np.zeros((n_verts, 3), dtype=np.float64)
|
||||
|
||||
# Ensure faces is 2D (numpy creates (3,) for single-face meshes)
|
||||
if faces.ndim == 1:
|
||||
faces = faces.reshape(1, -1)
|
||||
|
||||
# Winding is already correct from tessellation (face orientation check).
|
||||
# Just compute face normals and accumulate to vertices.
|
||||
v0 = vertices[faces[:, 0]]
|
||||
v1 = vertices[faces[:, 1]]
|
||||
v2 = vertices[faces[:, 2]]
|
||||
|
||||
edge1 = v1 - v0
|
||||
edge2 = v2 - v0
|
||||
fn = np.cross(edge1, edge2)
|
||||
|
||||
# Normalize face normals
|
||||
lengths = np.linalg.norm(fn, axis=1, keepdims=True)
|
||||
lengths[lengths < 1e-10] = 1.0
|
||||
fn /= lengths
|
||||
|
||||
# Accumulate to vertices
|
||||
for i in range(len(faces)):
|
||||
idx = faces[i]
|
||||
v_normals[idx[0]] += fn[i]
|
||||
v_normals[idx[1]] += fn[i]
|
||||
v_normals[idx[2]] += fn[i]
|
||||
|
||||
# Normalize vertex normals
|
||||
v_lengths = np.linalg.norm(v_normals, axis=1, keepdims=True)
|
||||
v_lengths[v_lengths < 1e-10] = 1.0
|
||||
v_normals /= v_lengths
|
||||
|
||||
return v_normals.astype(np.float32), faces.astype(np.uint32)
|
||||
|
||||
|
||||
def occ_shape_to_stl(
|
||||
shape,
|
||||
output_path: Optional[str] = None,
|
||||
linear_deflection: float = 0.1,
|
||||
) -> str:
|
||||
"""Tessellate an OCC TopoDS_Shape and write as binary STL.
|
||||
|
||||
Returns the path to the written STL file.
|
||||
"""
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.StlAPI import StlAPI_Writer
|
||||
|
||||
# Tessellate
|
||||
tess = BRepMesh_IncrementalMesh(shape, linear_deflection, False, 0.5, True)
|
||||
tess.Perform()
|
||||
|
||||
if output_path is None:
|
||||
fd, output_path = tempfile.mkstemp(suffix=".stl", prefix="fluency_render_")
|
||||
os.close(fd)
|
||||
|
||||
writer = StlAPI_Writer()
|
||||
writer.SetASCIIMode(False)
|
||||
writer.Write(shape, output_path)
|
||||
logger.info(f"Wrote STL: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def occ_shape_bounds(shape) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]:
|
||||
"""Return (min_xyz, max_xyz) bounding box of an OCC shape."""
|
||||
from OCP.Bnd import Bnd_Box
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
|
||||
bbox = Bnd_Box()
|
||||
BRepBndLib.Add_s(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
return (xmin, ymin, zmin), (xmax, ymax, zmax)
|
||||
|
||||
|
||||
def _write_ply(
|
||||
path: str,
|
||||
vertices: np.ndarray,
|
||||
faces: np.ndarray,
|
||||
normals: Optional[np.ndarray] = None,
|
||||
) -> None:
|
||||
"""Write a binary PLY file (little-endian) with optional vertex normals."""
|
||||
import struct
|
||||
|
||||
n_verts = len(vertices)
|
||||
n_faces = len(faces)
|
||||
has_normals = normals is not None and len(normals) == n_verts
|
||||
|
||||
with open(path, "wb") as f:
|
||||
# Header
|
||||
header_lines = [
|
||||
"ply",
|
||||
"format binary_little_endian 1.0",
|
||||
f"element vertex {n_verts}",
|
||||
"property float x",
|
||||
"property float y",
|
||||
"property float z",
|
||||
]
|
||||
if has_normals:
|
||||
header_lines.extend([
|
||||
"property float nx",
|
||||
"property float ny",
|
||||
"property float nz",
|
||||
])
|
||||
header_lines.append(f"element face {n_faces}")
|
||||
header_lines.append("property list uchar int vertex_indices")
|
||||
header_lines.append("end_header")
|
||||
|
||||
f.write(("\n".join(header_lines) + "\n").encode("ascii"))
|
||||
|
||||
# Vertex positions (+ normals if available)
|
||||
for i in range(n_verts):
|
||||
f.write(struct.pack("<fff", vertices[i, 0], vertices[i, 1], vertices[i, 2]))
|
||||
if has_normals:
|
||||
f.write(struct.pack("<fff", normals[i, 0], normals[i, 1], normals[i, 2]))
|
||||
|
||||
# Faces
|
||||
for face in faces:
|
||||
f.write(struct.pack("<B", 3))
|
||||
f.write(struct.pack("<iii", int(face[0]), int(face[1]), int(face[2])))
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Abstract render backend interface.
|
||||
|
||||
Any photorealistic renderer (Mitsuba, Blender, Cycles, ...) implements
|
||||
:class:`RenderBackend`. The UI only talks to this ABC so backends can be
|
||||
swapped by changing one import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderMaterial:
|
||||
"""PBR material description for the render backend."""
|
||||
|
||||
name: str = "Default"
|
||||
color: tuple[float, float, float] = (0.7, 0.7, 0.7)
|
||||
metallic: float = 0.0 # 0.0 = dielectric, 1.0 = metal
|
||||
roughness: float = 0.5 # 0.0 = mirror, 1.0 = fully rough
|
||||
bsdf_type: str = "diffuse" # diffuse | roughconductor | roughdielectric | plastic
|
||||
# Optional: named metal preset (copper, aluminium, gold, chrome, steel)
|
||||
metal_preset: Optional[str] = None
|
||||
# For dielectric / plastic
|
||||
int_ior: float = 1.5
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderCamera:
|
||||
"""Camera parameters for the render."""
|
||||
|
||||
origin: tuple[float, float, float] = (100.0, 100.0, 100.0)
|
||||
target: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
up: tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||
fov: float = 60.0 # vertical field of view in degrees
|
||||
|
||||
|
||||
@dataclass
|
||||
class LightingConfig:
|
||||
"""Lighting configuration for the render scene."""
|
||||
|
||||
ambient_intensity: float = 0.3 # constant environment fill [0..1]
|
||||
key_color: tuple[float, float, float] = (1.0, 0.98, 0.95) # RGB key light color
|
||||
key_intensity: float = 3.5 # key light irradiance multiplier
|
||||
fill_color: tuple[float, float, float] = (0.92, 0.94, 1.0) # RGB fill light color
|
||||
fill_intensity: float = 1.5 # fill light irradiance multiplier
|
||||
rim_color: tuple[float, float, float] = (1.0, 0.98, 0.96) # RGB rim light color
|
||||
rim_intensity: float = 1.2 # rim light irradiance multiplier
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroundPlaneConfig:
|
||||
"""Ground plane configuration for the render scene."""
|
||||
|
||||
enabled: bool = False
|
||||
color: tuple[float, float, float] = (0.5, 0.5, 0.5) # RGB diffuse color
|
||||
roughness: float = 0.8 # surface roughness [0..1]
|
||||
distance_below: float = 0.0 # mm below origin (positive = below)
|
||||
curved_backdrop: bool = False # photo booth style curved leinwand
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderSettings:
|
||||
"""Quality / resolution settings."""
|
||||
|
||||
width: int = 1920
|
||||
height: int = 1080
|
||||
spp: int = 256 # samples per pixel
|
||||
max_depth: int = 8 # max bounces for path tracer
|
||||
seed: int = 0 # random seed (0 = auto)
|
||||
lighting: LightingConfig = field(default_factory=LightingConfig)
|
||||
ground_plane: GroundPlaneConfig = field(default_factory=GroundPlaneConfig)
|
||||
|
||||
|
||||
class RenderBackend(ABC):
|
||||
"""Abstract photorealistic renderer.
|
||||
|
||||
Implementations live in separate modules so backends can be swapped
|
||||
without touching the UI. Typical call::
|
||||
|
||||
backend = MitsubaBackend()
|
||||
image = backend.render(obj_path, material, camera, settings)
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Human-readable backend name (shown in UI)."""
|
||||
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool:
|
||||
"""Return True if this backend's dependencies are installed."""
|
||||
|
||||
@abstractmethod
|
||||
def render(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
progress_callback=None,
|
||||
) -> "np.ndarray":
|
||||
"""Render a mesh file and return an (H, W, 3) float32 RGB array.
|
||||
|
||||
*mesh_path* is an STL or OBJ file on disk.
|
||||
*progress_callback(fraction)* is called with 0.0–1.0 progress.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def render_preview(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
) -> "np.ndarray":
|
||||
"""Quick low-quality preview (fewer spp)."""
|
||||
|
||||
@abstractmethod
|
||||
def export_image(self, image: "np.ndarray", path: str) -> None:
|
||||
"""Save a rendered image to PNG / EXR."""
|
||||
|
||||
def default_camera_from_bounds(
|
||||
self, bounds_min: tuple[float, float, float], bounds_max: tuple[float, float, float]
|
||||
) -> RenderCamera:
|
||||
"""Compute a sensible default camera looking at the bbox centre."""
|
||||
import numpy as np
|
||||
|
||||
mn = np.asarray(bounds_min, dtype=float)
|
||||
mx = np.asarray(bounds_max, dtype=float)
|
||||
centre = (mn + mx) / 2.0
|
||||
diag = float(np.linalg.norm(mx - mn))
|
||||
# Place camera at iso-ish position, far enough to see everything.
|
||||
eye = centre + np.array([0.7, -0.7, 0.5]) * diag * 0.8
|
||||
return RenderCamera(
|
||||
origin=tuple(eye.tolist()),
|
||||
target=tuple(centre.tolist()),
|
||||
up=(0.0, 0.0, 1.0),
|
||||
fov=45.0,
|
||||
)
|
||||
@@ -187,19 +187,29 @@ class SolverSketch(SolverSystem):
|
||||
def constrain_distance(
|
||||
self, entity_a, entity_b, distance: float
|
||||
) -> 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:
|
||||
handle_a = entity_a.handle if isinstance(entity_a, SolverPoint) else entity_a.handle
|
||||
handle_b = entity_b.handle if isinstance(entity_b, SolverPoint) else entity_b.handle
|
||||
if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
|
||||
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):
|
||||
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):
|
||||
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:
|
||||
logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}")
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
Undo/Redo manager for OCCSketch using snapshot-based approach.
|
||||
|
||||
python_solvespace has no per-entity delete API — the solver is rebuilt from
|
||||
scratch after every modification. This makes snapshot-based undo natural:
|
||||
we capture the complete sketch state (entities, geometry, constraints) and
|
||||
restore it by rebuilding the solver from the snapshot data.
|
||||
|
||||
Each snapshot is ~10-50 KB depending on sketch complexity, and we cap the
|
||||
stack at a configurable depth (default 50).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SketchSnapshot:
|
||||
"""Immutable snapshot of sketch state for undo/redo.
|
||||
|
||||
Captures everything needed to fully reconstruct an OCCSketch:
|
||||
entities, points, lines, circles, arcs, counter, constraint log,
|
||||
and the special entity id sets (centerlines, external/underlay).
|
||||
"""
|
||||
|
||||
# Entity registry: id → (entity_type, geometry, is_construction, is_external, constraints_list)
|
||||
entities: Dict[int, Tuple[str, Any, bool, bool, List[str]]] = field(default_factory=dict)
|
||||
|
||||
# Geometry sub-indices
|
||||
points: Dict[int, Tuple[float, float]] = field(default_factory=dict)
|
||||
lines: Dict[int, Tuple[int, int]] = field(default_factory=dict)
|
||||
circles: Dict[int, Tuple[int, float]] = field(default_factory=dict)
|
||||
arcs: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
# Counters and flags
|
||||
entity_counter: int = 0
|
||||
constraint_count: int = 0
|
||||
first_point_id: Optional[int] = None
|
||||
|
||||
# Constraint replay log
|
||||
constraint_log: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# Special entity sets
|
||||
centerline_ids: Set[int] = field(default_factory=set)
|
||||
external_entity_ids: Set[int] = field(default_factory=set)
|
||||
|
||||
# Workplane (so undo doesn't lose the placement plane)
|
||||
wp_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
wp_normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||
wp_x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||
wp_y_dir: Tuple[float, float, float] = (0.0, 1.0, 0.0)
|
||||
|
||||
|
||||
class SketchUndoManager:
|
||||
"""Manages undo/redo stacks of SketchSnapshot for an OCCSketch.
|
||||
|
||||
Usage::
|
||||
|
||||
undo_mgr = SketchUndoManager(sketch)
|
||||
|
||||
# Before any modification:
|
||||
undo_mgr.save_state()
|
||||
|
||||
# ... perform modification ...
|
||||
|
||||
# Ctrl+Z:
|
||||
undo_mgr.undo()
|
||||
|
||||
# Ctrl+Y / Ctrl+Shift+Z:
|
||||
undo_mgr.redo()
|
||||
"""
|
||||
|
||||
def __init__(self, sketch: Any, max_stack_size: int = 50) -> None:
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
|
||||
self._sketch: OCCSketch = sketch
|
||||
self._max_stack_size = max_stack_size
|
||||
self._undo_stack: List[SketchSnapshot] = []
|
||||
self._redo_stack: List[SketchSnapshot] = []
|
||||
|
||||
# ─── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def can_undo(self) -> bool:
|
||||
"""True if there is a state to undo to."""
|
||||
return len(self._undo_stack) > 0
|
||||
|
||||
@property
|
||||
def can_redo(self) -> bool:
|
||||
"""True if there is a state to redo to."""
|
||||
return len(self._redo_stack) > 0
|
||||
|
||||
@property
|
||||
def undo_depth(self) -> int:
|
||||
"""Number of undo levels available."""
|
||||
return len(self._undo_stack)
|
||||
|
||||
@property
|
||||
def redo_depth(self) -> int:
|
||||
"""Number of redo levels available."""
|
||||
return len(self._redo_stack)
|
||||
|
||||
def save_state(self) -> None:
|
||||
"""Capture the current sketch state and push it onto the undo stack.
|
||||
|
||||
Call this **before** any modifying operation (draw, delete, move,
|
||||
constraint add/remove, construction toggle, etc.).
|
||||
|
||||
The redo stack is cleared whenever a new state is saved (i.e. when
|
||||
the user makes a new change after undoing).
|
||||
"""
|
||||
snapshot = self._capture()
|
||||
self._undo_stack.append(snapshot)
|
||||
|
||||
# Cap the stack size.
|
||||
if len(self._undo_stack) > self._max_stack_size:
|
||||
self._undo_stack.pop(0)
|
||||
|
||||
# New mutation invalidates the redo history.
|
||||
self._redo_stack.clear()
|
||||
|
||||
logger.debug(
|
||||
f"save_state: undo_depth={len(self._undo_stack)} "
|
||||
f"entities={len(snapshot.entities)}"
|
||||
)
|
||||
|
||||
def undo(self) -> bool:
|
||||
"""Restore the previous sketch state.
|
||||
|
||||
Returns True if a state was restored, False if the undo stack is empty.
|
||||
"""
|
||||
if not self._undo_stack:
|
||||
logger.debug("undo: stack empty")
|
||||
return False
|
||||
|
||||
# Save current state to redo stack before restoring.
|
||||
current_snapshot = self._capture()
|
||||
self._redo_stack.append(current_snapshot)
|
||||
|
||||
# Pop and restore.
|
||||
snapshot = self._undo_stack.pop()
|
||||
self._restore(snapshot)
|
||||
|
||||
logger.debug(
|
||||
f"undo: restored state with {len(snapshot.entities)} entities, "
|
||||
f"undo_depth={len(self._undo_stack)} redo_depth={len(self._redo_stack)}"
|
||||
)
|
||||
return True
|
||||
|
||||
def redo(self) -> bool:
|
||||
"""Re-apply the most recently undone state.
|
||||
|
||||
Returns True if a state was restored, False if the redo stack is empty.
|
||||
"""
|
||||
if not self._redo_stack:
|
||||
logger.debug("redo: stack empty")
|
||||
return False
|
||||
|
||||
# Save current state to undo stack before restoring.
|
||||
current_snapshot = self._capture()
|
||||
self._undo_stack.append(current_snapshot)
|
||||
|
||||
# Pop and restore.
|
||||
snapshot = self._redo_stack.pop()
|
||||
self._restore(snapshot)
|
||||
|
||||
logger.debug(
|
||||
f"redo: restored state with {len(snapshot.entities)} entities, "
|
||||
f"undo_depth={len(self._undo_stack)} redo_depth={len(self._redo_stack)}"
|
||||
)
|
||||
return True
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear both stacks (e.g. when loading a new sketch)."""
|
||||
self._undo_stack.clear()
|
||||
self._redo_stack.clear()
|
||||
logger.debug("undo stacks cleared")
|
||||
|
||||
# ─── Snapshot Capture ──────────────────────────────────────────────────
|
||||
|
||||
def _capture(self) -> SketchSnapshot:
|
||||
"""Capture the current sketch state into a snapshot."""
|
||||
sketch = self._sketch
|
||||
|
||||
# Capture entities: id → (type, geometry, is_construction, is_external, constraints)
|
||||
entities: Dict[int, Tuple[str, Any, bool, bool, List[str]]] = {}
|
||||
for eid, ent in sketch._entities.items():
|
||||
entities[eid] = (
|
||||
ent.entity_type,
|
||||
ent.geometry,
|
||||
ent.is_construction,
|
||||
ent.is_external,
|
||||
list(ent.constraints), # copy the constraints list
|
||||
)
|
||||
|
||||
# Deep copy the mutable dicts (points coords are tuples, so shallow is fine,
|
||||
# but arcs contain dicts so we deep-copy those).
|
||||
points = dict(sketch._points)
|
||||
lines = dict(sketch._lines)
|
||||
circles = dict(sketch._circles)
|
||||
arcs = {k: copy.deepcopy(v) for k, v in sketch._arcs.items()}
|
||||
|
||||
# Constraint log entries contain tuples and sets — need careful copy.
|
||||
constraint_log = []
|
||||
for entry in sketch._constraint_log:
|
||||
copied = {
|
||||
"type": entry["type"],
|
||||
"ids": tuple(entry["ids"]),
|
||||
"params": tuple(entry["params"]) if entry.get("params") else (),
|
||||
"labels": set(entry["labels"]) if entry.get("labels") else set(),
|
||||
}
|
||||
constraint_log.append(copied)
|
||||
|
||||
return SketchSnapshot(
|
||||
entities=entities,
|
||||
points=points,
|
||||
lines=lines,
|
||||
circles=circles,
|
||||
arcs=arcs,
|
||||
entity_counter=sketch._entity_counter,
|
||||
constraint_count=sketch._constraint_count,
|
||||
first_point_id=sketch._first_point_id,
|
||||
constraint_log=constraint_log,
|
||||
centerline_ids=set(sketch._centerline_ids),
|
||||
external_entity_ids=set(sketch._external_entity_ids),
|
||||
wp_origin=sketch._wp_origin,
|
||||
wp_normal=sketch._wp_normal,
|
||||
wp_x_dir=sketch._wp_x_dir,
|
||||
wp_y_dir=sketch._wp_y_dir,
|
||||
)
|
||||
|
||||
# ─── Snapshot Restore ──────────────────────────────────────────────────
|
||||
|
||||
def _restore(self, snapshot: SketchSnapshot) -> None:
|
||||
"""Restore the sketch to a previously captured snapshot state."""
|
||||
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
|
||||
|
||||
sketch = self._sketch
|
||||
|
||||
# Clear the current solver and rebuild from scratch.
|
||||
sketch._solver = sketch._solver.__class__() # SolverSystem()
|
||||
sketch._wp = sketch._solver.create_2d_base()
|
||||
sketch._first_point_id = None
|
||||
|
||||
# Restore counters and flags.
|
||||
sketch._entity_counter = snapshot.entity_counter
|
||||
sketch._constraint_count = snapshot.constraint_count
|
||||
sketch._centerline_ids = set(snapshot.centerline_ids)
|
||||
sketch._external_entity_ids = set(snapshot.external_entity_ids)
|
||||
|
||||
# Restore workplane.
|
||||
sketch._wp_origin = snapshot.wp_origin
|
||||
sketch._wp_normal = snapshot.wp_normal
|
||||
sketch._wp_x_dir = snapshot.wp_x_dir
|
||||
sketch._wp_y_dir = snapshot.wp_y_dir
|
||||
|
||||
# Rebuild entity objects from the snapshot.
|
||||
sketch._entities.clear()
|
||||
sketch._points.clear()
|
||||
sketch._lines.clear()
|
||||
sketch._circles.clear()
|
||||
sketch._arcs.clear()
|
||||
|
||||
# First pass: re-add all points to the solver.
|
||||
for eid in sorted(snapshot.entities.keys()):
|
||||
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[eid]
|
||||
if etype == "point" and eid in snapshot.points:
|
||||
x, y = snapshot.points[eid]
|
||||
solver_handle = sketch._solver.add_point_2d(x, y, sketch._wp)
|
||||
ent = OCCSketchEntity(
|
||||
entity_id=eid,
|
||||
entity_type="point",
|
||||
geometry=(x, y),
|
||||
handle=solver_handle,
|
||||
)
|
||||
ent.is_construction = is_constr
|
||||
ent.is_external = is_ext
|
||||
ent.constraints = list(constraints)
|
||||
sketch._entities[eid] = ent
|
||||
sketch._points[eid] = (x, y)
|
||||
|
||||
# Anchor the first point (or first external point) for solver stability.
|
||||
if sketch._first_point_id is None and not is_ext:
|
||||
sketch._first_point_id = eid
|
||||
sketch._solver.dragged(solver_handle, sketch._wp)
|
||||
elif sketch._first_point_id is None and is_ext:
|
||||
sketch._first_point_id = eid
|
||||
sketch._solver.dragged(solver_handle, sketch._wp)
|
||||
|
||||
# Second pass: re-add all lines.
|
||||
for lid in sorted(snapshot.lines.keys()):
|
||||
sid, eid2 = snapshot.lines[lid]
|
||||
s_ent = sketch._entities.get(sid)
|
||||
e_ent = sketch._entities.get(eid2)
|
||||
if s_ent is None or e_ent is None or s_ent.handle is None or e_ent.handle is None:
|
||||
continue
|
||||
|
||||
solver_handle = sketch._solver.add_line_2d(s_ent.handle, e_ent.handle, sketch._wp)
|
||||
|
||||
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[lid]
|
||||
ent = OCCSketchEntity(
|
||||
entity_id=lid,
|
||||
entity_type="line",
|
||||
geometry=geometry,
|
||||
handle=solver_handle,
|
||||
)
|
||||
ent.is_construction = is_constr
|
||||
ent.is_external = is_ext
|
||||
ent.constraints = list(constraints)
|
||||
sketch._entities[lid] = ent
|
||||
sketch._lines[lid] = (sid, eid2)
|
||||
|
||||
# Restore circles (not in solver, just tracked).
|
||||
for cid, (center_id, radius) in snapshot.circles.items():
|
||||
if cid in snapshot.entities:
|
||||
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[cid]
|
||||
center_ent = sketch._entities.get(center_id)
|
||||
ent = OCCSketchEntity(
|
||||
entity_id=cid,
|
||||
entity_type="circle",
|
||||
geometry=geometry,
|
||||
handle=None,
|
||||
)
|
||||
ent.is_construction = is_constr
|
||||
ent.is_external = is_ext
|
||||
ent.constraints = list(constraints)
|
||||
sketch._entities[cid] = ent
|
||||
sketch._circles[cid] = (center_id, radius)
|
||||
|
||||
# Restore arcs (not in solver, just tracked).
|
||||
for aid, arc_data in snapshot.arcs.items():
|
||||
if aid in snapshot.entities:
|
||||
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[aid]
|
||||
ent = OCCSketchEntity(
|
||||
entity_id=aid,
|
||||
entity_type="arc",
|
||||
geometry=geometry,
|
||||
handle=None,
|
||||
)
|
||||
ent.is_construction = is_constr
|
||||
ent.is_external = is_ext
|
||||
ent.constraints = list(constraints)
|
||||
sketch._entities[aid] = ent
|
||||
sketch._arcs[aid] = copy.deepcopy(arc_data)
|
||||
|
||||
# Rebuild the constraint log (entries were deep-copied on capture).
|
||||
sketch._constraint_log = []
|
||||
for entry in snapshot.constraint_log:
|
||||
sketch._constraint_log.append({
|
||||
"type": entry["type"],
|
||||
"ids": tuple(entry["ids"]),
|
||||
"params": tuple(entry["params"]) if entry.get("params") else (),
|
||||
"labels": set(entry["labels"]) if entry.get("labels") else set(),
|
||||
})
|
||||
|
||||
# Re-apply all constraints to the solver.
|
||||
for entry in sketch._constraint_log:
|
||||
sketch._apply_constraint_log(entry)
|
||||
|
||||
# Solve to update geometry positions.
|
||||
sketch.solve()
|
||||
|
||||
logger.debug(
|
||||
f"Restored snapshot: {len(sketch._entities)} entities, "
|
||||
f"{len(sketch._constraint_log)} constraints"
|
||||
)
|
||||
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()
|
||||
@@ -13,16 +13,58 @@ import unittest
|
||||
# Allow running this file directly: ``python tests/test_project_io.py``.
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
|
||||
|
||||
from fluency.io.project_io import save_project, load_project
|
||||
from fluency.io.project_io import save_project, load_project, _feature_to_dict, _feature_from_dict
|
||||
from fluency.models.data_model import (
|
||||
Project,
|
||||
Component,
|
||||
Body,
|
||||
Sketch,
|
||||
Workplane,
|
||||
Assembly,
|
||||
Feature,
|
||||
)
|
||||
|
||||
|
||||
class TestRevolveAxisSerialization(unittest.TestCase):
|
||||
"""Revolve features persist their revolve axis across save/load."""
|
||||
|
||||
def test_axis_round_trip(self):
|
||||
sk = Sketch()
|
||||
feat = Feature(
|
||||
operation="revolve",
|
||||
angle=180.0,
|
||||
axis=(1, 0, 0),
|
||||
origin=(10.0, 5.0, 0.0),
|
||||
axis_line_id=7,
|
||||
)
|
||||
feat.sketch = sk
|
||||
data = _feature_to_dict(feat)
|
||||
self.assertEqual(data["axis"], [1.0, 0.0, 0.0])
|
||||
self.assertEqual(data["origin"], [10.0, 5.0, 0.0])
|
||||
self.assertEqual(data["axis_line_id"], 7)
|
||||
restored = _feature_from_dict(data, {sk.id: sk})
|
||||
self.assertEqual(tuple(restored.axis), (1.0, 0.0, 0.0))
|
||||
self.assertEqual(tuple(restored.origin), (10.0, 5.0, 0.0))
|
||||
self.assertEqual(restored.axis_line_id, 7)
|
||||
self.assertEqual(restored.angle, 180.0)
|
||||
|
||||
def test_default_axis(self):
|
||||
"""Old files without axis fields fall back to Z axis at the origin."""
|
||||
sk = Sketch()
|
||||
data = _feature_to_dict(Feature(operation="revolve", angle=90.0))
|
||||
restored = _feature_from_dict(data, {sk.id: sk})
|
||||
self.assertEqual(tuple(restored.axis), (0, 0, 1))
|
||||
self.assertEqual(tuple(restored.origin), (0.0, 0.0, 0.0))
|
||||
self.assertIsNone(restored.axis_line_id)
|
||||
# legacy file without the keys
|
||||
del data["axis"]
|
||||
del data["origin"]
|
||||
del data["axis_line_id"]
|
||||
restored = _feature_from_dict(data, {sk.id: sk})
|
||||
self.assertEqual(tuple(restored.axis), (0, 0, 1))
|
||||
self.assertEqual(tuple(restored.origin), (0.0, 0.0, 0.0))
|
||||
|
||||
|
||||
class TestProjectIO(unittest.TestCase):
|
||||
"""Round-trip the same project through save/load and check equivalence."""
|
||||
|
||||
|
||||
+1214
-120
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,906 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
################################################################################
|
||||
## Form generated from reading UI file 'gui.ui'
|
||||
##
|
||||
## Created by: Qt User Interface Compiler version 6.10.2
|
||||
##
|
||||
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||
################################################################################
|
||||
|
||||
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||
QMetaObject, QObject, QPoint, QRect,
|
||||
QSize, QTime, QUrl, Qt)
|
||||
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
|
||||
QCursor, QFont, QFontDatabase, QGradient,
|
||||
QIcon, QImage, QKeySequence, QLinearGradient,
|
||||
QPainter, QPalette, QPixmap, QRadialGradient,
|
||||
QTransform)
|
||||
from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QGroupBox,
|
||||
QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||
QMainWindow, QMenu, QMenuBar, QPushButton,
|
||||
QSizePolicy, QSpinBox, QStatusBar, QTabWidget,
|
||||
QTextEdit, QVBoxLayout, QWidget)
|
||||
|
||||
class Ui_fluencyCAD(object):
|
||||
def setupUi(self, fluencyCAD):
|
||||
if not fluencyCAD.objectName():
|
||||
fluencyCAD.setObjectName(u"fluencyCAD")
|
||||
fluencyCAD.resize(2359, 1285)
|
||||
self.actionNew_Project = QAction(fluencyCAD)
|
||||
self.actionNew_Project.setObjectName(u"actionNew_Project")
|
||||
self.actionOpen_Project = QAction(fluencyCAD)
|
||||
self.actionOpen_Project.setObjectName(u"actionOpen_Project")
|
||||
self.actionSave_Project = QAction(fluencyCAD)
|
||||
self.actionSave_Project.setObjectName(u"actionSave_Project")
|
||||
self.actionSave_Project_As = QAction(fluencyCAD)
|
||||
self.actionSave_Project_As.setObjectName(u"actionSave_Project_As")
|
||||
self.actionImport_File = QAction(fluencyCAD)
|
||||
self.actionImport_File.setObjectName(u"actionImport_File")
|
||||
self.actionExport_Step = QAction(fluencyCAD)
|
||||
self.actionExport_Step.setObjectName(u"actionExport_Step")
|
||||
self.actionExport_Iges = QAction(fluencyCAD)
|
||||
self.actionExport_Iges.setObjectName(u"actionExport_Iges")
|
||||
self.actionExport_Stl = QAction(fluencyCAD)
|
||||
self.actionExport_Stl.setObjectName(u"actionExport_Stl")
|
||||
self.actionExit = QAction(fluencyCAD)
|
||||
self.actionExit.setObjectName(u"actionExit")
|
||||
self.centralwidget = QWidget(fluencyCAD)
|
||||
self.centralwidget.setObjectName(u"centralwidget")
|
||||
self.gridLayout = QGridLayout(self.centralwidget)
|
||||
self.gridLayout.setObjectName(u"gridLayout")
|
||||
self.InputTab = QTabWidget(self.centralwidget)
|
||||
self.InputTab.setObjectName(u"InputTab")
|
||||
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.InputTab.sizePolicy().hasHeightForWidth())
|
||||
self.InputTab.setSizePolicy(sizePolicy)
|
||||
self.sketch_tab = QWidget()
|
||||
self.sketch_tab.setObjectName(u"sketch_tab")
|
||||
self.verticalLayout_4 = QVBoxLayout(self.sketch_tab)
|
||||
self.verticalLayout_4.setObjectName(u"verticalLayout_4")
|
||||
self.InputTab.addTab(self.sketch_tab, "")
|
||||
self.code_tab = QWidget()
|
||||
self.code_tab.setObjectName(u"code_tab")
|
||||
self.verticalLayout = QVBoxLayout(self.code_tab)
|
||||
self.verticalLayout.setObjectName(u"verticalLayout")
|
||||
self.textEdit = QTextEdit(self.code_tab)
|
||||
self.textEdit.setObjectName(u"textEdit")
|
||||
|
||||
self.verticalLayout.addWidget(self.textEdit)
|
||||
|
||||
self.groupBox_7 = QGroupBox(self.code_tab)
|
||||
self.groupBox_7.setObjectName(u"groupBox_7")
|
||||
self.gridLayout_5 = QGridLayout(self.groupBox_7)
|
||||
self.gridLayout_5.setObjectName(u"gridLayout_5")
|
||||
self.pushButton_5 = QPushButton(self.groupBox_7)
|
||||
self.pushButton_5.setObjectName(u"pushButton_5")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton_5, 2, 0, 1, 1)
|
||||
|
||||
self.pushButton_4 = QPushButton(self.groupBox_7)
|
||||
self.pushButton_4.setObjectName(u"pushButton_4")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton_4, 2, 1, 1, 1)
|
||||
|
||||
self.pb_apply_code = QPushButton(self.groupBox_7)
|
||||
self.pb_apply_code.setObjectName(u"pb_apply_code")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pb_apply_code, 1, 0, 1, 1)
|
||||
|
||||
self.pushButton = QPushButton(self.groupBox_7)
|
||||
self.pushButton.setObjectName(u"pushButton")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton, 1, 1, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout.addWidget(self.groupBox_7)
|
||||
|
||||
self.InputTab.addTab(self.code_tab, "")
|
||||
|
||||
self.gridLayout.addWidget(self.InputTab, 0, 1, 12, 1)
|
||||
|
||||
self.groupBox_9 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_9.setObjectName(u"groupBox_9")
|
||||
self.groupBox_9.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_7 = QGridLayout(self.groupBox_9)
|
||||
self.gridLayout_7.setObjectName(u"gridLayout_7")
|
||||
self.pb_origin_wp = QPushButton(self.groupBox_9)
|
||||
self.pb_origin_wp.setObjectName(u"pb_origin_wp")
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_origin_wp, 0, 0, 1, 1)
|
||||
|
||||
self.pb_origin_face = QPushButton(self.groupBox_9)
|
||||
self.pb_origin_face.setObjectName(u"pb_origin_face")
|
||||
self.pb_origin_face.setCheckable(True)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_origin_face, 0, 1, 1, 1)
|
||||
|
||||
self.pb_flip_face = QPushButton(self.groupBox_9)
|
||||
self.pb_flip_face.setObjectName(u"pb_flip_face")
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_flip_face, 1, 0, 1, 1)
|
||||
|
||||
self.pb_underlay = QPushButton(self.groupBox_9)
|
||||
self.pb_underlay.setObjectName(u"pb_underlay")
|
||||
self.pb_underlay.setEnabled(False)
|
||||
self.pb_underlay.setCheckable(True)
|
||||
self.pb_underlay.setChecked(True)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_underlay, 3, 0, 1, 1)
|
||||
|
||||
self.pb_clr_face = QPushButton(self.groupBox_9)
|
||||
self.pb_clr_face.setObjectName(u"pb_clr_face")
|
||||
self.pb_clr_face.setEnabled(False)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_clr_face, 3, 1, 1, 1)
|
||||
|
||||
self.pb_to_sketch = QPushButton(self.groupBox_9)
|
||||
self.pb_to_sketch.setObjectName(u"pb_to_sketch")
|
||||
self.pb_to_sketch.setEnabled(False)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_to_sketch, 4, 0, 1, 2)
|
||||
|
||||
self.pb_wp_new = QPushButton(self.groupBox_9)
|
||||
self.pb_wp_new.setObjectName(u"pb_wp_new")
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_wp_new, 1, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
|
||||
|
||||
self.assembly_box = QGroupBox(self.centralwidget)
|
||||
self.assembly_box.setObjectName(u"assembly_box")
|
||||
self.assembly_box.setMinimumSize(QSize(0, 50))
|
||||
|
||||
self.gridLayout.addWidget(self.assembly_box, 13, 1, 1, 2)
|
||||
|
||||
self.joint_tools = QGroupBox(self.centralwidget)
|
||||
self.joint_tools.setObjectName(u"joint_tools")
|
||||
self.joint_tools.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_10 = QGridLayout(self.joint_tools)
|
||||
self.gridLayout_10.setObjectName(u"gridLayout_10")
|
||||
self.pb_remove_connector = QPushButton(self.joint_tools)
|
||||
self.pb_remove_connector.setObjectName(u"pb_remove_connector")
|
||||
self.pb_remove_connector.setMinimumSize(QSize(50, 50))
|
||||
self.pb_remove_connector.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 2, 1, 1)
|
||||
|
||||
self.pb_add_connector = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector.setObjectName(u"pb_add_connector")
|
||||
self.pb_add_connector.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector, 0, 1, 1, 1)
|
||||
|
||||
self.pb_add_connector_2 = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
|
||||
self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 1, 1, 1)
|
||||
|
||||
self.pb_add_connector_3 = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
|
||||
self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 2, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.joint_tools, 12, 3, 2, 1)
|
||||
|
||||
self.gl_box = QGroupBox(self.centralwidget)
|
||||
self.gl_box.setObjectName(u"gl_box")
|
||||
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy1.setHorizontalStretch(0)
|
||||
sizePolicy1.setVerticalStretch(4)
|
||||
sizePolicy1.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
|
||||
self.gl_box.setSizePolicy(sizePolicy1)
|
||||
font = QFont()
|
||||
font.setPointSize(12)
|
||||
self.gl_box.setFont(font)
|
||||
self.horizontalLayout_4 = QHBoxLayout(self.gl_box)
|
||||
#ifndef Q_OS_MAC
|
||||
self.horizontalLayout_4.setSpacing(-1)
|
||||
#endif
|
||||
self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
|
||||
self.horizontalLayout_4.setContentsMargins(12, -1, -1, -1)
|
||||
|
||||
self.gridLayout.addWidget(self.gl_box, 0, 2, 12, 1)
|
||||
|
||||
self.groupBox_11 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_11.setObjectName(u"groupBox_11")
|
||||
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy2.setHorizontalStretch(0)
|
||||
sizePolicy2.setVerticalStretch(0)
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_11.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_11.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_11.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_7 = QVBoxLayout(self.groupBox_11)
|
||||
self.verticalLayout_7.setObjectName(u"verticalLayout_7")
|
||||
self.verticalLayout_7.setContentsMargins(5, 5, 5, 5)
|
||||
self.sketch_list = QListWidget(self.groupBox_11)
|
||||
self.sketch_list.setObjectName(u"sketch_list")
|
||||
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy3.setHorizontalStretch(0)
|
||||
sizePolicy3.setVerticalStretch(0)
|
||||
sizePolicy3.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
|
||||
self.sketch_list.setSizePolicy(sizePolicy3)
|
||||
self.sketch_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_7.addWidget(self.sketch_list)
|
||||
|
||||
self.groupBox_6 = QGroupBox(self.groupBox_11)
|
||||
self.groupBox_6.setObjectName(u"groupBox_6")
|
||||
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||
sizePolicy4.setHorizontalStretch(0)
|
||||
sizePolicy4.setVerticalStretch(0)
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_6.setSizePolicy(sizePolicy4)
|
||||
self.gridLayout_6 = QGridLayout(self.groupBox_6)
|
||||
self.gridLayout_6.setObjectName(u"gridLayout_6")
|
||||
self.gridLayout_6.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_edt_sktch = QPushButton(self.groupBox_6)
|
||||
self.pb_edt_sktch.setObjectName(u"pb_edt_sktch")
|
||||
|
||||
self.gridLayout_6.addWidget(self.pb_edt_sktch, 1, 1, 1, 1)
|
||||
|
||||
self.pb_nw_sktch = QPushButton(self.groupBox_6)
|
||||
self.pb_nw_sktch.setObjectName(u"pb_nw_sktch")
|
||||
|
||||
self.gridLayout_6.addWidget(self.pb_nw_sktch, 1, 0, 1, 1)
|
||||
|
||||
self.pb_del_sketch = QPushButton(self.groupBox_6)
|
||||
self.pb_del_sketch.setObjectName(u"pb_del_sketch")
|
||||
|
||||
self.gridLayout_6.addWidget(self.pb_del_sketch, 1, 2, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_7.addWidget(self.groupBox_6)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_11, 6, 0, 6, 1)
|
||||
|
||||
self.assembly_tools = QGroupBox(self.centralwidget)
|
||||
self.assembly_tools.setObjectName(u"assembly_tools")
|
||||
self.assembly_tools.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_12 = QGridLayout(self.assembly_tools)
|
||||
self.gridLayout_12.setObjectName(u"gridLayout_12")
|
||||
self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
|
||||
self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
|
||||
self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
|
||||
self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_12.addWidget(self.pb_compo_to_assembly, 0, 0, 1, 1)
|
||||
|
||||
self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
|
||||
self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
|
||||
self.pb_remove_compo_from_assembly.setEnabled(True)
|
||||
sizePolicy4.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
|
||||
self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy4)
|
||||
self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
|
||||
self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
|
||||
self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
|
||||
|
||||
self.gridLayout_12.addWidget(self.pb_remove_compo_from_assembly, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.assembly_tools, 13, 0, 1, 1)
|
||||
|
||||
self.compo_tool_box = QGroupBox(self.centralwidget)
|
||||
self.compo_tool_box.setObjectName(u"compo_tool_box")
|
||||
self.compo_tool_box.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_9 = QGridLayout(self.compo_tool_box)
|
||||
self.gridLayout_9.setObjectName(u"gridLayout_9")
|
||||
self.pb_new_compo = QPushButton(self.compo_tool_box)
|
||||
self.pb_new_compo.setObjectName(u"pb_new_compo")
|
||||
self.pb_new_compo.setMinimumSize(QSize(50, 50))
|
||||
self.pb_new_compo.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_9.addWidget(self.pb_new_compo, 0, 0, 1, 1)
|
||||
|
||||
self.pb_del_compo = QPushButton(self.compo_tool_box)
|
||||
self.pb_del_compo.setObjectName(u"pb_del_compo")
|
||||
self.pb_del_compo.setEnabled(True)
|
||||
sizePolicy4.setHeightForWidth(self.pb_del_compo.sizePolicy().hasHeightForWidth())
|
||||
self.pb_del_compo.setSizePolicy(sizePolicy4)
|
||||
self.pb_del_compo.setMinimumSize(QSize(50, 50))
|
||||
self.pb_del_compo.setMaximumSize(QSize(50, 50))
|
||||
self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
|
||||
|
||||
self.gridLayout_9.addWidget(self.pb_del_compo, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.compo_tool_box, 12, 0, 1, 1)
|
||||
|
||||
self.compo_box = QGroupBox(self.centralwidget)
|
||||
self.compo_box.setObjectName(u"compo_box")
|
||||
self.compo_box.setMinimumSize(QSize(0, 50))
|
||||
|
||||
self.gridLayout.addWidget(self.compo_box, 12, 1, 1, 2)
|
||||
|
||||
self.groupBox_12 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_12.setObjectName(u"groupBox_12")
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_12.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_12.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_12.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_8 = QVBoxLayout(self.groupBox_12)
|
||||
self.verticalLayout_8.setObjectName(u"verticalLayout_8")
|
||||
self.verticalLayout_8.setContentsMargins(5, 5, 5, 5)
|
||||
self.connection_list = QListWidget(self.groupBox_12)
|
||||
self.connection_list.setObjectName(u"connection_list")
|
||||
self.connection_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_8.addWidget(self.connection_list)
|
||||
|
||||
self.groupBox_13 = QGroupBox(self.groupBox_12)
|
||||
self.groupBox_13.setObjectName(u"groupBox_13")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_13.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_13.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_13.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_13 = QGridLayout(self.groupBox_13)
|
||||
self.gridLayout_13.setObjectName(u"gridLayout_13")
|
||||
self.gridLayout_13.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_del_connection = QPushButton(self.groupBox_13)
|
||||
self.pb_del_connection.setObjectName(u"pb_del_connection")
|
||||
|
||||
self.gridLayout_13.addWidget(self.pb_del_connection, 0, 2, 1, 1)
|
||||
|
||||
self.pb_update_connection = QPushButton(self.groupBox_13)
|
||||
self.pb_update_connection.setObjectName(u"pb_update_connection")
|
||||
|
||||
self.gridLayout_13.addWidget(self.pb_update_connection, 0, 0, 1, 1)
|
||||
|
||||
self.pb_edt_sktch_4 = QPushButton(self.groupBox_13)
|
||||
self.pb_edt_sktch_4.setObjectName(u"pb_edt_sktch_4")
|
||||
|
||||
self.gridLayout_13.addWidget(self.pb_edt_sktch_4, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_8.addWidget(self.groupBox_13)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_12, 6, 3, 6, 1)
|
||||
|
||||
self.groupBox_10 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_10.setObjectName(u"groupBox_10")
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_10.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_10.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
|
||||
self.verticalLayout_6.setObjectName(u"verticalLayout_6")
|
||||
self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
|
||||
self.body_list = QListWidget(self.groupBox_10)
|
||||
self.body_list.setObjectName(u"body_list")
|
||||
self.body_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_6.addWidget(self.body_list)
|
||||
|
||||
self.groupBox_8 = QGroupBox(self.groupBox_10)
|
||||
self.groupBox_8.setObjectName(u"groupBox_8")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_8.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_8.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_8 = QGridLayout(self.groupBox_8)
|
||||
self.gridLayout_8.setObjectName(u"gridLayout_8")
|
||||
self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_del_body = QPushButton(self.groupBox_8)
|
||||
self.pb_del_body.setObjectName(u"pb_del_body")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
|
||||
|
||||
self.pb_update_body = QPushButton(self.groupBox_8)
|
||||
self.pb_update_body.setObjectName(u"pb_update_body")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
|
||||
|
||||
self.pb_edt_sktch_3 = QPushButton(self.groupBox_8)
|
||||
self.pb_edt_sktch_3.setObjectName(u"pb_edt_sktch_3")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_edt_sktch_3, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_6.addWidget(self.groupBox_8)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_10, 3, 3, 3, 1)
|
||||
|
||||
self.groupBox_2 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_2.setObjectName(u"groupBox_2")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_2.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_2.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_2 = QGridLayout(self.groupBox_2)
|
||||
self.gridLayout_2.setObjectName(u"gridLayout_2")
|
||||
self.gridLayout_2.setContentsMargins(10, -1, -1, -1)
|
||||
self.pb_arc_tool = QPushButton(self.groupBox_2)
|
||||
self.pb_arc_tool.setObjectName(u"pb_arc_tool")
|
||||
self.pb_arc_tool.setCheckable(True)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_arc_tool, 2, 0, 1, 1)
|
||||
|
||||
self.pb_rectool = QPushButton(self.groupBox_2)
|
||||
self.pb_rectool.setObjectName(u"pb_rectool")
|
||||
self.pb_rectool.setCheckable(True)
|
||||
self.pb_rectool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_rectool, 0, 1, 1, 1)
|
||||
|
||||
self.pb_circtool = QPushButton(self.groupBox_2)
|
||||
self.pb_circtool.setObjectName(u"pb_circtool")
|
||||
self.pb_circtool.setCheckable(True)
|
||||
self.pb_circtool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_circtool, 1, 0, 1, 1, Qt.AlignTop)
|
||||
|
||||
self.pb_enable_construct = QPushButton(self.groupBox_2)
|
||||
self.pb_enable_construct.setObjectName(u"pb_enable_construct")
|
||||
self.pb_enable_construct.setCheckable(True)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_enable_construct, 4, 0, 1, 1)
|
||||
|
||||
self.pb_enable_snap = QPushButton(self.groupBox_2)
|
||||
self.pb_enable_snap.setObjectName(u"pb_enable_snap")
|
||||
self.pb_enable_snap.setIconSize(QSize(13, 16))
|
||||
self.pb_enable_snap.setCheckable(True)
|
||||
self.pb_enable_snap.setChecked(True)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_enable_snap, 4, 1, 1, 1)
|
||||
|
||||
self.pb_linetool = QPushButton(self.groupBox_2)
|
||||
self.pb_linetool.setObjectName(u"pb_linetool")
|
||||
self.pb_linetool.setCheckable(True)
|
||||
self.pb_linetool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_linetool, 0, 0, 1, 1)
|
||||
|
||||
self.pb_slotool = QPushButton(self.groupBox_2)
|
||||
self.pb_slotool.setObjectName(u"pb_slotool")
|
||||
self.pb_slotool.setCheckable(True)
|
||||
self.pb_slotool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1, Qt.AlignTop)
|
||||
|
||||
self.line = QFrame(self.groupBox_2)
|
||||
self.line.setObjectName(u"line")
|
||||
self.line.setFrameShape(QFrame.Shape.HLine)
|
||||
self.line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
|
||||
self.gridLayout_2.addWidget(self.line, 3, 0, 1, 2)
|
||||
|
||||
self.pb_offset_tool = QPushButton(self.groupBox_2)
|
||||
self.pb_offset_tool.setObjectName(u"pb_offset_tool")
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_offset_tool, 2, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
|
||||
|
||||
self.groupBox_3 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_3.setObjectName(u"groupBox_3")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_3.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_3.setMaximumSize(QSize(200, 16777213))
|
||||
self.gridLayout_4 = QGridLayout(self.groupBox_3)
|
||||
self.gridLayout_4.setObjectName(u"gridLayout_4")
|
||||
self.pb_con_ptpt = QPushButton(self.groupBox_3)
|
||||
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
|
||||
self.pb_con_ptpt.setCheckable(True)
|
||||
self.pb_con_ptpt.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_ptpt, 1, 0, 1, 1)
|
||||
|
||||
self.pb_con_vert = QPushButton(self.groupBox_3)
|
||||
self.pb_con_vert.setObjectName(u"pb_con_vert")
|
||||
self.pb_con_vert.setCheckable(True)
|
||||
self.pb_con_vert.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_vert, 3, 1, 1, 1)
|
||||
|
||||
self.pb_con_sym = QPushButton(self.groupBox_3)
|
||||
self.pb_con_sym.setObjectName(u"pb_con_sym")
|
||||
self.pb_con_sym.setCheckable(True)
|
||||
self.pb_con_sym.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_sym, 4, 1, 1, 1)
|
||||
|
||||
self.pb_con_mid = QPushButton(self.groupBox_3)
|
||||
self.pb_con_mid.setObjectName(u"pb_con_mid")
|
||||
self.pb_con_mid.setCheckable(True)
|
||||
self.pb_con_mid.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_mid, 2, 0, 1, 1)
|
||||
|
||||
self.pb_con_line = QPushButton(self.groupBox_3)
|
||||
self.pb_con_line.setObjectName(u"pb_con_line")
|
||||
self.pb_con_line.setCheckable(True)
|
||||
self.pb_con_line.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_line, 1, 1, 1, 1)
|
||||
|
||||
self.pb_con_horiz = QPushButton(self.groupBox_3)
|
||||
self.pb_con_horiz.setObjectName(u"pb_con_horiz")
|
||||
self.pb_con_horiz.setCheckable(True)
|
||||
self.pb_con_horiz.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_horiz, 3, 0, 1, 1)
|
||||
|
||||
self.pb_con_dist = QPushButton(self.groupBox_3)
|
||||
self.pb_con_dist.setObjectName(u"pb_con_dist")
|
||||
self.pb_con_dist.setCheckable(True)
|
||||
self.pb_con_dist.setAutoExclusive(False)
|
||||
self.pb_con_dist.setAutoRepeatDelay(297)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_dist, 4, 0, 1, 1)
|
||||
|
||||
self.pb_con_perp = QPushButton(self.groupBox_3)
|
||||
self.pb_con_perp.setObjectName(u"pb_con_perp")
|
||||
self.pb_con_perp.setCheckable(True)
|
||||
self.pb_con_perp.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_perp, 2, 1, 1, 1)
|
||||
|
||||
self.pb_con_diameter = QPushButton(self.groupBox_3)
|
||||
self.pb_con_diameter.setObjectName(u"pb_con_diameter")
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_diameter, 5, 0, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
|
||||
|
||||
self.groupBox_5 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_5.setObjectName(u"groupBox_5")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_5.setSizePolicy(sizePolicy4)
|
||||
self.gridLayout_11 = QGridLayout(self.groupBox_5)
|
||||
self.gridLayout_11.setObjectName(u"gridLayout_11")
|
||||
self.gridLayout_11.setContentsMargins(12, 12, 12, 12)
|
||||
self.label = QLabel(self.groupBox_5)
|
||||
self.label.setObjectName(u"label")
|
||||
|
||||
self.gridLayout_11.addWidget(self.label, 5, 0, 1, 1)
|
||||
|
||||
self.pb_snap_vert = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_vert.setObjectName(u"pb_snap_vert")
|
||||
self.pb_snap_vert.setCheckable(True)
|
||||
self.pb_snap_vert.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_vert, 2, 1, 1, 1)
|
||||
|
||||
self.line_2 = QFrame(self.groupBox_5)
|
||||
self.line_2.setObjectName(u"line_2")
|
||||
self.line_2.setFrameShape(QFrame.Shape.HLine)
|
||||
self.line_2.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
|
||||
self.gridLayout_11.addWidget(self.line_2, 4, 0, 1, 2)
|
||||
|
||||
self.label_2 = QLabel(self.groupBox_5)
|
||||
self.label_2.setObjectName(u"label_2")
|
||||
|
||||
self.gridLayout_11.addWidget(self.label_2, 5, 1, 1, 1)
|
||||
|
||||
self.spinbox_snap_distance = QSpinBox(self.groupBox_5)
|
||||
self.spinbox_snap_distance.setObjectName(u"spinbox_snap_distance")
|
||||
self.spinbox_snap_distance.setMaximum(30)
|
||||
self.spinbox_snap_distance.setValue(10)
|
||||
|
||||
self.gridLayout_11.addWidget(self.spinbox_snap_distance, 6, 0, 1, 1)
|
||||
|
||||
self.pushButton_7 = QPushButton(self.groupBox_5)
|
||||
self.pushButton_7.setObjectName(u"pushButton_7")
|
||||
self.pushButton_7.setCheckable(True)
|
||||
self.pushButton_7.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pushButton_7, 3, 0, 1, 1)
|
||||
|
||||
self.pb_snap_horiz = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_horiz.setObjectName(u"pb_snap_horiz")
|
||||
self.pb_snap_horiz.setCheckable(True)
|
||||
self.pb_snap_horiz.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_horiz, 2, 0, 1, 1)
|
||||
|
||||
self.spinbox_angle_steps = QSpinBox(self.groupBox_5)
|
||||
self.spinbox_angle_steps.setObjectName(u"spinbox_angle_steps")
|
||||
self.spinbox_angle_steps.setMaximum(180)
|
||||
self.spinbox_angle_steps.setValue(15)
|
||||
|
||||
self.gridLayout_11.addWidget(self.spinbox_angle_steps, 6, 1, 1, 1)
|
||||
|
||||
self.pushButton_8 = QPushButton(self.groupBox_5)
|
||||
self.pushButton_8.setObjectName(u"pushButton_8")
|
||||
self.pushButton_8.setCheckable(True)
|
||||
self.pushButton_8.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pushButton_8, 0, 0, 1, 1)
|
||||
|
||||
self.pb_snap_midp = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_midp.setObjectName(u"pb_snap_midp")
|
||||
self.pb_snap_midp.setCheckable(True)
|
||||
self.pb_snap_midp.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_midp, 0, 1, 1, 1)
|
||||
|
||||
self.pb_snap_angle = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_angle.setObjectName(u"pb_snap_angle")
|
||||
self.pb_snap_angle.setCheckable(True)
|
||||
self.pb_snap_angle.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_angle, 3, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_5, 3, 0, 1, 1)
|
||||
|
||||
self.groupBox = QGroupBox(self.centralwidget)
|
||||
self.groupBox.setObjectName(u"groupBox")
|
||||
self.gridLayout_3 = QGridLayout(self.groupBox)
|
||||
self.gridLayout_3.setObjectName(u"gridLayout_3")
|
||||
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_extrdop = QPushButton(self.groupBox)
|
||||
self.pb_extrdop.setObjectName(u"pb_extrdop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 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_cutop = QPushButton(self.groupBox)
|
||||
self.pb_cutop.setObjectName(u"pb_cutop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
|
||||
|
||||
self.pb_combop = QPushButton(self.groupBox)
|
||||
self.pb_combop.setObjectName(u"pb_combop")
|
||||
|
||||
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.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
|
||||
|
||||
self.groupBox_4 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_4.setObjectName(u"groupBox_4")
|
||||
self.verticalLayout_2 = QVBoxLayout(self.groupBox_4)
|
||||
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
|
||||
self.pushButton_2 = QPushButton(self.groupBox_4)
|
||||
self.pushButton_2.setObjectName(u"pushButton_2")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.pushButton_2)
|
||||
|
||||
self.pb_export_step = QPushButton(self.groupBox_4)
|
||||
self.pb_export_step.setObjectName(u"pb_export_step")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.pb_export_step)
|
||||
|
||||
self.pb_export_iges = QPushButton(self.groupBox_4)
|
||||
self.pb_export_iges.setObjectName(u"pb_export_iges")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.pb_export_iges)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_4, 2, 3, 1, 1)
|
||||
|
||||
fluencyCAD.setCentralWidget(self.centralwidget)
|
||||
self.menubar = QMenuBar(fluencyCAD)
|
||||
self.menubar.setObjectName(u"menubar")
|
||||
self.menubar.setGeometry(QRect(0, 0, 2359, 24))
|
||||
self.menuFile = QMenu(self.menubar)
|
||||
self.menuFile.setObjectName(u"menuFile")
|
||||
self.menuSettings = QMenu(self.menubar)
|
||||
self.menuSettings.setObjectName(u"menuSettings")
|
||||
fluencyCAD.setMenuBar(self.menubar)
|
||||
self.statusbar = QStatusBar(fluencyCAD)
|
||||
self.statusbar.setObjectName(u"statusbar")
|
||||
fluencyCAD.setStatusBar(self.statusbar)
|
||||
|
||||
self.menubar.addAction(self.menuFile.menuAction())
|
||||
self.menubar.addAction(self.menuSettings.menuAction())
|
||||
self.menuFile.addAction(self.actionNew_Project)
|
||||
self.menuFile.addAction(self.actionOpen_Project)
|
||||
self.menuFile.addAction(self.actionSave_Project)
|
||||
self.menuFile.addAction(self.actionSave_Project_As)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionImport_File)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionExport_Step)
|
||||
self.menuFile.addAction(self.actionExport_Iges)
|
||||
self.menuFile.addAction(self.actionExport_Stl)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionExit)
|
||||
|
||||
self.retranslateUi(fluencyCAD)
|
||||
|
||||
self.InputTab.setCurrentIndex(0)
|
||||
|
||||
|
||||
QMetaObject.connectSlotsByName(fluencyCAD)
|
||||
# setupUi
|
||||
|
||||
def retranslateUi(self, fluencyCAD):
|
||||
fluencyCAD.setWindowTitle(QCoreApplication.translate("fluencyCAD", u"fluencyCAD", None))
|
||||
self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New Project", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionNew_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+N", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionOpen_Project.setText(QCoreApplication.translate("fluencyCAD", u"Open Project...", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionOpen_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+O", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionSave_Project.setText(QCoreApplication.translate("fluencyCAD", u"Save Project", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionSave_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionSave_Project_As.setText(QCoreApplication.translate("fluencyCAD", u"Save Project As...", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionSave_Project_As.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Shift+S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionImport_File.setText(QCoreApplication.translate("fluencyCAD", u"Import STEP/IGES...", None))
|
||||
self.actionExport_Step.setText(QCoreApplication.translate("fluencyCAD", u"Export STEP...", None))
|
||||
self.actionExport_Iges.setText(QCoreApplication.translate("fluencyCAD", u"Export IGES...", None))
|
||||
self.actionExport_Stl.setText(QCoreApplication.translate("fluencyCAD", u"Export STL...", None))
|
||||
self.actionExit.setText(QCoreApplication.translate("fluencyCAD", u"Exit", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionExit.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Q", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.InputTab.setTabText(self.InputTab.indexOf(self.sketch_tab), QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||
self.groupBox_7.setTitle(QCoreApplication.translate("fluencyCAD", u"Executive", None))
|
||||
self.pushButton_5.setText(QCoreApplication.translate("fluencyCAD", u"Load Code", None))
|
||||
self.pushButton_4.setText(QCoreApplication.translate("fluencyCAD", u"Save code", None))
|
||||
self.pb_apply_code.setText(QCoreApplication.translate("fluencyCAD", u"Apply Code", None))
|
||||
self.pushButton.setText(QCoreApplication.translate("fluencyCAD", u"Delete Code", None))
|
||||
self.InputTab.setTabText(self.InputTab.indexOf(self.code_tab), QCoreApplication.translate("fluencyCAD", u"Code", None))
|
||||
self.groupBox_9.setTitle(QCoreApplication.translate("fluencyCAD", u"Workplanes", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_origin_wp.setToolTip(QCoreApplication.translate("fluencyCAD", u"<W>orking Plane at 0, 0, 0", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_origin_wp.setText(QCoreApplication.translate("fluencyCAD", u"WP Origin", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_origin_wp.setShortcut(QCoreApplication.translate("fluencyCAD", u"W", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_origin_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Working Plane >P<rojection at selected edges face", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_origin_face.setText(QCoreApplication.translate("fluencyCAD", u" WP Face", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_origin_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"P", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_flip_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Flip >N<ormal of projected mesh.", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_flip_face.setText(QCoreApplication.translate("fluencyCAD", u"WP Flip", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_flip_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"N", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_underlay.setToolTip(QCoreApplication.translate("fluencyCAD", u"Show / hide the construction lines projected from the source face", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_underlay.setText(QCoreApplication.translate("fluencyCAD", u"Underlay", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_clr_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Forget the picked source face (keep the workplane)", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_clr_face.setText(QCoreApplication.translate("fluencyCAD", u"ClrFace", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_to_sketch.setToolTip(QCoreApplication.translate("fluencyCAD", u"Convert projected construction lines into real sketch geometry", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_to_sketch.setText(QCoreApplication.translate("fluencyCAD", u"ToSketch", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_wp_new.setToolTip(QCoreApplication.translate("fluencyCAD", u"Create a new independent workplane (datum plane)", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_wp_new.setText(QCoreApplication.translate("fluencyCAD", u"WP New", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_wp_new.setShortcut(QCoreApplication.translate("fluencyCAD", u"Shift+W", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
|
||||
self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
|
||||
self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
|
||||
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
|
||||
self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
|
||||
self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
|
||||
self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
|
||||
self.groupBox_11.setTitle(QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||
self.groupBox_6.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_edt_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Edt", None))
|
||||
self.pb_nw_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||
self.pb_del_sketch.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
|
||||
self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||
self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", None))
|
||||
self.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
|
||||
self.pb_new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
|
||||
self.pb_del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", None))
|
||||
self.groupBox_12.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Connections", None))
|
||||
self.groupBox_13.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.pb_update_connection.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||
self.pb_edt_sktch_4.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
||||
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||
self.pb_edt_sktch_3.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||
self.groupBox_2.setTitle(QCoreApplication.translate("fluencyCAD", u"Drawing", None))
|
||||
self.pb_arc_tool.setText(QCoreApplication.translate("fluencyCAD", u"Arc", None))
|
||||
self.pb_rectool.setText(QCoreApplication.translate("fluencyCAD", u"Rctgl", None))
|
||||
self.pb_circtool.setText(QCoreApplication.translate("fluencyCAD", u"Circle", None))
|
||||
self.pb_enable_construct.setText(QCoreApplication.translate("fluencyCAD", u"Cstrct", None))
|
||||
self.pb_enable_snap.setText(QCoreApplication.translate("fluencyCAD", u"Snap", None))
|
||||
self.pb_linetool.setText(QCoreApplication.translate("fluencyCAD", u"Line", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_linetool.setShortcut(QCoreApplication.translate("fluencyCAD", u"S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.pb_slotool.setText(QCoreApplication.translate("fluencyCAD", u"Slot", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_offset_tool.setToolTip(QCoreApplication.translate("fluencyCAD", u"Offset selected sketch face (duplicate + offset boundary)", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
|
||||
self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_ptpt.setToolTip(QCoreApplication.translate("fluencyCAD", u"Poin to Point Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_ptpt.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Pt", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_vert.setToolTip(QCoreApplication.translate("fluencyCAD", u"Vertical Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||
self.pb_con_sym.setText(QCoreApplication.translate("fluencyCAD", u"Symetrc", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_mid.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Middle Point Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_mid.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Mid_L", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_line.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Line Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_line.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Lne", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_horiz.setToolTip(QCoreApplication.translate("fluencyCAD", u"Horizontal Constrain ", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_dist.setToolTip(QCoreApplication.translate("fluencyCAD", u"Dimension of Line of Distance from Point to Line", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_dist.setText(QCoreApplication.translate("fluencyCAD", u"Distnce", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_perp.setToolTip(QCoreApplication.translate("fluencyCAD", u"Constrain Line perpendicular to another line.", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_perp.setText(QCoreApplication.translate("fluencyCAD", u"Perp_Lne", None))
|
||||
self.pb_con_diameter.setText(QCoreApplication.translate("fluencyCAD", u"Diameter", None))
|
||||
self.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
|
||||
self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
|
||||
self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||
self.label_2.setText(QCoreApplication.translate("fluencyCAD", u"Angl Stps", None))
|
||||
self.spinbox_snap_distance.setSuffix(QCoreApplication.translate("fluencyCAD", u"mm", None))
|
||||
self.pushButton_7.setText(QCoreApplication.translate("fluencyCAD", u"Grid", None))
|
||||
self.pb_snap_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||
self.spinbox_angle_steps.setSuffix(QCoreApplication.translate("fluencyCAD", u"\u00b0", None))
|
||||
self.pushButton_8.setText(QCoreApplication.translate("fluencyCAD", u"Pnt", None))
|
||||
self.pb_snap_midp.setText(QCoreApplication.translate("fluencyCAD", u"MidP", None))
|
||||
self.pb_snap_angle.setText(QCoreApplication.translate("fluencyCAD", u"Angles", None))
|
||||
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
||||
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
||||
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
|
||||
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Array", None))
|
||||
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
|
||||
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
||||
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
||||
self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
|
||||
self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
|
||||
self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", None))
|
||||
self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
|
||||
self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
|
||||
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
|
||||
# retranslateUi
|
||||
|
||||
+6737
-505
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2113
-352
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+649
-70
File diff suppressed because it is too large
Load Diff
+130
-115
@@ -1,7 +1,6 @@
|
||||
"""Tests for Fluency CAD geometry kernel."""
|
||||
|
||||
import pytest
|
||||
import numpy as np
|
||||
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
@@ -195,10 +194,14 @@ class TestOCCSketch:
|
||||
sk = OCCSketch()
|
||||
sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir)
|
||||
# 20x20 square in UV
|
||||
p0 = sk.add_point(-10, -10); p1 = sk.add_point(10, -10)
|
||||
p2 = sk.add_point(10, 10); p3 = sk.add_point(-10, 10)
|
||||
sk.add_line(p0, p1); sk.add_line(p1, p2)
|
||||
sk.add_line(p2, p3); sk.add_line(p3, p0)
|
||||
p0 = sk.add_point(-10, -10)
|
||||
p1 = sk.add_point(10, -10)
|
||||
p2 = sk.add_point(10, 10)
|
||||
p3 = sk.add_point(-10, 10)
|
||||
sk.add_line(p0, p1)
|
||||
sk.add_line(p1, p2)
|
||||
sk.add_line(p2, p3)
|
||||
sk.add_line(p3, p0)
|
||||
|
||||
geom = sk.get_geometry()
|
||||
# The face must carry the plane normal for the kernel.
|
||||
@@ -220,10 +223,14 @@ class TestOCCSketch:
|
||||
|
||||
sk = OCCSketch()
|
||||
sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0))
|
||||
a = sk.add_point(-10, -10); b = sk.add_point(10, -10)
|
||||
c = sk.add_point(10, 10); d = sk.add_point(-10, 10)
|
||||
sk.add_line(a, b); sk.add_line(b, c)
|
||||
sk.add_line(c, d); sk.add_line(d, a)
|
||||
a = sk.add_point(-10, -10)
|
||||
b = sk.add_point(10, -10)
|
||||
c = sk.add_point(10, 10)
|
||||
d = sk.add_point(-10, 10)
|
||||
sk.add_line(a, b)
|
||||
sk.add_line(b, c)
|
||||
sk.add_line(c, d)
|
||||
sk.add_line(d, a)
|
||||
ctr = sk.add_point(0, 0)
|
||||
sk.add_circle(ctr, 3.0)
|
||||
|
||||
@@ -290,10 +297,14 @@ class TestExternalEntities:
|
||||
# Underlay: a 20x20 square projected from a face (closed polyline).
|
||||
sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)])
|
||||
# User profile: a 5x5 square — this is what should be extruded.
|
||||
a = sk.add_point(2, 2); b = sk.add_point(8, 2)
|
||||
c = sk.add_point(8, 8); d = sk.add_point(2, 8)
|
||||
sk.add_line(a, b); sk.add_line(b, c)
|
||||
sk.add_line(c, d); sk.add_line(d, a)
|
||||
a = sk.add_point(2, 2)
|
||||
b = sk.add_point(8, 2)
|
||||
c = sk.add_point(8, 8)
|
||||
d = sk.add_point(2, 8)
|
||||
sk.add_line(a, b)
|
||||
sk.add_line(b, c)
|
||||
sk.add_line(c, d)
|
||||
sk.add_line(d, a)
|
||||
faces = sk.detect_faces()
|
||||
# Only the user-drawn face (5x5 square) should be detected.
|
||||
assert len(faces) == 1
|
||||
@@ -310,10 +321,14 @@ class TestExternalEntities:
|
||||
def test_external_entities_excluded_from_get_polygon_points(self):
|
||||
sk = OCCSketch()
|
||||
sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)])
|
||||
a = sk.add_point(1, 1); b = sk.add_point(2, 1)
|
||||
c = sk.add_point(2, 2); d = sk.add_point(1, 2)
|
||||
sk.add_line(a, b); sk.add_line(b, c)
|
||||
sk.add_line(c, d); sk.add_line(d, a)
|
||||
a = sk.add_point(1, 1)
|
||||
b = sk.add_point(2, 1)
|
||||
c = sk.add_point(2, 2)
|
||||
d = sk.add_point(1, 2)
|
||||
sk.add_line(a, b)
|
||||
sk.add_line(b, c)
|
||||
sk.add_line(c, d)
|
||||
sk.add_line(d, a)
|
||||
poly = sk.get_polygon_points()
|
||||
# The user square (1..2 range) should appear, not the 0..100 underlay.
|
||||
assert all(1.0 <= p.x <= 2.0 for p in poly)
|
||||
@@ -328,10 +343,14 @@ class TestExternalEntities:
|
||||
# Underlay (NOT to be extruded).
|
||||
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
|
||||
# User profile: a 2x2 square inside the underlay.
|
||||
a = sk.add_point(1, 1); b = sk.add_point(3, 1)
|
||||
c = sk.add_point(3, 3); d = sk.add_point(1, 3)
|
||||
sk.add_line(a, b); sk.add_line(b, c)
|
||||
sk.add_line(c, d); sk.add_line(d, a)
|
||||
a = sk.add_point(1, 1)
|
||||
b = sk.add_point(3, 1)
|
||||
c = sk.add_point(3, 3)
|
||||
d = sk.add_point(1, 3)
|
||||
sk.add_line(a, b)
|
||||
sk.add_line(b, c)
|
||||
sk.add_line(c, d)
|
||||
sk.add_line(d, a)
|
||||
geom = sk.get_geometry()
|
||||
# Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
|
||||
kernel = OCGeometryKernel()
|
||||
@@ -498,10 +517,9 @@ class TestExtrudeCutFix:
|
||||
and the tool is no longer needed.
|
||||
"""
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||
from OCP.GProp import GProp_GProps
|
||||
from OCP.BRepGProp import BRepGProp
|
||||
import math
|
||||
|
||||
k = OCGeometryKernel()
|
||||
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
||||
@@ -511,17 +529,18 @@ class TestExtrudeCutFix:
|
||||
# expected volume easy to compute.
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
||||
from OCP.gp import gp_Pnt, gp_Vec
|
||||
|
||||
# 20x20 square at (0,0,0), extruded along +Z by 200.
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||
|
||||
mp = BRepBuilderAPI_MakePolygon()
|
||||
for (x, y) in [(0, 0), (20, 0), (20, 20), (0, 20)]:
|
||||
for x, y in [(0, 0), (20, 0), (20, 20), (0, 20)]:
|
||||
mp.Add(gp_Pnt(x, y, 0))
|
||||
mp.Close()
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
||||
|
||||
face = BRepBuilderAPI_MakeFace(mp.Wire()).Face()
|
||||
tool_shape = BRepPrimAPI_MakePrism(
|
||||
face, gp_Vec(0, 0, 200)
|
||||
).Shape()
|
||||
tool_shape = BRepPrimAPI_MakePrism(face, gp_Vec(0, 0, 200)).Shape()
|
||||
tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
|
||||
|
||||
# Before cut: target is 100^3 = 1_000_000.
|
||||
@@ -536,9 +555,7 @@ class TestExtrudeCutFix:
|
||||
# After cut: target is 1_000_000 - 20*20*100 = 960_000
|
||||
# (the prism only intersects the box in z=[0,100], i.e. 100 deep).
|
||||
g1 = GProp_GProps()
|
||||
BRepGProp.VolumeProperties_s(
|
||||
k._get_shape(target_obj_geometry), g1
|
||||
)
|
||||
BRepGProp.VolumeProperties_s(k._get_shape(target_obj_geometry), g1)
|
||||
assert abs(g1.Mass() - 960_000.0) < 1.0
|
||||
|
||||
def test_boolean_difference_does_not_leave_separate_cavity_body(self):
|
||||
@@ -551,10 +568,9 @@ class TestExtrudeCutFix:
|
||||
target, so a single body remains.
|
||||
"""
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_SOLID
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||
|
||||
k = OCGeometryKernel()
|
||||
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
||||
@@ -562,6 +578,7 @@ class TestExtrudeCutFix:
|
||||
|
||||
# Tool: small box at the centre, fully inside the target.
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
|
||||
|
||||
tool_shape = BBox(20, 20, 20).Shape()
|
||||
tool_obj = OCCGeometryObject(tool_shape, {})
|
||||
|
||||
@@ -595,91 +612,59 @@ class TestBodyVisibilityToggle:
|
||||
|
||||
def _make_window(self):
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
from fluency.main import MainWindow
|
||||
|
||||
return MainWindow()
|
||||
|
||||
def test_body_list_uses_checkable_items(self):
|
||||
"""Each body list item must be a checkable QListWidgetItem."""
|
||||
"""Each body list item has a data role for the toggle handler."""
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
win = self._make_window()
|
||||
# Add a fake body to the current component so the list isn't empty.
|
||||
from fluency.models.data_model import Body
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||
box = OCCGeometryObject(
|
||||
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
||||
)
|
||||
|
||||
box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
|
||||
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||
win._refresh_lists()
|
||||
items = win._body_list.findItems("A", Qt.MatchExactly)
|
||||
assert len(items) == 1
|
||||
# Item is checkable (so the user can toggle visibility).
|
||||
assert items[0].flags() & Qt.ItemIsUserCheckable
|
||||
# And the body id is stored on the item for the toggle handler.
|
||||
assert items[0].data(Qt.UserRole) == "a"
|
||||
# Default state is checked (= visible).
|
||||
assert items[0].checkState() == Qt.Checked
|
||||
# Default state is visible.
|
||||
assert win._current_component.bodies["a"].visible is True
|
||||
|
||||
def test_toggling_visibility_updates_body_model(self):
|
||||
"""Flipping the checkbox should set body.visible accordingly."""
|
||||
"""Toggling visibility via _on_body_visibility_changed updates the model."""
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
win = self._make_window()
|
||||
from fluency.models.data_model import Body
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||
box = OCCGeometryObject(
|
||||
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
||||
)
|
||||
|
||||
box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
|
||||
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||
win._refresh_lists()
|
||||
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
|
||||
|
||||
# Toggle off.
|
||||
item.setCheckState(Qt.Unchecked)
|
||||
win._on_body_visibility_changed(item)
|
||||
assert win._current_component.bodies["a"].visible is False
|
||||
|
||||
# Toggle back on.
|
||||
item.setCheckState(Qt.Checked)
|
||||
win._on_body_visibility_changed(item)
|
||||
assert win._current_component.bodies["a"].visible is True
|
||||
|
||||
def test_visibility_no_op_when_unchanged(self):
|
||||
"""Re-emitting the same state must not trigger a viewer call.
|
||||
|
||||
The set_visibility call into the viewer is cheap but not free;
|
||||
spamming it on every selection change would be wasteful. The
|
||||
handler short-circuits when the new state matches the model's.
|
||||
"""
|
||||
from PySide6.QtCore import Qt
|
||||
win = self._make_window()
|
||||
from fluency.models.data_model import Body
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||
box = OCCGeometryObject(
|
||||
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
||||
)
|
||||
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||
win._refresh_lists()
|
||||
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
|
||||
|
||||
# Force the model's visibility to False to mimic a desync.
|
||||
win._current_component.bodies["a"].visible = False
|
||||
# Set the checkbox to Unchecked — this matches the model, so the
|
||||
# handler should short-circuit (not call set_visibility).
|
||||
item.setCheckState(Qt.Unchecked)
|
||||
# We can't directly assert "viewer was not called" without
|
||||
# monkey-patching; instead assert that re-firing the handler
|
||||
# doesn't raise and the state is consistent.
|
||||
win._on_body_visibility_changed(item)
|
||||
assert win._current_component.bodies["a"].visible is False
|
||||
|
||||
|
||||
def math_hypot(x, y):
|
||||
import math
|
||||
|
||||
return math.hypot(x, y)
|
||||
|
||||
|
||||
@@ -698,10 +683,13 @@ class TestConstraintTagRendering:
|
||||
def _make_widget_with_sketch(self, sk):
|
||||
"""Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
from fluency.main import Sketch2DWidget
|
||||
|
||||
w = Sketch2DWidget()
|
||||
w.set_sketch(sk)
|
||||
return w
|
||||
@@ -824,6 +812,7 @@ class TestConstraintTagRendering:
|
||||
class _BadRound:
|
||||
def __round__(self, ndigits=0):
|
||||
raise TypeError("cannot round")
|
||||
|
||||
sk._entities[c.id].geometry = (_BadRound(), _BadRound())
|
||||
tags = w._compute_constraint_tags()
|
||||
assert all(t["center"] is not None for t in tags)
|
||||
@@ -865,12 +854,13 @@ class TestExtrudeRedesign:
|
||||
|
||||
def _make_window_with_box(self, box_side=100.0):
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
from fluency.main import MainWindow
|
||||
from fluency.models.data_model import Sketch, Body
|
||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
|
||||
@@ -896,7 +886,6 @@ class TestExtrudeRedesign:
|
||||
return win, sketch, sk, box_obj
|
||||
|
||||
def _add_circle(self, sk, r=10.0):
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
c = sk.add_point(0, 0)
|
||||
sk.add_circle(c, r)
|
||||
sk.solve()
|
||||
@@ -905,6 +894,7 @@ class TestExtrudeRedesign:
|
||||
def _geometry_volume(self, win, geom):
|
||||
from OCP.GProp import GProp_GProps
|
||||
from OCP.BRepGProp import BRepGProp
|
||||
|
||||
sh = win._kernel._get_shape(geom)
|
||||
g = GProp_GProps()
|
||||
BRepGProp.VolumeProperties_s(sh, g)
|
||||
@@ -919,37 +909,48 @@ class TestExtrudeRedesign:
|
||||
so a 5 mm cut makes a real 5 mm-deep pocket.
|
||||
"""
|
||||
import math
|
||||
|
||||
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||
face_geom = self._add_circle(sk, r=10.0)
|
||||
# Plain cut, length=5, NOT inverted. Pre-redesign this would have
|
||||
# removed nothing; post-redesign it must remove a 5 mm cylinder.
|
||||
result = win._compute_extrude_result(
|
||||
sketch, face_geom,
|
||||
length=5.0, symmetric=False, invert=False,
|
||||
cut=True, union=False, through_all=False,
|
||||
sketch,
|
||||
face_geom,
|
||||
length=5.0,
|
||||
symmetric=False,
|
||||
invert=False,
|
||||
cut=True,
|
||||
union=False,
|
||||
through_all=False,
|
||||
)
|
||||
assert result is not None
|
||||
assert result["target_body"] is not None
|
||||
assert result["target_body"].name == "Box1"
|
||||
vol = self._geometry_volume(win, result["result_geom"])
|
||||
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 5.0
|
||||
expected = 100.0**3 - math.pi * (10.0**2) * 5.0
|
||||
assert abs(vol - expected) < 1.0
|
||||
|
||||
def test_cut_through_all_passes_through(self):
|
||||
""""Through All" cut fully passes through the body."""
|
||||
""" "Through All" cut fully passes through the body."""
|
||||
import math
|
||||
|
||||
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||
face_geom = self._add_circle(sk, r=10.0)
|
||||
result = win._compute_extrude_result(
|
||||
sketch, face_geom,
|
||||
sketch,
|
||||
face_geom,
|
||||
length=5.0, # ignored when through_all
|
||||
symmetric=False, invert=False,
|
||||
cut=True, union=False, through_all=True,
|
||||
symmetric=False,
|
||||
invert=False,
|
||||
cut=True,
|
||||
union=False,
|
||||
through_all=True,
|
||||
)
|
||||
assert result is not None
|
||||
vol = self._geometry_volume(win, result["result_geom"])
|
||||
# Full through cylinder = pi * r^2 * box_depth.
|
||||
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
|
||||
expected = 100.0**3 - math.pi * (10.0**2) * 100.0
|
||||
assert abs(vol - expected) < 1.0
|
||||
|
||||
def test_cut_auto_targets_source_body_not_existing_zero(self):
|
||||
@@ -960,30 +961,23 @@ class TestExtrudeRedesign:
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
from fluency.main import MainWindow
|
||||
from fluency.models.data_model import Sketch, Body
|
||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
|
||||
win = MainWindow()
|
||||
# First body in the dict: a 50-millimetre box ALSO.
|
||||
first = OCCGeometryObject(
|
||||
BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {}
|
||||
)
|
||||
win._current_component.bodies["first"] = Body(
|
||||
name="First", geometry=first
|
||||
)
|
||||
first = OCCGeometryObject(BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {})
|
||||
win._current_component.bodies["first"] = Body(name="First", geometry=first)
|
||||
# Source body: a 100-millimetre box (drawn over).
|
||||
src = OCCGeometryObject(
|
||||
BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {}
|
||||
)
|
||||
win._current_component.bodies["src"] = Body(
|
||||
name="Src", geometry=src
|
||||
)
|
||||
src = OCCGeometryObject(BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {})
|
||||
win._current_component.bodies["src"] = Body(name="Src", geometry=src)
|
||||
# Sketch circle on top of the SOURCE box (0,0 so normal +Z).
|
||||
sk = OCCSketch()
|
||||
sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
||||
@@ -999,16 +993,21 @@ class TestExtrudeRedesign:
|
||||
|
||||
face_geom = sk.get_geometry()
|
||||
result = win._compute_extrude_result(
|
||||
sketch, face_geom,
|
||||
length=5.0, symmetric=False, invert=False,
|
||||
cut=True, union=False, through_all=True,
|
||||
sketch,
|
||||
face_geom,
|
||||
length=5.0,
|
||||
symmetric=False,
|
||||
invert=False,
|
||||
cut=True,
|
||||
union=False,
|
||||
through_all=True,
|
||||
)
|
||||
assert result is not None
|
||||
# Target is the source box, NOT the dict's first body.
|
||||
assert result["target_body"].name == "Src"
|
||||
vol = self._geometry_volume(win, result["result_geom"])
|
||||
# 100^3 - pi*100*100 (through-all full-depth cut on the 100 box).
|
||||
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
|
||||
expected = 100.0**3 - math.pi * (10.0**2) * 100.0
|
||||
assert abs(vol - expected) < 1.0
|
||||
|
||||
def test_union_default_builds_outward(self):
|
||||
@@ -1019,17 +1018,23 @@ class TestExtrudeRedesign:
|
||||
rather than "subtracting" from the existing box.
|
||||
"""
|
||||
import math
|
||||
|
||||
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||
face_geom = self._add_circle(sk, r=10.0)
|
||||
result = win._compute_extrude_result(
|
||||
sketch, face_geom,
|
||||
length=10.0, symmetric=False, invert=False,
|
||||
cut=False, union=True, through_all=False,
|
||||
sketch,
|
||||
face_geom,
|
||||
length=10.0,
|
||||
symmetric=False,
|
||||
invert=False,
|
||||
cut=False,
|
||||
union=True,
|
||||
through_all=False,
|
||||
)
|
||||
assert result is not None
|
||||
vol = self._geometry_volume(win, result["result_geom"])
|
||||
# 100^3 + pi*100*10 — material added on top.
|
||||
expected = 100.0 ** 3 + math.pi * (10.0 ** 2) * 10.0
|
||||
expected = 100.0**3 + math.pi * (10.0**2) * 10.0
|
||||
assert abs(vol - expected) < 1.0
|
||||
|
||||
def test_plain_extrude_untouched_by_source_body(self):
|
||||
@@ -1037,9 +1042,14 @@ class TestExtrudeRedesign:
|
||||
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||
face_geom = self._add_circle(sk, r=10.0)
|
||||
result = win._compute_extrude_result(
|
||||
sketch, face_geom,
|
||||
length=10.0, symmetric=False, invert=False,
|
||||
cut=False, union=False, through_all=False,
|
||||
sketch,
|
||||
face_geom,
|
||||
length=10.0,
|
||||
symmetric=False,
|
||||
invert=False,
|
||||
cut=False,
|
||||
union=False,
|
||||
through_all=False,
|
||||
)
|
||||
assert result is not None
|
||||
# No boolean target; result is the standalone tool extrusion.
|
||||
@@ -1047,7 +1057,8 @@ class TestExtrudeRedesign:
|
||||
vol = self._geometry_volume(win, result["result_geom"])
|
||||
# Standalone cylinder 10 mm tall.
|
||||
import math
|
||||
assert abs(vol - math.pi * (10.0 ** 2) * 10.0) < 1.0
|
||||
|
||||
assert abs(vol - math.pi * (10.0**2) * 10.0) < 1.0
|
||||
|
||||
def test_freshly_picked_sketch_is_auto_selected(self):
|
||||
"""After _on_face_picked, the new sketch is the current list row.
|
||||
@@ -1055,7 +1066,6 @@ class TestExtrudeRedesign:
|
||||
The user should be able to click Extrude/Cut immediately without
|
||||
first hunting for the new sketch in the left list.
|
||||
"""
|
||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||
win, _, sk, box_obj = self._make_window_with_box(100.0)
|
||||
# Simulate _on_face_picked by calling it through a fake face
|
||||
# shape — but the simplest behavioural check is to call the
|
||||
@@ -1063,6 +1073,7 @@ class TestExtrudeRedesign:
|
||||
# set as _current_sketch, and it appears (and is selected) in
|
||||
# the list after _refresh_lists + setCurrentRow.
|
||||
from fluency.models.data_model import Sketch
|
||||
|
||||
sketch = Sketch(name="Sketch on face 99")
|
||||
sketch._source_body_id = "b1"
|
||||
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
||||
@@ -1084,8 +1095,10 @@ class TestExtrudeRedesign:
|
||||
def test_preview_callback_invoked_on_value_change(self):
|
||||
"""The live preview callback fires on spinbox/checkbox changes."""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
from fluency.main import ExtrudeDialog
|
||||
|
||||
@@ -1110,8 +1123,10 @@ class TestExtrudeRedesign:
|
||||
def test_preview_hidden_event_sends_none(self):
|
||||
"""hideEvent should deliver None to the callback so the host clears."""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
from fluency.main import ExtrudeDialog
|
||||
|
||||
|
||||
@@ -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