40 Commits

Author SHA1 Message Date
bklronin 9abeb6266a - Assembly instaiated operations
- assembly forward proagation
2026-08-19 20:14:14 +02:00
bklronin 6b6f7de5ab Fiexed highlighting of operations 2026-08-19 00:11:24 +02:00
bklronin b184ade967 - added iso sheet for tech draw 2026-08-18 16:12:09 +02:00
bklronin 67b73c13b8 - tech drawing and render improv 2026-08-18 15:06:51 +02:00
bklronin 813ddc3596 - tech draw draft v2 2026-08-17 21:39:20 +02:00
bklronin 37e5335446 - tech draw draft v2 2026-08-17 18:35:18 +02:00
bklronin 108ad2d5b5 - tech draw draft v2 2026-08-16 22:01:36 +02:00
bklronin 1eee203ba8 - tech draw draft 2026-08-16 15:54:41 +02:00
bklronin a00129603c - Operation highlighting, body highlighting 2026-08-08 13:49:59 +02:00
bklronin 3d63f033f2 - Operation highlighting, body highlighting 2026-08-08 13:46:12 +02:00
bklronin b29bc11b42 - Operation highlighting, body highlighting 2026-08-08 11:15:49 +02:00
bklronin 79ad85594e - arc improvements, fillets, operations, bodys 2026-08-07 19:55:15 +02:00
bklronin 655df71cd8 - arc improvements, fillets, operations, bodys 2026-08-07 15:44:53 +02:00
bklronin 7072dcee08 - arc improvements, fillets, operations, bodys 2026-08-05 18:53:48 +02:00
bklronin 1e9b0cab1c - arc improvements, fillets, operations, bodys 2026-08-05 16:47:25 +02:00
bklronin a7e7a9f2c3 - arc improvements, fillets, operations, bodys 2026-08-05 09:44:17 +02:00
bklronin 48042659fc - arc improvements, fillets, operations, bodys 2026-08-02 22:04:57 +02:00
bklronin baa2fd5d47 - added "measurement lines" 2026-08-02 20:58:43 +02:00
bklronin 0daa6152ee - added "measurement lines" 2026-07-26 21:39:47 +02:00
bklronin 9f1c29d319 - added contrain context menu
- improved line pickability.
2026-07-19 16:07:11 +02:00
bklronin 2037106c0d - added contrain context menu
- improved line pickability.
2026-07-19 11:18:10 +02:00
bklronin d6e829c23d Improved render previews 2026-07-18 23:06:42 +02:00
bklronin 742d06d242 - Render improvements, camera plane, update 2026-07-13 23:01:35 +02:00
bklronin c78d0af78c - added renderer
- Added undo
2026-07-13 06:54:21 +02:00
bklronin dda9db822b - added renderer
- Added undo
2026-07-12 23:25:59 +02:00
bklronin 9f1387fe68 - added renderer
- Added undo
2026-07-12 22:21:43 +02:00
bklronin 210e3cfb5d - added renderer 2026-07-12 22:21:20 +02:00
bklronin b8516fff91 - Working assembly multi :) 2026-07-11 21:42:08 +02:00
bklronin d7e5929a13 - Working assembly multi :) 2026-07-11 21:29:58 +02:00
bklronin 2b2afbc479 - Added save file foramt
- Split main.py refactor
2026-07-11 15:39:30 +02:00
bklronin b0aebdc04f - Added save file foramt
- Split main.py refactor
2026-07-11 09:34:38 +02:00
bklronin be22c44a3f - Added save file foramt
- Split main.py refactor
2026-07-07 22:40:40 +02:00
bklronin 80ba3cc70a - Added save file foramt
- Split main.py refactor
2026-07-07 21:51:27 +02:00
bklronin 5269c0897c - Added save file foramt
- Split main.py refactor
2026-07-05 22:16:08 +02:00
bklronin 3a169007f7 - assembly draft 2026-07-05 19:36:27 +02:00
bklronin b595b88e04 - assembly draft 2026-07-05 10:16:49 +02:00
bklronin 9f10a5c5e5 - UI refinement, button position ui file as source no dirty drafting anymore 2026-07-04 16:16:04 +02:00
bklronin 6ba742ddf5 - sketch enhacements 2026-07-04 12:10:58 +02:00
bklronin 01833e4af2 - sketch enhacements 2026-07-03 21:49:05 +02:00
bklronin f860ff3e77 - removed cadquery deoendency 2026-07-01 20:03:00 +02:00
52 changed files with 38062 additions and 7354 deletions
+38
View File
@@ -36,3 +36,41 @@ uv.lock
.vscode/ .vscode/
*.swp *.swp
*.swo *.swo
/src/fluency/Tesfiles/bordo_adapter3.fluency
/CONSTRAINT_STATUS_FINAL.md
/CONSTRAINT_STATUS_IMPLEMENTATION.md
/src/fluency/rendering/first.png
/littlebrother.md
/src/fluency/Tesfiles/multiboidy.fluency
/src/fluency/rendering/nromal_test.png
/package.json
/package-lock.json
/src/fluency/Screenshot 2026-06-28 at 17.57.52.png
/src/fluency/rendering/Screenshot 2026-07-12 at 16.54.14.png
/src/fluency/Screenshot 2026-07-26 at 20.23.42.png
/Screenshot 2026-08-05 at 10.14.15.png
/Screenshot 2026-08-05 at 11.15.33.png
/Screenshot 2026-08-05 at 11.22.42.png
/SURFACE_MODIFIER_PLAN.md
/test.step
/src/fluency/test333.step
/src/fluency/tests/test_arc_attached_to_rectangle.py
/src/fluency/tests/test_array_pattern.py
/src/fluency/tests/test_chamfer.py
/src/fluency/tests/test_circle_diameter_constraint.py
/tests/test_distance_constraint_picking.py
/tests/test_extrude_geometry.py
/src/fluency/tests/test_feature_replay.py
/src/fluency/tests/test_fillet.py
/tests/test_mirror.py
/test_modifier.py
/src/fluency/tests/test_projection_constraints.py
/tests/test_re_extrude.py
/scripts/test_render_zoom.py
/test_thread.py
/test_thread_hole.step
/test_thread_m3.step
/test_thread_m5_hole.step
/test_thread_shaft.step
/test_thread_tilted.step
/src/fluency/testpart.step
+311 -66
View File
@@ -4,13 +4,17 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Tons of addtions"> <list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="Fiexed highlighting of operations">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/main.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/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/tests/test_geometry.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_geometry.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> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -27,7 +31,7 @@
<component name="Git.Settings"> <component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY"> <option name="RECENT_BRANCH_BY_REPOSITORY">
<map> <map>
<entry key="$PROJECT_DIR$" value="single_window" /> <entry key="$PROJECT_DIR$" value="feature/surface-modifier-pyramid-patterns" />
</map> </map>
</option> </option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -52,17 +56,26 @@
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;, &quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
&quot;Python.3d_windows.executor&quot;: &quot;Run&quot;, &quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
&quot;Python.Unnamed.executor&quot;: &quot;Run&quot;, &quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
&quot;Python.base.executor&quot;: &quot;Run&quot;,
&quot;Python.data_model.executor&quot;: &quot;Run&quot;,
&quot;Python.debug_dragging.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;, &quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;, &quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
&quot;Python.fluency.executor&quot;: &quot;Run&quot;, &quot;Python.fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.fluencyb.executor&quot;: &quot;Run&quot;, &quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
&quot;Python.gl_widget.executor&quot;: &quot;Run&quot;, &quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.gui_ui.executor&quot;: &quot;Run&quot;,
&quot;Python.kernel.executor&quot;: &quot;Run&quot;, &quot;Python.kernel.executor&quot;: &quot;Run&quot;,
&quot;Python.main.executor&quot;: &quot;Run&quot;, &quot;Python.main.executor&quot;: &quot;Run&quot;,
&quot;Python.main_window.executor&quot;: &quot;Run&quot;,
&quot;Python.meshtest.executor&quot;: &quot;Run&quot;, &quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;, &quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.render_backend.executor&quot;: &quot;Run&quot;,
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;, &quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;, &quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.sketch.executor&quot;: &quot;Run&quot;,
&quot;Python.technical_drawing_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;, &quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;, &quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;, &quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
@@ -89,8 +102,9 @@
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency" /> <recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" /> <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$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" />
</key> </key>
<key name="MoveFile.RECENT_KEYS"> <key name="MoveFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$" /> <recent name="$PROJECT_DIR$" />
@@ -112,54 +126,6 @@
<option name="presentableId" value="Default" /> <option name="presentableId" value="Default" />
<updated>1703867682707</updated> <updated>1703867682707</updated>
</task> </task>
<task id="LOCAL-00001" summary="init">
<option name="closed" value="true" />
<created>1703951701948</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1703951701948</updated>
</task>
<task id="LOCAL-00002" summary="- Basic oop sketch widget implement">
<option name="closed" value="true" />
<created>1729958532384</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1729958532384</updated>
</task>
<task id="LOCAL-00003" summary="- Sketch projection partly works again :)">
<option name="closed" value="true" />
<created>1735563255455</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1735563255455</updated>
</task>
<task id="LOCAL-00004" summary="- Sketch projection partly works again :)">
<option name="closed" value="true" />
<created>1735585968733</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1735585968733</updated>
</task>
<task id="LOCAL-00005" summary="- Added new componnt controls">
<option name="closed" value="true" />
<created>1735601610504</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1735601610504</updated>
</task>
<task id="LOCAL-00006" summary="- Added new componnt controls">
<option name="closed" value="true" />
<created>1735601786207</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1735601786207</updated>
</task>
<task id="LOCAL-00007" summary="- changing compos for sketches works"> <task id="LOCAL-00007" summary="- changing compos for sketches works">
<option name="closed" value="true" /> <option name="closed" value="true" />
<created>1735652081552</created> <created>1735652081552</created>
@@ -280,7 +246,279 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1782679912834</updated> <updated>1782679912834</updated>
</task> </task>
<option name="localTasksCounter" value="22" /> <task id="LOCAL-00022" summary="- Basic operations">
<option name="closed" value="true" />
<created>1782768610475</created>
<option name="number" value="00022" />
<option name="presentableId" value="LOCAL-00022" />
<option name="project" value="LOCAL" />
<updated>1782768610475</updated>
</task>
<task id="LOCAL-00023" summary="- removed cadquery deoendency">
<option name="closed" value="true" />
<created>1782928990792</created>
<option name="number" value="00023" />
<option name="presentableId" value="LOCAL-00023" />
<option name="project" value="LOCAL" />
<updated>1782928990792</updated>
</task>
<task id="LOCAL-00024" summary="- sketch enhacements">
<option name="closed" value="true" />
<created>1783108151675</created>
<option name="number" value="00024" />
<option name="presentableId" value="LOCAL-00024" />
<option name="project" value="LOCAL" />
<updated>1783108151676</updated>
</task>
<task id="LOCAL-00025" summary="- sketch enhacements">
<option name="closed" value="true" />
<created>1783159860774</created>
<option name="number" value="00025" />
<option name="presentableId" value="LOCAL-00025" />
<option name="project" value="LOCAL" />
<updated>1783159860774</updated>
</task>
<task id="LOCAL-00026" summary="- UI refinement, button position ui file as source no dirty drafting anymore">
<option name="closed" value="true" />
<created>1783174566362</created>
<option name="number" value="00026" />
<option name="presentableId" value="LOCAL-00026" />
<option name="project" value="LOCAL" />
<updated>1783174566362</updated>
</task>
<task id="LOCAL-00027" summary="- assembly draft">
<option name="closed" value="true" />
<created>1783239410744</created>
<option name="number" value="00027" />
<option name="presentableId" value="LOCAL-00027" />
<option name="project" value="LOCAL" />
<updated>1783239410744</updated>
</task>
<task id="LOCAL-00028" summary="- assembly draft">
<option name="closed" value="true" />
<created>1783272988957</created>
<option name="number" value="00028" />
<option name="presentableId" value="LOCAL-00028" />
<option name="project" value="LOCAL" />
<updated>1783272988957</updated>
</task>
<task id="LOCAL-00029" summary="- Added save file foramt&#10;- Split main.py refactor">
<option name="closed" value="true" />
<created>1783282570014</created>
<option name="number" value="00029" />
<option name="presentableId" value="LOCAL-00029" />
<option name="project" value="LOCAL" />
<updated>1783282570014</updated>
</task>
<task id="LOCAL-00030" summary="- Added save file foramt&#10;- 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&#10;- 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&#10;- 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&#10;- 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&#10;- 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&#10;- 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&#10;- 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 &quot;measurement lines&quot;">
<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 &quot;measurement lines&quot;">
<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 /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -301,16 +539,6 @@
<ignored-roots> <ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" /> <path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots> </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&#10;- Cut working&#10;- Edit sketch working" />
<MESSAGE value="- delete sketch working&#10;- added mid point snap&#10;- added hovering line with distance" />
<MESSAGE value="- Added new buttons and settings" />
<MESSAGE value="- Added construction lines switching&#10;- Moved callbacks into sketchwidget from main.&#10;- Changed reset on right click" /> <MESSAGE value="- Added construction lines switching&#10;- Moved callbacks into sketchwidget from main.&#10;- Changed reset on right click" />
<MESSAGE value="- Added contrain displayed next to line&#10;- Slight change to point check from solver." /> <MESSAGE value="- Added contrain displayed next to line&#10;- Slight change to point check from solver." />
<MESSAGE value="- Added enabling of midpsnap and prepared others&#10;- Show dimesnion on hover" /> <MESSAGE value="- Added enabling of midpsnap and prepared others&#10;- Show dimesnion on hover" />
@@ -319,6 +547,23 @@
<MESSAGE value="- added screenshot" /> <MESSAGE value="- added screenshot" />
<MESSAGE value="- added sdf folder ( doesnt work via pip or git=)" /> <MESSAGE value="- added sdf folder ( doesnt work via pip or git=)" />
<MESSAGE value="- Tons of addtions" /> <MESSAGE value="- Tons of addtions" />
<option name="LAST_COMMIT_MESSAGE" value="- Tons of addtions" /> <MESSAGE value="- Basic operations" />
<MESSAGE value="- removed cadquery deoendency" />
<MESSAGE value="- sketch enhacements" />
<MESSAGE value="- UI refinement, button position ui file as source no dirty drafting anymore" />
<MESSAGE value="- assembly draft" />
<MESSAGE value="- Added save file foramt&#10;- Split main.py refactor" />
<MESSAGE value="- Working assembly multi :)" />
<MESSAGE value="- added renderer" />
<MESSAGE value="- added renderer&#10;- Added undo" />
<MESSAGE value="- Render improvements, camera plane, update" />
<MESSAGE value="- added &quot;measurement lines&quot;" />
<MESSAGE value="- arc improvements, fillets, operations, bodys" />
<MESSAGE value="- Operation highlighting, body highlighting" />
<MESSAGE value="- tech draw draft" />
<MESSAGE value="- tech draw draft v2" />
<MESSAGE value="- tech drawing and render improv" />
<MESSAGE value="Fiexed highlighting of operations" />
<option name="LAST_COMMIT_MESSAGE" value="Fiexed highlighting of operations" />
</component> </component>
</project> </project>
+376
View File
@@ -0,0 +1,376 @@
# ── Sheet layout regions ────────────────────────────────────────────────────
_LAYOUT_MARGIN_MM = 10.0
_VIEW_GAP_MM = 12.0
# Title block box (see _title_block_primitives): 180 × 52 at the bottom-right
# corner with a 5 mm sheet margin. Views must clear it (plus clearance).
_TB_LEFT_MM = _A3_WIDTH_MM - 180.0 - 5.0
_TB_TOP_MM = 5.0 + 52.0
_TB_CLEARANCE_MM = 5.0
# Sheet interior (border margin) as (x0, y0, x1, y1) in sheet mm.
_SHEET_INNER = (
_LAYOUT_MARGIN_MM,
_LAYOUT_MARGIN_MM,
_A3_WIDTH_MM - _LAYOUT_MARGIN_MM,
_A3_HEIGHT_MM - _LAYOUT_MARGIN_MM,
)
# Regions the orthographic layout may occupy, as (x0, y0, w, h):
# - UPPER: full sheet width above the title block
# - LEFT: full sheet height in the left strip beside the title block
_REGION_UPPER = (
_LAYOUT_MARGIN_MM,
_TB_TOP_MM + _TB_CLEARANCE_MM,
_A3_WIDTH_MM - 2 * _LAYOUT_MARGIN_MM,
_A3_HEIGHT_MM - _LAYOUT_MARGIN_MM - (_TB_TOP_MM + _TB_CLEARANCE_MM),
)
_REGION_LEFT = (
_LAYOUT_MARGIN_MM,
_LAYOUT_MARGIN_MM,
_TB_LEFT_MM - _TB_CLEARANCE_MM - _LAYOUT_MARGIN_MM,
_A3_HEIGHT_MM - 2 * _LAYOUT_MARGIN_MM,
)
_MID_ORDER = ("left", "front", "right", "back")
_COL_ORDER = ("top", "front", "bottom") # sheet top → bottom
_GRID_ORDER = ("front", "right", "back", "top", "left", "bottom")
_RectList = List[Tuple[str, float, float, float, float]]
def _place_cross(
dims: Dict[str, Tuple[float, float]], gap: float
) -> _RectList:
"""Classic third-angle cross: mid views run left→right (left, front,
right, back), col views stack top→bottom (top, front, bottom), with the
anchor view (front, or the first present one) at the intersection.
*dims* maps view id → ``(w, h)`` in sheet units. Returns local
``(vid, x, y, w, h)`` rects (origin arbitrary — the caller centres the
union on the sheet).
"""
mid = [k for k in _MID_ORDER if k in dims]
col = [k for k in _COL_ORDER if k in dims]
if not mid and not col:
return []
rects: Dict[str, Tuple[float, float, float, float]] = {}
if col:
# Stack bottom → top.
y = 0.0
for k in reversed(col):
w, h = dims[k]
rects[k] = (0.0, y, w, h)
y += h + gap
col_h = y - gap
cx = max(dims[k][0] for k in col) / 2.0
for k in col:
_x, yy, w, h = rects[k]
rects[k] = (cx - w / 2.0, yy, w, h)
else:
col_h = 0.0
cx = 0.0
anchor = "front" if "front" in dims else (mid[0] if mid else col[0])
if anchor in rects:
ax, ay, aw, _ah = rects[anchor]
anchor_cy = ay + _ah / 2.0
else:
aw, ah = dims[anchor]
ax = cx - aw / 2.0
ay = col_h / 2.0 - ah / 2.0
rects[anchor] = (ax, ay, aw, ah)
anchor_cy = col_h / 2.0
ia = mid.index(anchor) if anchor in mid else -1
x = ax
for k in reversed(mid[:ia]):
w, h = dims[k]
x -= w + gap
rects[k] = (x, anchor_cy - h / 2.0, w, h)
x = ax + aw
for k in mid[ia + 1 :]:
w, h = dims[k]
x += gap
rects[k] = (x, anchor_cy - h / 2.0, w, h)
x += w
return [(k, *r) for k, r in rects.items()]
def _place_swapped(dims: Dict[str, Tuple[float, float]], gap: float) -> _RectList:
"""Cross with the view families swapped: the mid views stack vertically
(left, front, right, back from the top) and the col views run
horizontally (bottom, front, top from the left) — the classic cross
turned a quarter turn, for sheets where that orientation fits more.
"""
mid = [k for k in _MID_ORDER if k in dims]
col = [k for k in _COL_ORDER if k in dims]
if not mid or not col:
return []
rects: Dict[str, Tuple[float, float, float, float]] = {}
cx = max(dims[k][0] for k in mid) / 2.0
y = 0.0
for k in mid: # top → bottom
w, h = dims[k]
rects[k] = (cx - w / 2.0, y, w, h)
y += h + gap
anchor = "front" if "front" in dims else mid[0]
anchor_cy = rects[anchor][1] + dims[anchor][1] / 2.0
row: Dict[str, Tuple[float, float, float, float]] = {}
x = 0.0
x_anchor = 0.0
for k in reversed(col): # bottom, front, top → left to right
w, h = dims[k]
if k == anchor:
x_anchor = x
row[k] = (x, anchor_cy - h / 2.0, w, h)
x += w + gap
shift = rects[anchor][0] - x_anchor
for k, r in row.items():
if k == anchor:
continue
x0, y0, w, h = r
rects[k] = (x0 + shift, y0, w, h)
return [(k, *r) for k, r in rects.items()]
def _place_grid(
dims: Dict[str, Tuple[float, float]], gap: float, rows: int
) -> _RectList:
"""Wrap the present standard views into a grid of *rows* rows, filled
bottom→top and left→right (so the primary views sit near the bottom,
like in the cross)."""
order = [k for k in _GRID_ORDER if k in dims]
if not order:
return []
cols = max(1, -(-len(order) // rows))
rects: _RectList = []
y = 0.0
for r in range(rows):
chunk = order[r * cols : (r + 1) * cols]
if not chunk:
break
x = 0.0
row_h = 0.0
for k in chunk:
w, h = dims[k]
rects.append((k, x, y, w, h))
x += w + gap
row_h = max(row_h, h)
y += row_h + gap
return rects
def _fit_scale(
place: Callable[
[Dict[str, Tuple[float, float]], float], _RectList
],
dims_m: Dict[str, Tuple[float, float]],
region: Tuple[float, float, float, float],
) -> float:
"""Largest shared scale at which *dims_m* (model units) laid out by
*place* fits the ``(x0, y0, w, h)`` *region* of the sheet.
The union size grows monotonically with the scale, so a bisection
converges to the tight fit.
"""
_rx0, _ry0, rw, rh = region
def fits(s: float) -> bool:
rects = place(
{k: (w * s, h * s) for k, (w, h) in dims_m.items()}, _VIEW_GAP_MM
)
if not rects:
return True
minx = min(r[1] for r in rects)
miny = min(r[2] for r in rects)
maxx = max(r[1] + r[3] for r in rects)
maxy = max(r[2] + r[4] for r in rects)
return (maxx - minx) <= rw + 1e-9 and (maxy - miny) <= rh + 1e-9
s_lo, s_hi = 0.0, 1.0
if fits(s_hi):
s_lo = s_hi
while s_hi < 1.0e6 and fits(s_hi * 2.0):
s_hi *= 2.0
for _ in range(60):
mid = 0.5 * (s_lo + s_hi)
if fits(mid):
s_lo = mid
else:
s_hi = mid
return s_lo
def _free_rects(
used_rects: Sequence[Tuple[float, float, float, float]]
) -> List[Tuple[float, float, float, float]]:
"""Axis-aligned free rects ``(x0, y0, w, h)`` around *used_rects*,
clearing the sheet border and the title block zone."""
ix0, iy0, ix1, iy1 = _SHEET_INNER
if used_rects:
ux0 = min(r[0] for r in used_rects)
uy0 = min(r[1] for r in used_rects)
ux1 = max(r[0] + r[2] for r in used_rects)
uy1 = max(r[1] + r[3] for r in used_rects)
cands = [
(ux1 + _VIEW_GAP_MM, iy0, ix1, iy1), # right of the used block
(ix0, iy0, ux0 - _VIEW_GAP_MM, iy1), # left
(ix0, uy1 + _VIEW_GAP_MM, ix1, iy1), # above
(ix0, iy0, ix1, uy0 - _VIEW_GAP_MM), # below
]
else:
cands = [(ix0, iy0, ix1, iy1)]
tb = (
_TB_LEFT_MM - _TB_CLEARANCE_MM,
0.0,
_A3_WIDTH_MM - (_TB_LEFT_MM - _TB_CLEARANCE_MM),
_TB_TOP_MM + _TB_CLEARANCE_MM,
)
out: List[Tuple[float, float, float, float]] = []
for x0, y0, x1, y1 in cands:
x0, y0 = max(x0, ix0), max(y0, iy0)
x1, y1 = min(x1, ix1), min(y1, iy1)
if x1 - x0 < 1.0 or y1 - y0 < 1.0:
continue
if not (x1 <= tb[0] or tb[2] <= x0 or y1 <= tb[1] or tb[3] <= y0):
# Overlaps the title block zone — keep the parts above/left of it.
subs = [
(x0, max(y0, tb[3]), x1, y1),
(x0, y0, min(x1, tb[0]), y1),
]
else:
subs = [(x0, y0, x1, y1)]
for sx0, sy0, sx1, sy1 in subs:
if sx1 - sx0 > 1.0 and sy1 - sy0 > 1.0:
out.append((sx0, sy0, sx1 - sx0, sy1 - sy0))
return out
def _layout_views_on_sheet(
views: Sequence[DrawingView],
bboxes: Dict[str, Tuple[float, float, float, float]],
) -> Tuple[
Dict[str, Tuple[float, float, float, float]],
Optional[float],
Dict[str, float],
]:
"""Compute a slot rectangle and sheet rotation for each view.
*bboxes* maps view_id → ``(min_x, min_y, max_x, max_y)`` in model
units (from :func:`_edges_bounds`). Returns ``(slots, common_scale,
rotations)``: slots are ``(left, bottom, width, height)`` in sheet mm
(origin at the sheet's bottom-left corner, +y up), common_scale is the
shared model→sheet scale of the standard orthographic views, and
rotations maps view_id → sheet rotation in degrees (0 or 90).
The sheet is filled, not just used: every candidate arrangement
(classic third-angle cross, the cross with the view families swapped,
and 1/2/3-row grids) is combined with every per-view 90° rotation
assignment, and the candidate giving the largest shared scale is used.
Candidates within 0.5% of the best scale prefer the one with fewer
rotated views, then the more conventional arrangement, so layouts stay
stable and standard whenever they are already the best fit. All
orthographic views share one scale so the projections stay mutually
consistent. Isometric and custom views take the largest remaining
free rect (clearing the title block).
"""
slots: Dict[str, Tuple[float, float, float, float]] = {}
rotations: Dict[str, float] = {}
common_scale: Optional[float] = None
ortho = [
v for v in views if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
]
used_rects: List[Tuple[float, float, float, float]] = []
if ortho:
dims0: Dict[str, Tuple[float, float]] = {}
for v in ortho:
b = bboxes.get(v.kind)
vs = max(v.scale, 1e-9)
if b is None:
dims0[v.kind] = (1.0 * vs, 1.0 * vs)
else:
dims0[v.kind] = (
max(b[2] - b[0], 1e-6) * vs,
max(b[3] - b[1], 1e-6) * vs,
)
keys = list(dims0)
arrangements: Tuple[
Tuple[str, int, Callable[[Dict[str, Tuple[float, float]], float], _RectList]]
] = (
("cross", 0, _place_cross),
("swapped", 1, _place_swapped),
("grid1", 2, lambda d, g: _place_grid(d, g, 1)),
("grid2", 3, lambda d, g: _place_grid(d, g, 2)),
("grid3", 4, lambda d, g: _place_grid(d, g, 3)),
)
cands: List[
Tuple[float, int, int, Tuple[float, float, float, float],
Dict[str, Tuple[float, float]],
Callable[[Dict[str, Tuple[float, float]], float], _RectList],
List[bool]]
] = []
for mask in range(1 << len(keys)):
rotated = [bool(mask & (1 << i)) for i in range(len(keys))]
dims_m = {
k: (
dims0[k][1] if rotated[i] else dims0[k][0],
dims0[k][0] if rotated[i] else dims0[k][1],
)
for i, k in enumerate(keys)
}
for _name, rank, place in arrangements:
s_up = _fit_scale(place, dims_m, _REGION_UPPER)
s_left = _fit_scale(place, dims_m, _REGION_LEFT)
if s_up >= s_left:
s, region = s_up, _REGION_UPPER
else:
s, region = s_left, _REGION_LEFT
if s <= 0.0:
continue
cands.append((s, sum(rotated), rank, region, dims_m, place, rotated))
if cands:
best_s = max(c[0] for c in cands)
s, _nrot, _rank, region, dims_m, place, rotated = min(
(c for c in cands if c[0] >= best_s * 0.995),
key=lambda c: (c[1], c[2], -c[0]),
)
rx0, _ry0, rw, rh = region
ds = {k: (w * s, h * s) for k, (w, h) in dims_m.items()}
rects = place(ds, _VIEW_GAP_MM)
minx = min(r[1] for r in rects)
miny = min(r[2] for r in rects)
maxx = max(r[1] + r[3] for r in rects)
maxy = max(r[2] + r[4] for r in rects)
ox = rx0 + (rw - (maxx - minx)) / 2.0
oy = _ry0 + (rh - (maxy - miny)) / 2.0
for vid, x, y, w, h in rects:
slots[vid] = (x - minx + ox, y - miny + oy, w, h)
rotations[vid] = 90.0 if rotated[keys.index(vid)] else 0.0
common_scale = s
used_rects = [(ox, oy, maxx - minx, maxy - miny)]
# Isometric and custom views: the largest remaining free rect each,
# clearing the title block.
extra = [
v for v in views
if v.kind == "isometric" or v.kind not in _STANDARD_VIEWS
]
assigned = list(used_rects)
for i, v in enumerate(extra):
vid = v.kind if v.kind in _STANDARD_VIEWS else (v.name or v.id)
free = _free_rects(assigned)
if free:
slot = max(free, key=lambda r: r[2] * r[3])
else:
# No free rect left — park in the bottom-left corner stack.
slot = (_LAYOUT_MARGIN_MM, _LAYOUT_MARGIN_MM + i * 60.0, 120.0, 50.0)
slots[vid] = slot
assigned.append(slot)
return slots, common_scale, rotations
+50
View File
@@ -0,0 +1,50 @@
import os, sys
os.environ["QT_QPA_PLATFORM"] = "offscreen"
sys.path.insert(0, "/Volumes/Data_drive/Programming/fluency/src")
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QPixmap, QPainter, QColor
from PySide6.QtCore import QRectF
import math
app = QApplication.instance() or QApplication([])
from fluency.geometry.base import Point2D
from fluency.geometry_occ.kernel import OCGeometryKernel
from fluency.models.data_model import Body, Component, Project, DrawingView, TechnicalDrawing
from fluency.technical_drawing import generate_drawing, render_drawing, _A3_WIDTH_MM, _A3_HEIGHT_MM
kernel = OCGeometryKernel()
# Long thin bar: 120 x 25 x 30 (matches the "wide bar" screenshot case).
points = [Point2D(0, 0), Point2D(120, 0), Point2D(120, 25), Point2D(0, 25)]
box = kernel.extrude(kernel.create_polygon(points), 30.0)
body = Body(name="Bar", geometry=box)
comp = Component(name="BarComp")
comp.bodies[body.id] = body
project = Project()
project.components[comp.id] = comp
project.active_component = comp.id
W = 2400
H = int(W * _A3_HEIGHT_MM / _A3_WIDTH_MM)
pm = QPixmap(W, H)
pm.fill(QColor(255, 255, 255))
for name, kinds in [
("four", ["front", "top", "right", "back"]),
("six", ["front", "top", "right", "left", "back", "bottom"]),
("sixiso", ["front", "top", "right", "left", "back", "bottom", "isometric"]),
]:
drawing = TechnicalDrawing(
source_kind="component", source_id=comp.id,
views=[DrawingView(kind=k) for k in kinds],
auto_dimensions=True, title=name,
)
result = generate_drawing(drawing, project, kernel)
p = QPainter(pm)
render_drawing(p, result, QRectF(0, 0, W, H))
p.end()
out = f"/tmp/drawing_{name}.png"
pm.save(out)
print(name, "saved", out, "prims", len(result.primitives), "scale",
round(result.view_transforms.get("front", (None,))[0] or 0, 3))
+159
View File
@@ -0,0 +1,159 @@
"""Smoke test: layout optimizer fills the page, no overlaps, title block clear."""
import math
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, "/Volumes/Data_drive/Programming/fluency/src")
from fluency.models.data_model import DrawingView
from fluency.technical_drawing import (
_layout_views_on_sheet,
build_manual_candidates,
)
A3W, A3H = 420.0, 297.0
TB = (235.0, 0.0, 420.0, 62.0) # title block zone incl. clearance
def b(x, y, w, h):
return (x, y, x + w, y + h)
def overlaps(r1, r2, clear=0.0):
x0, y0, w, h = r1
x1, y1, w2, h2 = r2
return not (x0 + w <= x1 + clear or x1 + w2 <= x0 + clear
or y0 + h <= y1 + clear or y1 + h2 <= y0 + clear)
def tb_overlap(r, clear=5.0):
x0, y0, w, h = r
x1, y1, w2, h2 = TB
return not (x0 + w <= x1 + clear or x1 + w2 <= x0 + clear
or y0 + h <= y1 + clear or y1 + h2 <= y0 + clear)
def union_rect(rects):
x0 = min(r[0] for r in rects)
y0 = min(r[1] for r in rects)
x1 = max(r[0] + r[2] for r in rects)
y1 = max(r[1] + r[3] for r in rects)
return (x0, y0, x1 - x0, y1 - y0)
def check(name, kinds, boxes, expect_rot=None):
views = [DrawingView(kind=k) for k in kinds]
bboxes = {k: boxes[k] for k in kinds}
slots, scale, rots = _layout_views_on_sheet(views, bboxes)
print(f"--- {name}: scale={scale:.4f} rots={rots}")
# all slots within sheet
for k, s in slots.items():
assert 10 - 1e-6 <= s[0] and 10 - 1e-6 <= s[1], f"{k} outside sheet {s}"
assert s[0] + s[2] <= A3W - 10 + 1e-6, f"{k} beyond right {s}"
assert s[1] + s[3] <= A3H - 10 + 1e-6, f"{k} beyond top {s}"
# no overlaps between slots
ks = list(slots)
for i in range(len(ks)):
for j in range(i + 1, len(ks)):
assert not overlaps(slots[ks[i]], slots[ks[j]], 11.9), \
f"{ks[i]} overlaps {ks[j]}: {slots[ks[i]]} / {slots[ks[j]]}"
# title block clear
for k, s in slots.items():
assert not tb_overlap(s), f"{k} intrudes title block {s}"
# fill report
u = union_rect(list(slots.values()))
area = u[2] * u[3]
print(f" union: x0={u[0]:.1f} y0={u[1]:.1f} w={u[2]:.1f} h={u[3]:.1f} "
f"area={area:.0f}mm^2 ({100*area/(A3W*A3H):.0f}% of sheet)")
for k in ks:
print(f" {k}: {tuple(round(v,1) for v in slots[k])}")
if expect_rot is not None:
assert rots == expect_rot, f"expected {expect_rot}, got {rots}"
return slots, scale, rots
# 1. Two square views (front+top): should fill the page, no rotation.
check("two square", ["front", "top"],
{"front": b(0, 0, 40, 40), "top": b(0, 0, 40, 20)})
# 2. Wide bar, 4 views (old screenshot case): front+back wide, top+right.
check("wide bar 4", ["front", "top", "right", "back"],
{"front": b(0, 0, 120, 25), "top": b(0, 0, 25, 40),
"right": b(0, 0, 25, 40), "back": b(0, 0, 120, 25)})
# 3. Six views of a long thin part: rotation should kick in.
check("thin part 6", ["front", "top", "right", "left", "back", "bottom"],
{"front": b(0, 0, 200, 30), "top": b(0, 0, 30, 50),
"right": b(0, 0, 50, 30), "left": b(0, 0, 50, 30),
"back": b(0, 0, 200, 30), "bottom": b(0, 0, 30, 50)})
# 4. Single front view: fills the whole page.
check("single", ["front"], {"front": b(0, 0, 10, 20)})
# 5. All 6 + isometric + custom.
check("everything",
["front", "top", "right", "left", "back", "bottom", "isometric"],
{"front": b(0, 0, 80, 40), "top": b(0, 0, 80, 30),
"right": b(0, 0, 30, 40), "left": b(0, 0, 30, 40),
"back": b(0, 0, 80, 40), "bottom": b(0, 0, 80, 30),
"isometric": b(0, 0, 60, 60)})
# ── Inverse-transform roundtrip ─────────────────────────────────────────
# A 90°-rotated view: forward via _assemble_view's recorded 6-tuple,
# inverse via the widget's formula.
t = (2.0, 150.0, 80.0, 90.0, 10.0, 5.0) # s, o_x, o_y, deg, cx, cy
scale, ox, oy, deg, cx, cy = t
th = math.radians(deg)
cos_t, sin_t = math.cos(th), math.sin(th)
def fwd(p):
dx, dy = p[0] - cx, p[1] - cy
return ((dx * cos_t - dy * sin_t) * scale + ox,
(dx * sin_t + dy * cos_t) * scale + oy)
def inv(p):
sx, sy = (p[0] - ox) / scale, (p[1] - oy) / scale
return (sx * cos_t + sy * sin_t + cx, -sx * sin_t + sy * cos_t + cy)
for p in [(0, 0), (10, 5), (3, -7), (42.5, 11.25)]:
rt = inv(fwd(p))
assert abs(rt[0] - p[0]) < 1e-9 and abs(rt[1] - p[1]) < 1e-9, (p, rt)
print("inverse roundtrip OK")
# Legacy 3-tuple still works through build_manual_candidates.
from fluency.models.data_model import DrawingAnnotation, TechnicalDrawing
d = TechnicalDrawing(source_kind="component", source_id="c")
ann = DrawingAnnotation(kind="dimension", dimension_kind="length",
view_id="front", anchors=[(0.0, 0.0), (0.0, 12.5)],
direction=(0.0, 1.0))
d.annotations.append(ann)
cands, res, unres = build_manual_candidates(d, {"front": (2.0, 10.0, 20.0)})
assert unres == [] and cands[0].anchor_points[1] == (10.0, 45.0)
print("legacy 3-tuple OK")
# 6-tuple manual: rotated length direction must rotate too.
ann2 = DrawingAnnotation(kind="dimension", dimension_kind="length",
view_id="front",
anchors=[(0.0, 0.0), (0.0, 10.0)],
direction=(0.0, 1.0))
d2 = TechnicalDrawing(source_kind="component", source_id="c")
d2.annotations.append(ann2)
# 90° rotation about centre c=(5,5), scale 2, o=(100,80)
cands, res, unres = build_manual_candidates(
d2, {"front": (2.0, 100.0, 80.0, 90.0, 5.0, 5.0)}
)
c = cands[0]
# anchors: (0,0)->rot90 about (5,5) = (5-(0-5)*0 - ... compute: dx=-5,dy=-5
# fwd: (dx*cos - dy*sin)*2+100 = (0 - (-5))*2+100 = 110 ; (dx*sin+dy*cos)*2+80 = (-5)*2+80=70
# (0,10): dx=-5, dy=5 -> (0-5)*2+100=90 ; (5*1+0)*2+80=70
assert c.anchor_points[0] == (110.0, 70.0), c.anchor_points
assert c.anchor_points[1] == (90.0, 70.0), c.anchor_points
# direction (0,1) rotated 90° CCW -> (-1, 0)
assert c.direction[0] == -1.0 and abs(c.direction[1]) < 1e-9, c.direction
print("6-tuple manual (rotated) OK")
print("ALL SMOKE CHECKS PASSED")
+238
View File
@@ -0,0 +1,238 @@
"""Headless test: connectors follow moved features across ALL assemblies.
Simulates the test-file scenario: a plate with a hole, mated hole-to-face
in two different assemblies. The hole is moved (rebuilt geometry) and the
body-update connector recalculation must:
1. re-locate the hole connector on every instance of the component
(both the active and a non-active assembly),
2. re-solve each mated pair so the partner parts follow,
3. never snap a planar connector onto the cylindrical hole (type match),
4. mark a connector invalid when its feature disappears.
5. auto-follow a FAR move when the candidate is unambiguous (single
feature of its class) — the demo case,
6. NOT auto-apply an ambiguous far candidate (another same-class feature
is nearer) — that needs a manual pick, simulated here.
7. backfill legacy connectors' entity_type from their auto-generated name.
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
import numpy as np
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
from PySide6.QtWidgets import QApplication
from fluency.ui.main_window import MainWindow
from fluency.models.data_model import Assembly, Body
from fluency.geometry_occ.kernel import OCCGeometryObject
app = QApplication.instance() or QApplication([])
w = MainWindow()
comp = w._current_component
def make_plate(hole_xy):
box = BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape()
ax = gp_Ax2(gp_Pnt(float(hole_xy[0]), float(hole_xy[1]), 0.0), gp_Dir(0, 0, 1))
cyl = BRepPrimAPI_MakeCylinder(ax, 3.0, 6.0).Shape()
return BRepAlgoAPI_Cut(box, cyl).Shape()
body = Body(name="plate")
comp.bodies[body.id] = body
body.geometry = OCCGeometryObject(make_plate((10.0, 5.0)))
partner = w._project.add_component()
pbody = Body(name="partner")
partner.bodies[pbody.id] = pbody
pbody.geometry = OCCGeometryObject(BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape())
def make_pair(asm):
ac1 = asm.add_component_instance(comp.id, name="A")
ac2 = asm.add_component_instance(partner.id, name="B")
ac1.position = np.zeros(3)
ac1.rotation = np.eye(3)
ac2.position = np.array([0.0, 0.0, 15.0])
ac2.rotation = np.eye(3)
ac1.geom_cache[body.id] = body.geometry
c1 = ac1.add_connector(
position=(10.0, 5.0, 2.5),
normal=(0.0, 0.0, 1.0),
x_dir=(1.0, 0.0, 0.0),
source_obj_id=f"asm_{ac1.id}_{body.id}",
name="Conn hole A",
entity_type="cylindrical_face",
)
c2 = ac2.add_connector(
position=(15.0, 15.0, 0.0),
normal=(0.0, 0.0, -1.0),
x_dir=(1.0, 0.0, 0.0),
source_obj_id=f"asm_{ac2.id}_{pbody.id}",
name="Conn face B",
entity_type="planar_face",
)
c1.is_grounded = True
c1.partner_ac_id = ac2.id
c1.partner_connector_id = c2.id
c2.partner_ac_id = ac1.id
c2.partner_connector_id = c1.id
aconn = asm.add_connection(ac1.id, ac2.id)
aconn.first_connector_id = c1.id
aconn.second_connector_id = c2.id
return ac1, ac2, c1, c2, aconn
asm1 = w._project.get_active_assembly()
asm2 = w._project.add_assembly(Assembly(name="second"))
pair1 = make_pair(asm1)
pair2 = make_pair(asm2)
assert w._project.active_assembly == asm1.id # asm2 is the NON-active one
# ── Move the hole: rebuild the plate with the hole at (25, 12) ──────────
new_geom = OCCGeometryObject(make_plate((25.0, 12.0)))
body.geometry = new_geom
for asm in (asm1, asm2):
for ac in asm.components.values():
if ac.component_id == comp.id:
ac.geom_cache[body.id] = new_geom
# ── The body-update auto path ───────────────────────────────────────────
w._recalculate_connectors()
for (ac1, ac2, c1, c2, aconn), label in ((pair1, "asm1"), (pair2, "asm2")):
# The hole connector followed the hole on every instance.
assert np.allclose(c1.position, (25.0, 12.0, 2.5), atol=1e-6), (label, c1.position)
# The mated pair re-aligned: both world connectors coincide.
w1 = ac1.position + ac1.rotation @ np.asarray(c1.position)
w2 = ac2.position + ac2.rotation @ np.asarray(c2.position)
assert np.allclose(w1, w2, atol=1e-6), (label, w1, w2)
print(f"{label}: connector at {np.round(c1.position, 3)}, "
f"partner moved to {np.round(ac2.position, 3)}")
# ── The 'Upd' button path: move the hole again, re-run the handler ─────
w._refresh_connection_list()
w._connection_list.setCurrentRow(0) # active assembly = asm1
geom3 = OCCGeometryObject(make_plate((35.0, 20.0)))
body.geometry = geom3
for ac in asm1.components.values():
if ac.component_id == comp.id:
ac.geom_cache[body.id] = geom3
w._on_update_connection_from_list()
ac1, ac2, c1, c2, aconn = pair1
assert np.allclose(c1.position, (35.0, 20.0, 2.5), atol=1e-6), c1.position
w1 = ac1.position + ac1.rotation @ np.asarray(c1.position)
w2 = ac2.position + ac2.rotation @ np.asarray(c2.position)
assert np.allclose(w1, w2, atol=1e-6), (w1, w2)
print("Upd button: connector at", np.round(c1.position, 3),
"partner at", np.round(ac2.position, 3))
# ── Type matching: a planar connector must not snap onto the hole ───────
ac1 = pair1[0]
c3 = ac1.add_connector(
position=(30.0, 20.0, 5.0),
normal=(0.0, 0.0, 1.0),
x_dir=(1.0, 0.0, 0.0),
source_obj_id=f"asm_{ac1.id}_{body.id}",
name="Conn face",
entity_type="planar_face",
)
res = w._redetect_connector_on_geometry(c3, ac1, comp)
assert res is not None, "planar connector candidate missing"
assert not c3.is_invalid, "pure relocator must not mutate the connector"
assert np.allclose(c3.position, (30.0, 20.0, 5.0), atol=1e-6), c3.position
assert np.allclose(res[1], (30.0, 20.0, 5.0), atol=1e-6), res[1]
print("planar connector stayed on the face:", np.round(res[1], 3))
# ── Feature removed: relocator finds nothing; auto path marks invalid ──
plain = OCCGeometryObject(BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape())
body.geometry = plain
ac1.geom_cache[body.id] = plain
res = w._redetect_connector_on_geometry(pair1[2], ac1, comp)
assert res is None, "hole connector should find no candidate on a plain box"
w._recalculate_connectors()
assert pair1[2].is_invalid, "auto path must mark the connector invalid"
print("removed feature -> connector marked invalid")
# ── Far move with a decoy: ambiguous candidate is NOT auto-applied ─────
# The real hole moved to (25, 12) — ~12.8mm from the stored (35, 20) — but
# a SECOND hole now sits at (28, 16), only ~8mm away. The nearest
# candidate is ambiguous (different feature), so the auto path must leave
# the connector alone and queue it for a manual pick.
def make_plate2(holes):
box = BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape()
for hx, hy in holes:
ax = gp_Ax2(gp_Pnt(hx, hy, 0.0), gp_Dir(0, 0, 1))
box = BRepAlgoAPI_Cut(box, BRepPrimAPI_MakeCylinder(ax, 3.0, 6.0).Shape()).Shape()
return box
# ── Stage 1: the user's demo — a SINGLE hole moved far away ─────────────
# 12.8mm from the stored position, but the only cylindrical face on the
# body → unambiguous → must be auto-applied (and the mate re-solved).
geom4 = OCCGeometryObject(make_plate2([(25.0, 12.0)]))
body.geometry = geom4
for asm in (asm1, asm2):
for ac in asm.components.values():
if ac.component_id == comp.id:
ac.geom_cache[body.id] = geom4
w._recalculate_connectors()
assert np.allclose(pair1[2].position, (25.0, 12.0, 2.5), atol=1e-6), \
"unique far candidate must be auto-applied (the demo case)"
assert not pair1[2].is_invalid
w1 = ac1.position + ac1.rotation @ np.asarray(pair1[2].position)
w2 = pair1[1].position + pair1[1].rotation @ np.asarray(pair1[3].position)
assert np.allclose(w1, w2, atol=1e-6), (w1, w2)
print("single far hole: auto-followed to", np.round(pair1[2].position, 3))
# ── Stage 2: far move with a decoy — ambiguous, NOT auto-applied ───────
# The real hole now sits at (32, 20) — 10.6mm from the stored (25, 12) —
# while a decoy hole at (21, 7) is only 6.4mm away. The nearest
# candidate is likely a DIFFERENT feature, so the auto path must leave
# the connector alone and queue it for a manual pick.
geom5 = OCCGeometryObject(make_plate2([(32.0, 20.0), (21.0, 7.0)]))
body.geometry = geom5
for asm in (asm1, asm2):
for ac in asm.components.values():
if ac.component_id == comp.id:
ac.geom_cache[body.id] = geom5
w._recalculate_connectors()
assert np.allclose(pair1[2].position, (25.0, 12.0, 2.5), atol=1e-6), \
"ambiguous far candidate must not be auto-applied"
assert not pair1[2].is_invalid, "ambiguous candidate is not a missing feature"
# Simulate the user clicking the REAL hole in the relocate pick flow:
w._relocate_pending = [(asm1, ac1, pair1[2])]
w._on_relocate_picked(
(32.0, 20.0, 2.5), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0),
"cylindrical_face", f"asm_{ac1.id}_{body.id}",
)
assert np.allclose(pair1[2].position, (32.0, 20.0, 2.5), atol=1e-6), pair1[2].position
assert not pair1[2].is_invalid, "manual pick must re-validate the connector"
assert w._relocate_pending is None, "pending queue must drain after the pick"
w1 = ac1.position + ac1.rotation @ np.asarray(pair1[2].position)
w2 = pair1[1].position + pair1[1].rotation @ np.asarray(pair1[3].position)
assert np.allclose(w1, w2, atol=1e-6), (w1, w2)
print("far move: manual pick re-homed connector at", np.round(pair1[2].position, 3),
"partner at", np.round(pair1[1].position, 3))
# ── Legacy backfill: empty entity_type recovered from the auto name ────
from fluency.models.data_model import Connector
legacy = Connector(
name="Conn cylindrical_face anchor",
position=(32.0, 20.0, 2.5),
source_obj_id=f"asm_{ac1.id}_{body.id}",
)
assert legacy.entity_type == "cylindrical_face", legacy.entity_type
res = w._redetect_connector_on_geometry(legacy, ac1, comp)
assert res is not None and res[0] < 1e-3, res
print("legacy name backfill: entity_type =", legacy.entity_type)
print("CONNECTOR_RELOCATE_OK")
+158
View File
@@ -0,0 +1,158 @@
"""Headless repro: load assemblytest.fluency, compare sketch circle centers
vs. saved body hole axes vs. connector positions, then run the real
body-update path and check where connectors land.
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
import numpy as np
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Cylinder
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS
from PySide6.QtWidgets import QApplication
from fluency.ui.main_window import MainWindow
def etype(e):
if isinstance(e, dict):
return e.get("type")
return getattr(e, "entity_type", None) or getattr(e, "type", None)
def egeom(e):
if isinstance(e, dict):
return e.get("geometry")
return getattr(e, "geometry", None)
app = QApplication.instance() or QApplication([])
w = MainWindow()
path = os.path.join(os.path.dirname(__file__), "assemblytest.fluency")
ok = w._open_project_file(path)
assert ok, "failed to open demo file"
proj = w._project
for comp in proj.components.values():
print(f"=== {comp.name} ({comp.id})")
for sk in comp.sketches.values():
occ = sk.occ_sketch
if occ is None:
print(" sketch with no occ_sketch:", sk.id)
continue
for e in occ._entities.values():
t = etype(e)
if t == "circle":
g = egeom(e)
cid = e.get("id") if isinstance(e, dict) else e.id
print(f" circle id={cid} center=({g[0][0]!r}, {g[0][1]!r}) r={g[1]!r}")
for body in comp.bodies.values():
if not body.geometry:
print(f" body {body.name}: no geometry")
continue
shape = w._kernel._get_shape(body.geometry)
print(f" body {body.name}: extrude len={body.extrude_length}")
expl = TopExp_Explorer(shape, TopAbs_FACE)
while expl.More():
face = TopoDS.Face_s(expl.Current())
try:
adaptor = BRepAdaptor_Surface(face)
if adaptor.GetType() == GeomAbs_Cylinder:
cyl = adaptor.Cylinder()
loc = cyl.Location()
d = cyl.Axis().Direction()
print(
f" cyl axis at ({loc.X()!r}, {loc.Y()!r}) "
f"dir=({d.X():.4f},{d.Y():.4f},{d.Z():.4f})"
)
except Exception:
pass
expl.Next()
asm = proj.get_active_assembly()
print("=== active assembly:", asm.name)
for ac in asm.components.values():
comp = proj.get_component_by_id(ac.component_id)
print(f" instance '{ac.name}' -> {comp.name}, pos={np.round(ac.position, 4)}")
for conn in ac.connectors.values():
print(
f" conn '{conn.name}' pos={np.round(conn.position, 6)} "
f"normal={np.round(conn.normal, 3)} et={conn.entity_type!r} "
f"invalid={conn.is_invalid}"
)
# ── Run the real update path: rebuild bodies from sketch, recalc connectors ──
# Activate the component with the holes (Component 1).
comp1 = None
for comp in proj.components.values():
if any(
etype(e) == "circle"
for sk in comp.sketches.values()
for e in (sk.occ_sketch._entities.values() if sk.occ_sketch else [])
):
comp1 = comp
break
assert comp1 is not None
w._current_component = comp1
print("=== running _update_bodies_from_sketch()")
w._update_bodies_from_sketch()
print("=== running _recalculate_connectors()")
w._recalculate_connectors()
print("=== after update")
for comp in proj.components.values():
for body in comp.bodies.values():
if not body.geometry:
continue
shape = w._kernel._get_shape(body.geometry)
expl = TopExp_Explorer(shape, TopAbs_FACE)
axes = []
while expl.More():
face = TopoDS.Face_s(expl.Current())
try:
adaptor = BRepAdaptor_Surface(face)
if adaptor.GetType() == GeomAbs_Cylinder:
loc = adaptor.Cylinder().Location()
axes.append((round(loc.X(), 9), round(loc.Y(), 9)))
except Exception:
pass
expl.Next()
print(f" {comp.name} body axes: {axes}")
for sk in comp.sketches.values():
for e in sk.occ_sketch._entities.values():
t = etype(e)
if t == "circle":
g = egeom(e)
print(f" {comp.name} circle: ({g[0][0]!r}, {g[0][1]!r})")
asm = proj.get_active_assembly()
for ac in asm.components.values():
for conn in ac.connectors.values():
print(
f" conn '{conn.name}' pos={np.round(conn.position, 6)} "
f"invalid={conn.is_invalid}"
)
# Partner alignment check: for each connection, world positions of the pair.
for aconn in asm.connections:
a1 = asm.components.get(aconn.first_ac_id)
a2 = asm.components.get(aconn.second_ac_id)
c1 = a1.connectors.get(aconn.first_connector_id)
c2 = a2.connectors.get(aconn.second_connector_id)
if c1 is None or c2 is None:
continue
w1 = a1.position + a1.rotation @ np.asarray(c1.position, dtype=float)
w2 = a2.position + a2.rotation @ np.asarray(c2.position, dtype=float)
print(
f" conn {aconn.id[:8]}: w1={np.round(w1, 6)} w2={np.round(w2, 6)} "
f"gap={float(np.linalg.norm(w1 - w2))!r}"
)
print("DEMO_REPRO_DONE")
+89
View File
@@ -0,0 +1,89 @@
"""Round-trip smoke test: instance-local sketches + modifiers survive save/load.
Builds a minimal project (component with a body, assembly with two instances,
one carrying an instance sketch + cut modifier + fillet modifier), saves to a
temp .fluency, reloads, and asserts:
1. the shared component is untouched by instance work,
2. the instance sketch + modifiers round-trip with sketch refs intact,
3. the plain instance has none.
"""
import os
import tempfile
import numpy as np
from fluency.models.data_model import (
Project, Component, Body, Sketch, Feature, Assembly, AssemblyComponent,
)
from fluency.io.project_io import save_project, load_project
def main():
project = Project(name="inst test")
comp = project.add_component()
comp.name = "BasePart"
body = comp.add_body(Body(name="MainBody"))
base_sketch = comp.add_sketch(Sketch(name="BaseSketch"))
body.features.append(
Feature(operation="extrude", sketch=base_sketch, length=10.0)
)
asm = project.add_assembly(Assembly(name="TestAsm"))
ac1 = asm.add_component_instance(comp.id, name="Instance A")
ac1.position = np.array([0.0, 0.0, 0.0])
ac2 = asm.add_component_instance(comp.id, name="Instance B")
ac2.position = np.array([50.0, 0.0, 0.0])
# Instance A: local sketch + cut modifier referencing it + fillet.
inst_sketch = ac1.add_instance_sketch()
inst_sketch.name = "InstCutSketch"
ac1.add_modifier(body.id, Feature(operation="cut", sketch=inst_sketch,
length=5.0, through_all=True))
ac1.add_modifier(body.id, Feature(operation="fillet", radius=1.0))
# ---- save / load ----
fd, path = tempfile.mkstemp(suffix=".fluency")
os.close(fd)
try:
save_project(project, path)
loaded, _view = load_project(path)
lcomp = loaded.components[comp.id]
lac1 = None
lac2 = None
for lasm in loaded.assemblies.values():
for ac in lasm.components.values():
if ac.name == "Instance A":
lac1 = ac
elif ac.name == "Instance B":
lac2 = ac
assert lac1 is not None and lac2 is not None, "instances missing"
# 1. component untouched
assert len(lcomp.sketches) == 1, "component sketch count changed"
assert len(lcomp.bodies[body.id].features) == 1, "feature chain changed"
assert not getattr(lcomp.bodies[body.id], "modifiers", None)
# 2. instance A round-trip
assert len(lac1.sketches) == 1, "instance sketch missing"
lsk_id, lsk = next(iter(lac1.sketches.items()))
assert lsk.name == "InstCutSketch"
mods = lac1.modifiers
lbody_id = next(iter(lcomp.bodies))
assert len(mods.get(lbody_id, [])) == 2, f"modifiers missing: {mods}"
cut, fil = mods[lbody_id][0], mods[lbody_id][1]
assert cut.operation == "cut" and fil.operation == "fillet"
assert cut.sketch is not None and cut.sketch.id == lsk_id, \
"cut sketch ref did not resolve to instance sketch"
assert cut.length == 5.0 and cut.through_all
# 3. plain instance clean
assert not lac2.sketches and not lac2.modifiers
print("ROUND_TRIP_OK")
finally:
os.unlink(path)
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
"""Headless smoke: the manual re-pick fallback path (prompt -> frame -> pick mode)."""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
import numpy as np
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
from PySide6.QtWidgets import QApplication, QMessageBox
from fluency.ui.main_window import MainWindow
from fluency.models.data_model import Body
from fluency.geometry_occ.kernel import OCCGeometryObject
app = QApplication.instance() or QApplication([])
w = MainWindow()
comp = w._current_component
body = Body(name="plate")
comp.bodies[body.id] = body
box = BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape()
ax = gp_Ax2(gp_Pnt(10.0, 5.0, 0.0), gp_Dir(0, 0, 1))
box = BRepAlgoAPI_Cut(box, BRepPrimAPI_MakeCylinder(ax, 3.0, 6.0).Shape()).Shape()
body.geometry = OCCGeometryObject(box)
partner = w._project.add_component()
pbody = Body(name="partner")
partner.bodies[pbody.id] = pbody
pbody.geometry = OCCGeometryObject(BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape())
asm = w._project.get_active_assembly()
ac1 = asm.add_component_instance(comp.id, name="A")
ac2 = asm.add_component_instance(partner.id, name="B")
ac1.geom_cache[body.id] = body.geometry
c1 = ac1.add_connector(
position=(10.0, 5.0, 2.5), normal=(0, 0, 1), x_dir=(1, 0, 0),
source_obj_id=f"asm_{ac1.id}_{body.id}",
name="Conn hole", entity_type="cylindrical_face",
)
c1.partner_ac_id = ac2.id
# Activate the assembly view so the prompt path is taken.
w._assembly_view_active = True
w._selected_assembly_component_id = ac1.id
# Force a Yes from the question dialog, and record that it actually fired.
asked = {}
def fake_question(parent, title, text, buttons, default):
asked["title"] = title
return QMessageBox.StandardButton.Yes
QMessageBox.question = staticmethod(fake_question)
def fake_warning(*a, **k):
return QMessageBox.StandardButton.Ok
QMessageBox.warning = staticmethod(fake_warning)
w._prompt_relocate_unresolved([(asm, ac1, c1)])
assert asked.get("title") == "Connector Position Needed", asked
assert w._relocate_pending == [(asm, ac1, c1)], w._relocate_pending
assert w._viewer_3d._connector_pick_mode, "pick mode must be active"
# A wrong-part click must not consume the pending entry.
w._on_relocate_picked((0, 0, 0), (0, 0, 1), (1, 0, 0), "planar_face", f"asm_{ac2.id}_{pbody.id}")
assert w._relocate_pending == [(asm, ac1, c1)], "wrong part must not consume the pick"
# The right click re-homes and drains.
w._on_relocate_picked((40.0, 30.0, 2.5), (0, 0, 1), (1, 0, 0), "cylindrical_face", f"asm_{ac1.id}_{body.id}")
assert w._relocate_pending is None
assert np.allclose(c1.position, (40.0, 30.0, 2.5)), c1.position
assert not w._viewer_3d._connector_pick_mode, "pick mode must be off after completion"
# Esc cancel mid-flight clears the state.
w._relocate_pending = [(asm, ac1, c1)]
w._start_relocate_pick_next()
w._on_connector_pick_cancelled()
assert w._relocate_pending is None
assert not w._viewer_3d._connector_pick_mode
print("RELOCATE_PICK_FALLBACK_OK")
+149
View File
@@ -0,0 +1,149 @@
"""Headless test: update_external_entities handles circle/arc dict entries.
Reproduces the crash where a re-projected face contains a circular edge
(e.g. an instance cut hole): _project_face_to_uv returns a mixed list of
polylines + curve dicts, and update_external_entities used to unpack the
dict entries as (u, v) tuples.
Covers:
1. mixed projection (polylines + circle dict) -> rebuild + rebind path,
no crash, no duplicate/orphan curve entities, user geometry re-anchored.
2. repeated update with the same mixed projection -> stable (idempotent).
3. polylines-only same-topology projection -> in-place path still works
(external ids preserved).
"""
import math
from fluency.geometry_occ.sketch import OCCSketch
RECT = [
[(0.0, 0.0), (10.0, 0.0)],
[(10.0, 0.0), (10.0, 10.0)],
[(10.0, 10.0), (0.0, 10.0)],
[(0.0, 10.0), (0.0, 0.0)],
]
def _counts(sk):
ents = list(sk._entities.values())
return {
"ext_points": sum(
1
for e in ents
if e.entity_type == "point" and getattr(e, "is_external", False)
),
"ext_lines": sum(
1
for e in ents
if e.entity_type == "line" and getattr(e, "is_external", False)
),
"circles": sum(1 for e in ents if e.entity_type == "circle"),
"arcs": sum(1 for e in ents if e.entity_type == "arc"),
"user_points": sum(
1
for e in ents
if e.entity_type == "point" and not getattr(e, "is_external", False)
),
}
def test_mixed_projection_rebuild():
sk = OCCSketch()
sk.add_external_polylines([list(p) for p in RECT])
center = sk.add_external_point(5.0, 5.0)
sk.add_circle(center, 2.0)
user = sk.add_point(5.0, 5.0)
assert sk.constrain_coincident(user, center)
assert sk.solve()
# Re-projection: same rectangle, circle moved + resized -> mixed list.
new_proj = [
list(p) for p in RECT
] + [
{"type": "circle", "center": [6.0, 6.0], "radius": 1.5},
]
old_ext_ids = set(sk._external_entity_ids)
assert sk.update_external_entities(new_proj), "rebuild + rebind solve failed"
c = _counts(sk)
assert c["circles"] == 1, f"duplicate circle entities: {c}"
assert c["ext_points"] == 5, f"ext point count wrong (expect 4 corners + 1 centre): {c}"
assert c["ext_lines"] == 4, f"ext line count wrong (expect 4): {c}"
assert c["user_points"] == 1
# The coincident rebind must anchor the user point to the NEW centre.
ux, uy = user.geometry
assert math.hypot(ux - 6.0, uy - 6.0) < 1e-6, f"user point at {(ux, uy)}"
# Rebuild path: fresh external ids.
assert not (old_ext_ids & sk._external_entity_ids)
# Idempotent second pass with the same projection.
assert sk.update_external_entities(list(new_proj)), "second pass failed"
c2 = _counts(sk)
assert c2 == c, f"counts changed on second pass: {c} -> {c2}"
ux, uy = user.geometry
assert math.hypot(ux - 6.0, uy - 6.0) < 1e-6
print("test_mixed_projection_rebuild OK")
def test_polylines_only_inplace():
sk = OCCSketch()
sk.add_external_polylines([list(p) for p in RECT])
corner = None
for eid in sk._external_entity_ids:
ent = sk._entities[eid]
if ent.entity_type == "point" and ent.geometry == (0.0, 0.0):
corner = ent
break
assert corner is not None
user = sk.add_point(0.0, 0.0)
assert sk.constrain_coincident(user, corner)
assert sk.solve()
# Same topology, slightly shifted rectangle -> in-place move.
moved = [[(u + 1.0, v + 2.0) for (u, v) in poly] for poly in RECT]
old_ext_ids = set(sk._external_entity_ids)
assert sk.update_external_entities(moved), "in-place solve failed"
assert sk._external_entity_ids == old_ext_ids, "in-place path must keep ids"
ux, uy = user.geometry
assert math.hypot(ux - 1.0, uy - 2.0) < 1e-6, f"user point at {(ux, uy)}"
print("test_polylines_only_inplace OK")
def test_arc_import_shares_corners():
"""_import_external_curves must merge arc endpoints with existing
polyline corner points (no floating duplicate endpoints)."""
sk = OCCSketch()
# Rectangle with the top-right corner filleted: the arc endpoints must
# land on the truncated-edge corner points, not create new ones.
r = 2.0
sk.add_external_polylines([
[(0.0, 0.0), (10.0, 0.0)],
[(10.0, 0.0), (10.0, 10.0 - r)],
[(10.0 - r, 10.0), (0.0, 10.0)],
[(0.0, 10.0), (0.0, 0.0)],
])
sk._import_external_curves(
[],
[
{
"type": "arc",
"center": [10.0 - r, 10.0 - r],
"start": [10.0, 10.0 - r],
"end": [10.0 - r, 10.0],
"radius": r,
},
],
)
c = _counts(sk)
# 5 corners + 1 arc centre, NO extra endpoint entities.
assert c["ext_points"] == 6, f"expected 6 ext points, got {c}"
assert c["arcs"] == 1, f"expected 1 arc, got {c}"
assert sk.solve()
print("test_arc_import_shares_corners OK")
if __name__ == "__main__":
test_mixed_projection_rebuild()
test_polylines_only_inplace()
test_arc_import_shares_corners()
print("UNDERLAY_CURVES_OK")
+3 -4
View File
@@ -6,7 +6,7 @@ A parametric CAD application built on OpenCASCADE Technology (OCCT) with a moder
- **OpenCASCADE Geometry Kernel**: Industry-standard BRep geometry with exact precision - **OpenCASCADE Geometry Kernel**: Industry-standard BRep geometry with exact precision
- **STEP/IGES Import/Export**: Full support for industry-standard CAD file formats - **STEP/IGES Import/Export**: Full support for industry-standard CAD file formats
- **Parametric Sketching**: 2D sketching with constraint solving using CadQuery - **Parametric Sketching**: 2D sketching with constraint solving using SolveSpace
- **Boolean Operations**: Union, difference, and intersection - **Boolean Operations**: Union, difference, and intersection
- **Fillet & Chamfer**: Apply edge treatments to solid bodies - **Fillet & Chamfer**: Apply edge treatments to solid bodies
- **Modern Renderer**: WebGPU-based rendering with pygfx (smaller footprint than VTK) - **Modern Renderer**: WebGPU-based rendering with pygfx (smaller footprint than VTK)
@@ -47,8 +47,7 @@ pip install -e ".[dev]"
| Package | Purpose | | Package | Purpose |
|---------|---------| |---------|---------|
| cadquery | High-level OpenCASCADE Python bindings | | cadquery-ocp | OpenCASCADE Python bindings (OCP) |
| ocp | Low-level OpenCASCADE Python bindings |
| pygfx | WebGPU-based 3D renderer | | pygfx | WebGPU-based 3D renderer |
| wgpu | WebGPU Python bindings | | wgpu | WebGPU Python bindings |
| PySide6 | Qt GUI framework | | PySide6 | Qt GUI framework |
@@ -105,7 +104,7 @@ kernel.export_stl(body, "part.stl")
| File Size | Large (mesh) | Small (BRep) | | File Size | Large (mesh) | Small (BRep) |
| Fillet/Chamfer | Approximate | Exact | | Fillet/Chamfer | Approximate | Exact |
| Dependency Size | ~200MB (VTK) | ~30MB (pygfx) | | Dependency Size | ~200MB (VTK) | ~30MB (pygfx) |
| Constraint Solver | SolveSpace (separate) | CadQuery (integrated) | | Constraint Solver | SolveSpace (separate) | SolveSpace (integrated) |
## License ## License
-147
View File
@@ -1,147 +0,0 @@
# WARP.md
This file provides guidance to WARP (warp.dev) when working with code in this repository.
## Project Overview
Fluency is a CAD (Computer Aided Design) application built with Python/PySide6 that provides parametric 3D modeling through a timeline-based project system. The application combines 2D sketching with constraint solving, 3D visualization using VTK, and SDF (Signed Distance Function) based mesh generation.
## Common Commands
### Development Environment Setup
```bash
# Activate virtual environment (if exists)
source .venv/bin/activate
# Install dependencies
pip install -r requirements.txt
```
### Running the Application
```bash
# Run the main application
python main.py
# Run with debugging
python -u main.py
```
### UI Development
```bash
# Convert Qt Designer UI file to Python code
pyside6-uic gui.ui > Gui.py -g python
```
### Building Executable
The project uses Nuitka for compilation (configured in `main.py` header):
```bash
# Build standalone executable
nuitka --standalone --plugin-enable=pyside6 --plugin-enable=numpy --macos-create-app-bundle main.py
```
### Testing
```bash
# Run mesh generation test
python meshtest.py
```
## Architecture Overview
### Core Components
#### Main Application (`main.py`)
- **MainWindow**: Central UI controller that manages all widgets and user interactions
- **Project System**: Hierarchical structure: `Project → Timeline → Component → Sketch/Body`
- **Signal-based Communication**: Qt signals coordinate between 2D sketching and 3D rendering
#### Project Hierarchy
```
Project
├── Timeline (list of Components)
└── Component
├── Sketches (dict)
├── Bodies (dict)
└── Connectors (for assembly)
```
#### Drawing Modules (`drawing_modules/`)
- **SketchWidget** (`draw_widget_solve.py`): 2D parametric sketching with SolverSpace constraint solving
- **VTKWidget** (`vtk_widget.py`): 3D visualization and mesh interaction using VTK
- **PyVistaWidget** (`vysta_widget.py`): Alternative 3D rendering backend
#### Mesh Generation (`mesh_modules/`)
- **VESTA** (`vesta_mesh.py`): Multi-threaded SDF-to-mesh conversion using marching cubes
- **Interactor Mesh** (`interactor_mesh.py`): Simplified edge-based meshes for 3D selection
- **Simple Mesh** (`simple_mesh.py`): Basic mesh utilities
### Data Flow Architecture
#### 2D to 3D Pipeline
1. **2D Sketching**: User draws in SketchWidget using Qt coordinate system
2. **Constraint Solving**: SolverSpace resolves geometric constraints
3. **SDF Generation**: Sketch converted to Signed Distance Functions for 3D operations
4. **Mesh Generation**: VESTA generates triangle meshes from SDF using marching cubes
5. **3D Rendering**: VTK displays both solid meshes and interactive edges
#### Signal Flow (from `doc/flow.md`)
- 2D QPoint → cartesian space → SolverSpace dict → constraint solving → display
- 3D mesh selection → projection to 2D → sketch widget integration
### Key Classes
#### Core Data Structures
- **Sketch**: 2D geometric data with origin, normal, points, and constraints
- **Body**: 3D mesh representation containing SDF objects and interactor meshes
- **Component**: Container grouping related sketches and bodies
- **Interactor**: Simplified edge-based mesh for 3D manipulation
#### Constraint Solving
The application uses `python_solvespace` for parametric constraint solving:
- Point-to-point constraints
- Distance constraints
- Horizontal/vertical line constraints
- Point-to-line constraints
### Technology Stack
- **GUI**: PySide6 (Qt for Python)
- **3D Graphics**: VTK for rendering, PyVista as alternative
- **Constraint Solving**: SolverSpace for parametric geometry
- **Mesh Generation**: SDF library with custom VESTA marching cubes implementation
- **Scientific Computing**: NumPy for mathematical operations
## Development Workflow
### Adding New Sketch Tools
1. Add UI button in `gui.ui`
2. Convert UI: `pyside6-uic gui.ui > Gui.py -g python`
3. Connect signal in `MainWindow.__init__()`
4. Implement tool logic in `SketchWidget`
### Adding New 3D Operations
1. Extend operation buttons in the Modify group
2. Implement operation logic using SDF functions
3. Update Body creation and timeline management
4. Handle interactor mesh generation for selection
### Debugging Tips
- Monitor solver results through `SolverSystem` status
- Use VTK's built-in debugging for rendering issues
- Check coordinate transformations between 2D sketch and 3D space
- Verify SDF function outputs before mesh generation
### File Structure
- `main.py`: Application entry point and main window
- `Gui.py`: Auto-generated UI code (do not edit directly)
- `gui.ui`: Qt Designer UI definition file
- `drawing_modules/`: 2D and 3D rendering widgets
- `mesh_modules/`: Mesh generation and processing
- `doc/`: Architecture and command documentation
## Dependencies
Primary external libraries:
- `PySide6`: Qt GUI framework
- `vtk`: 3D visualization toolkit
- `python-solvespace`: Constraint solving
- `sdf`: Signed Distance Function operations
- `numpy`: Numerical computations
- `scikit-image`: Marching cubes algorithm
- `names`: Random name generation for sketches
+219
View File
@@ -0,0 +1,219 @@
# Fluency CAD — Agent Guide
## Project Overview
**Fluency CAD 2.0** is a parametric CAD application built on **OpenCASCADE Technology (OCCT)** with a modern **pygfx-based 3D renderer**. It provides 2D sketching with SolveSpace constraint solving, boolean operations, STEP/IGES/STL import/export, and exact BRep geometry.
- Language: **Python 3.10+**
- GUI: **PySide6** (Qt6)
- Geometry Kernel: **OCP** (cadquery-ocp — OpenCASCADE Python bindings)
- Constraint Solver: **python_solvespace**
- Renderer: **pygfx** (WebGPU) + **OCCRenderer** (native OCC AIS display)
---
## Architecture
```
src/fluency/
├── __init__.py # Package entry (version, imports)
├── main.py # Application entry point, MainWindow, Sketch2DWidget (~5000 lines)
├── sketch_solver.py # SolveSpace constraint solver wrapper (legacy)
├── geometry/
│ └── base.py # Abstract interfaces: GeometryKernel, SketchInterface, data classes
├── geometry_occ/
│ ├── kernel.py # OCGeometryKernel (extrude, boolean, fillet, import/export, mesh)
│ └── sketch.py # OCCSketch with SolveSpace integration (face detection, constraints)
├── models/
│ └── data_model.py # Project, Component, Sketch, Body dataclasses
├── rendering/
│ ├── base.py # Abstract Renderer interface
│ ├── occ_renderer.py # OCC AIS renderer (preferred — smooth BRep display)
│ └── pygfx_renderer.py # Legacy pygfx renderer
├── utils/ # Utility modules
└── widgets/ # Custom widgets
tests/
└── test_geometry.py # Comprehensive test suite (52+ tests)
```
### Key Classes & Responsibilities
| Class | File | Purpose |
|-------|------|---------|
| `OCGeometryKernel` | `kernel.py` | OCC shape ops: extrude, boolean, fillet, mesh, import/export |
| `OCCSketch` | `sketch.py` | 2D sketch with SolveSpace solver, face detection, workplane |
| `OCCSketchEntity` | `sketch.py` | Entity (point/line/circle/arc) with solver handle, is_construction, is_external |
| `Sketch2DWidget` | `main.py` | Qt widget for interactive 2D sketching (draw, snap, constrain) |
| `MainWindow` | `main.py` | Main application window, toolbars, 3D viewer, operations |
| `OCCRenderer` | `occ_renderer.py` | Native OCC AIS display (shaded + edges, face pick) |
| `Sketch` | `data_model.py` | Data model: workplane, occ_sketch ref, source_body_id |
| `Body` | `data_model.py` | 3D solid with geometry, visibility, render object |
| `Component` | `data_model.py` | Container for sketches and bodies |
| `Project` | `data_model.py` | Top-level container with kernel |
### Data Flow
```
User draws in Sketch2DWidget
→ OCCSketch entities created in solver
→ Constraint solving (python_solvespace)
→ OCCSketch.get_geometry() → detect_faces() → build_face_geometry()
→ OCGeometryKernel.extrude() → BRepPrimAPI_MakePrism
→ Boolean operations → Body added to Component
→ OCCRenderer.add_shape() → AIS display
```
---
## Development Commands
```bash
# Install editable
pip install -e ".[dev]"
# Run app
python -m fluency.main
# Run tests (52 tests)
python -m pytest tests/test_geometry.py -v
# Run single test
python -m pytest tests/test_geometry.py::TestOCCSketch::test_workplane_extrude_with_hole -xvs
# Quck geometry test (raw OCC, no Qt)
python -c "from fluency.geometry_occ.sketch import OCCSketch; ..."
```
---
## Code Conventions
### General
- Line length: **100 chars** (black/ruff config)
- Target Python: **3.10+** (uses `from __future__ import annotations`, walrus, pattern matching)
- Docstrings: Google/NumPy style preferred
- Logging: `logger = logging.getLogger(__name__)` with `logging.DEBUG` level
### OCC / OCP
- Always use `is not None` for OCP objects — `TopoDS_Shape.__bool__` can be falsy even for valid shapes
- `BRepBuilderAPI_MakeFace.Add(wire)` expects a `TopoDS_Wire`. `wire.Reversed()` returns `TopoDS_Shape` → cast via `_TopoDS.Wire_s(wire.Reversed())`
- Face normal direction: check `face.Orientation()` vs `TopAbs_REVERSED` — a REVERSED face's outward normal is the NEGATION of the surface axis
- `TopoDS_Wire_s(shape)`, `TopoDS_Face_s(shape)` — use `_s` suffix from OCP for downcasts
- Mesh: `BRepMesh_IncrementalMesh(shape, tol, False, 0.15, True)` — default deflection 0.15 rad for smooth curves
### Extrude / Cut Workflow
- Snapshot `list(self._current_component.bodies.items())` **BEFORE** `add_body()` — the new body must not be in the target set
- Cut targets the **source body** (`sketch._source_body_id` from face pick), not `bodies[0]`
- The fix: apply boolean to **target** geometry, then remove tool body
- Plain extrude with holes: inner wires must have **OPPOSITE** geometric winding to the outer wire (see `build_face_geometry` and `_loop_signed_area`)
### Sketch / Solvers
- `python_solvespace` has NO remove API for entities/constraints. Deleting requires: drop from `_points`/`_lines`, prune `_constraint_log`, `_rebuild_solver()` (recreates entire system), `_rebuild_labels()`, re-solve
- `_constraint_log`: each entry is `{"type": str, "ids": tuple[int,...], "params": tuple, "labels": set[str]}`
- Constraint labels: stored on **point** entities for paintEvent rendering; rebuilt via `_rebuild_labels()`
- Line constraints (`horizontal`/`vertical`/`parallel`/`perpendicular`) need the **line's** solver handle, not a point's. Use `_find_line_sketch_entity()` to get the correct handle
- External entities (underlay): `is_external=True`, `is_construction=True`, fixed in solver (always `dragged`). Stored in `_external_entity_ids`, excluded from `_line_segments()`, `get_polygon_points()`, `get_closed_loops()`, `detect_faces()`, `get_geometry()`
### Face Detection
- `get_closed_loops()`: uses snapped-coordinate graph (`_SNAP_TOL = 1e-4`) from line endpoint adjacency. Only accepts simple cycles (all nodes degree 2)
- `detect_faces()`: even-odd nesting rule via `_loop_contains`. Even depth = outer boundary, odd = hole
- `_loop_rep_point`: midpoint between centroid and first vertex. **Fragile** — can land inside a nested shape for certain geometries (e.g., a small hole near the centroid's direction from the first vertex)
- `_loop_signed_area`: shoelace formula for polygons, `πr²` (positive = CCW) for circles
### Rendering
- **OCCRenderer** is the main renderer (not pygfx). Uses `AIS_Shape`, `V3d_Viewer`, `AIS_InteractiveContext`
- Face pick: `pick_planar_face(x, y)``MoveTo``DetectedShape``TopoDS_Face_s``BRepAdaptor_Surface` plane check
- Highlight: `highlight_face(face)` creates a transparent AIS overlay; `clear_face_highlight()` removes it
- Preview: `preview_shape(shape)` for live transparent extrude preview
- Navigation: Left=orbit, Middle=pan, Wheel=zoom. **Right is RESERVED** — check user before reassigning
### Paint-Event Safety
- Every constraint-tag rendering loop wraps each entry in `try/except` so a bad entry (dangling id, corrupted geometry) doesn't crash the entire paint event
- `_point_world()` and `_entity_anchor()` return `None` (not raise) for malformed input
---
## Known Bugs & Fix Patterns
### 1. Hole Orientation in Extrusion (FIXED 2026-07-03)
**Symptom**: Inner shapes (circle/triangle/slot) inside a rectangle become solid islands instead of holes when extruding, depending on drag direction.
**Root Cause**: `wire_loop` in `build_face_geometry` unconditionally reversed hole wires (`w.Reversed()`). When the outer polygon was CW-winding (e.g., dragging from top-left to bottom-right), the reversed inner had the SAME effective direction as the outer, making OCC treat it as solid.
**Fix**: Added `_loop_signed_area()` to compute geometric winding. Hole wires are only reversed when their natural winding matches the outer's (ensuring opposite winding for holes).
**Relevant code**: `sketch.py`, `build_face_geometry()` and `_loop_signed_area()`
### 2. _loop_rep_point Fragility (KNOWN)
**Symptom**: Face detection fails when a nested shape contains the outer loop's representative point (midpoint between centroid and first vertex).
**Would-be fix**: Use a guaranteed-interior point (maximum inscribed circle center or perturbed centroid) instead of the centroid-first-vertex midpoint.
### 3. Extrude Cut / Target Selection (FIXED 2026-06-29)
**Symptom**: Cut created a separate "cavity-shaped" body next to the original instead of modifying the target.
**Fix**: Boolean result stored on TARGET body geometry; tool body removed from component. Auto-target via `sketch._source_body_id`.
### 4. Workplane Preservation (FIXED 2026-06-29)
**Symptom**: Sketch placed on a face lost its workplane after being added to component.
**Fix**: Copy `occ_sketch` workplane fields into `Sketch` dataclass BEFORE `apply_workplane()`.
---
## API Quirks
- **`QPoint(0,0)`**: falsy via `isNull()` in PySide6 → always use `is not None` for `Optional[QPoint]`
- **`QMouseEvent`/`QPainterPath`**: live in `PySide6.QtGui` (NOT `QtCore`)
- **`BRepBuilderAPI_MakeFace.Add()`**: needs `TopoDS_Wire`. `wire.Reversed()` returns `TopoDS_Shape` — cast via `TopoDS_Wire_s()`
- **python_solvespace**: NO entity/constraint remove API — workaround via `_rebuild_solver()`. Parameters read via `solver.params(handle.params)` → returns `(x, y)` tuple
- **OCGeometryKernel.extrude**: unwraps `OCCGeometryObject`, raw `TopoDS_Shape`, or cadquery `Workplane`. Always use `is not None` for the shape (not truthiness)
- **Sketch._source_body_id**: dynamic attribute set on `Sketch` dataclass, set during face-pick flow
- **`_get_shape(obj)`**: returns `obj.shape.wrapped` for `OCCGeometryObject`, `obj.shape` for raw shapes, `None` for empty. Use `is not None` guards everywhere
---
## Memory / Agent Context
This project has extensive Pi memory (hermes-memory) for:
- `project="fluency"` with `target="failure"`: bugs, fixes, corrections, insights
- `project="fluency"` with `target="memory"`: conventions, decisions, workflow patterns
- Available skills: `fix-cad-app-pipeline`, `refactor-from-cadquery-to-ocp`
Key memory queries for debugging:
- "hole orientation" → `_loop_signed_area` / `build_face_geometry` fix
- "extrude cut auto-target" → cut/target body fix
- "workplane preservation" → _add_sketch_to_component fix
- "_loop_rep_point" → face detection fragility
- "paint-event safety" → try/except per entry pattern
- "solver rebuild" → delete workflow via _rebuild_solver
- "face pick origin" → pick_planar_face face bbox centre
---
## Testing Patterns
```python
# Direct OCC test (no Qt)
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace
from OCP.gp import gp_Pnt
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.GProp import GProp_GProps; from OCP.BRepGProp import BRepGProp
# Build test shape, extrude, verify volume
mp = BRepBuilderAPI_MakePolygon(); ...
g = GProp_GProps(); BRepGProp.VolumeProperties_s(shape, g)
assert abs(g.Mass() - expected) < 0.1
```
```python
# Sketch-based test
from fluency.geometry_occ.sketch import OCCSketch
from fluency.geometry_occ.kernel import OCGeometryKernel
sk = OCCSketch()
# ... add points, lines, circles ...
sk.solve()
geom = sk.get_geometry()
solid = OCGeometryKernel().extrude(geom, 10.0)
```
Binary file not shown.
+253
View File
@@ -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.01.0
roughness: float = 0.5 # 0.01.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
+1102 -715
View File
File diff suppressed because it is too large Load Diff
+948
View File
@@ -0,0 +1,948 @@
# -*- 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(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)
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.groupBox_5 = QGroupBox(self.centralwidget)
self.groupBox_5.setObjectName(u"groupBox_5")
sizePolicy.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
self.groupBox_5.setSizePolicy(sizePolicy)
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_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_9.addWidget(self.pushButton_2, 0, 0, 1, 1)
self.pb_export_iges = QPushButton(self.groupBox_4)
self.pb_export_iges.setObjectName(u"pb_export_iges")
self.gridLayout_9.addWidget(self.pb_export_iges, 2, 0, 1, 1)
self.pb_export_step = QPushButton(self.groupBox_4)
self.pb_export_step.setObjectName(u"pb_export_step")
self.gridLayout_9.addWidget(self.pb_export_step, 0, 1, 1, 1)
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.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.horizontalLayout.addWidget(self.pb_new_compo)
self.pb_del_compo = QPushButton(self.compo_tool_box)
self.pb_del_compo.setObjectName(u"pb_del_compo")
self.pb_del_compo.setEnabled(True)
sizePolicy.setHeightForWidth(self.pb_del_compo.sizePolicy().hasHeightForWidth())
self.pb_del_compo.setSizePolicy(sizePolicy)
self.pb_del_compo.setMinimumSize(QSize(50, 50))
self.pb_del_compo.setMaximumSize(QSize(50, 50))
self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
self.horizontalLayout.addWidget(self.pb_del_compo)
self.gridLayout.addWidget(self.compo_tool_box, 7, 0, 1, 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_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_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")
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.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_8.addWidget(self.connection_list)
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_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, 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")
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")
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)
self.groupBox_6 = QGroupBox(self.groupBox_11)
self.groupBox_6.setObjectName(u"groupBox_6")
sizePolicy.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
self.groupBox_6.setSizePolicy(sizePolicy)
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, 4, 0, 1, 1)
self.groupBox_2 = QGroupBox(self.centralwidget)
self.groupBox_2.setObjectName(u"groupBox_2")
sizePolicy.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
self.groupBox_2.setSizePolicy(sizePolicy)
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)
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)
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.assembly_box = QGroupBox(self.centralwidget)
self.assembly_box.setObjectName(u"assembly_box")
self.assembly_box.setMinimumSize(QSize(0, 120))
self.gridLayout.addWidget(self.assembly_box, 8, 1, 1, 2)
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_3.addWidget(self.pb_chamfer_op, 2, 1, 1, 1)
self.pb_fillet_op = QPushButton(self.groupBox)
self.pb_fillet_op.setObjectName(u"pb_fillet_op")
self.gridLayout_3.addWidget(self.pb_fillet_op, 2, 0, 1, 1)
self.pb_thread = QPushButton(self.groupBox)
self.pb_thread.setObjectName(u"pb_thread")
self.gridLayout_3.addWidget(self.pb_thread, 6, 0, 1, 1)
self.pb_extrdop = QPushButton(self.groupBox)
self.pb_extrdop.setObjectName(u"pb_extrdop")
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.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, 2551, 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.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_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.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))
#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.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_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.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", 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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

View File
-1
View File
@@ -26,7 +26,6 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"cadquery>=2.4",
"pygfx>=0.1.0", "pygfx>=0.1.0",
"wgpu>=0.1.0", "wgpu>=0.1.0",
"PySide6>=6.4.0", "PySide6>=6.4.0",
+1 -1
View File
@@ -2,7 +2,7 @@
Fluency CAD - Parametric CAD Application Fluency CAD - Parametric CAD Application
A modern parametric CAD application built on OpenCASCADE Technology (OCCT) A modern parametric CAD application built on OpenCASCADE Technology (OCCT)
with a clean Python API using CadQuery. with a clean Python API using OCP (OpenCASCADE Python bindings).
""" """
__version__ = "2.0.0" __version__ = "2.0.0"
+5
View File
@@ -406,6 +406,11 @@ class SketchInterface(ABC):
"""Constrain two circles to have equal radius.""" """Constrain two circles to have equal radius."""
pass pass
@abstractmethod
def constrain_diameter(self, circle: SketchEntity, diameter: float) -> bool:
"""Set the diameter of a circle."""
pass
@abstractmethod @abstractmethod
def constrain_fixed(self, entity: SketchEntity) -> bool: def constrain_fixed(self, entity: SketchEntity) -> bool:
"""Fix an entity in place.""" """Fix an entity in place."""
File diff suppressed because it is too large Load Diff
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")
+5
View File
@@ -0,0 +1,5 @@
"""I/O module: project save/load."""
from fluency.io.project_io import save_project, load_project, project_zip_path
__all__ = ["save_project", "load_project", "project_zip_path"]
File diff suppressed because it is too large Load Diff
+42 -4228
View File
File diff suppressed because it is too large Load Diff
+702 -2
View File
@@ -13,14 +13,84 @@ import numpy as np
from fluency.geometry.base import ( from fluency.geometry.base import (
Point2D, Point2D,
Point3D,
GeometryObject, GeometryObject,
SketchInterface,
) )
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch from fluency.geometry_occ.sketch import OCCSketch
@dataclass
class Workplane:
"""
An independent working plane (datum plane) not tied to a face.
Workplanes can be created at any time and serve as the foundation for
sketching and subsequent 3D operations (extrude, cut, revolve, etc.).
They are visible in the 3D view as a semi-transparent reference grid.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Workplane"
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
# OCC AIS shape (visual plane) object id in the renderer
render_object: Any = None
visible: bool = True
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
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)
# Remove any component of x along n, then renormalise.
x = x - np.dot(x, n) * n
x_norm = np.linalg.norm(x)
if x_norm < 1e-9:
fallback = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
x = fallback - np.dot(fallback, n) * n
x_norm = np.linalg.norm(x)
x = x / x_norm
y = np.cross(n, x)
y = y / np.linalg.norm(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]:
"""Derived in-plane Y axis (normal × x_dir)."""
return self._y_dir
def uv_to_world(self, u: float, v: float) -> Tuple[float, float, float]:
"""Map a UV point to 3D world coordinates on this plane."""
ox, oy, oz = self.origin
xx, xy, xz = self.x_dir
yx, yy, yz = self._y_dir
return (
ox + u * xx + v * yx,
oy + u * xy + v * yy,
oz + u * xz + v * yz,
)
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)
yd = np.array(self._y_dir, dtype=float)
return (float(np.dot(v, xd)), float(np.dot(v, yd)))
@dataclass @dataclass
class Sketch: class Sketch:
""" """
@@ -136,6 +206,101 @@ class Sketch:
self.modified_at = datetime.now() 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 @dataclass
class Body: class Body:
""" """
@@ -152,6 +317,27 @@ class Body:
source_sketch: Optional[Sketch] = None source_sketch: Optional[Sketch] = None
source_operation: str = "extrude" 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])) 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)) rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
@@ -160,6 +346,7 @@ class Body:
visible: bool = True visible: bool = True
render_object: Any = None render_object: Any = None
render_material: Optional[str] = None # material preset name for render backend
created_at: datetime = field(default_factory=datetime.now) created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now) modified_at: datetime = field(default_factory=datetime.now)
@@ -192,12 +379,34 @@ class Component:
sketches: Dict[str, Sketch] = field(default_factory=dict) sketches: Dict[str, Sketch] = field(default_factory=dict)
bodies: Dict[str, Body] = field(default_factory=dict) bodies: Dict[str, Body] = field(default_factory=dict)
workplanes: Dict[str, Workplane] = field(default_factory=dict)
active_sketch: Optional[str] = None active_sketch: Optional[str] = None
active_workplane: Optional[str] = None
created_at: datetime = field(default_factory=datetime.now) created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now) modified_at: datetime = field(default_factory=datetime.now)
def add_workplane(self, workplane: Optional[Workplane] = None) -> Workplane:
"""Add an independent workplane to the component."""
if workplane is None:
workplane = Workplane(name=f"Workplane {len(self.workplanes) + 1}")
self.workplanes[workplane.id] = workplane
if self.active_workplane is None:
self.active_workplane = workplane.id
self.modified_at = datetime.now()
return workplane
def remove_workplane(self, wp_id: str) -> bool:
"""Remove a workplane from the component."""
if wp_id in self.workplanes:
del self.workplanes[wp_id]
if self.active_workplane == wp_id:
self.active_workplane = next(iter(self.workplanes.keys()), None)
self.modified_at = datetime.now()
return True
return False
def add_sketch(self, sketch: Optional[Sketch] = None) -> Sketch: def add_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
"""Add a sketch to the component.""" """Add a sketch to the component."""
if sketch is None: if sketch is None:
@@ -244,6 +453,336 @@ class Component:
self.modified_at = datetime.now() self.modified_at = datetime.now()
@dataclass
class Connector:
"""
A connection point on an assembly component instance.
Stores the position and orientation of a connection point
(e.g. a hole center, face midpoint, or edge point) that will
later be used by the SolveSpace solver to mate components.
The *normal* defines the connection axis direction (e.g. the
hole axis for a screw connection).
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Connector"
# 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) in local coords.
normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
# 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).
axis_rotation: float = 0.0
# Offset distance along the normal.
offset: float = 0.0
# Which AssemblyComponent this connector belongs to.
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.
# The FIRST-picked component is the grounded reference of the pair;
# 'is_grounded' marks that side so the move handler knows which half
# is the fixed frame of the rigid group.
partner_ac_id: Optional[str] = None
# The id of the partner Connector on the partner component.
partner_connector_id: Optional[str] = None
# 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:
"""
An instance of a component within an assembly.
References a component in the project and stores its relative
position and rotation for placement within the assembly.
Holds connectors that define connection points for the
SolveSpace solver.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
component_id: str = ""
name: str = "Untitled Instance"
# Position and orientation relative to the assembly origin.
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))
# 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)
def add_connector(
self,
position: Tuple[float, float, float],
normal: Tuple[float, float, float],
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(
name=name or f"Connector {len(self.connectors) + 1}",
position=position,
normal=normal,
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()
return conn
def remove_connector(self, connector_id: str) -> bool:
"""Remove a connector from this component instance."""
if connector_id in self.connectors:
del self.connectors[connector_id]
self.modified_at = datetime.now()
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:
"""A mated connector pair linking two AssemblyComponents.
Records which component is the grounded reference (``first_ac_id``) and
which was solved against it (``second_ac_id``), plus the partner
connector ids so the linkage can be followed / removed symmetrically.
Used by the assembly-move handler to propagate translations across the
rigid group.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
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)
@dataclass
class Assembly:
"""
An assembly of multiple component instances.
An assembly groups component instances with relative positions,
ready for constraint solving and joint definition between them.
Components can be instanced multiple times, each at a different
position and rotation.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Assembly"
components: Dict[str, AssemblyComponent] = field(default_factory=dict)
active_assembly_component: Optional[str] = None
# Mated connector pairs — each entry links two AssemblyComponents so the
# assembly-move handler can propagate rigid-group translations. The
# 'first_ac_id' side is the grounded reference of the pair.
connections: List["AssemblyConnection"] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def add_connection(self, first_ac_id: str, second_ac_id: str) -> "AssemblyConnection":
"""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. 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,
)
self.connections.append(conn)
self.modified_at = datetime.now()
return conn
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
]
def get_rigid_group(self, ac_id: str) -> List[str]:
"""Return ids of all components rigidly linked to *ac_id* (BFS).
Includes *ac_id* itself. Two components are linked when a mated
connector pair (in ``connections``) joins them; linkage is
transitive, so the whole connected subgraph forms one rigid group.
"""
if ac_id not in self.components:
return []
# Build adjacency from the connection list.
adj: Dict[str, List[str]] = {}
for c in self.connections:
adj.setdefault(c.first_ac_id, []).append(c.second_ac_id)
adj.setdefault(c.second_ac_id, []).append(c.first_ac_id)
seen: List[str] = []
queue: List[str] = [ac_id]
while queue:
cur = queue.pop(0)
if cur in seen:
continue
seen.append(cur)
for nb in adj.get(cur, []):
if nb not in seen:
queue.append(nb)
return seen
def is_grounded_reference(self, ac_id: str) -> bool:
"""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:
"""Add a component instance to the assembly.
Returns the newly created AssemblyComponent. The same
component can be added multiple times (multiple instances).
"""
ac = AssemblyComponent(
component_id=component_id,
name=name or f"Instance {len(self.components) + 1}",
)
self.components[ac.id] = ac
if self.active_assembly_component is None:
self.active_assembly_component = ac.id
self.modified_at = datetime.now()
return ac
def remove_component_instance(self, assembly_component_id: str) -> bool:
"""Remove a component instance from the assembly."""
if assembly_component_id in self.components:
del self.components[assembly_component_id]
# Also drop any mated-connector links that referenced this
# instance — otherwise stale connection edges would remain in
# 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.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:
return self.components[self.active_assembly_component]
return None
@dataclass @dataclass
class Project: class Project:
""" """
@@ -259,12 +798,21 @@ class Project:
components: Dict[str, Component] = field(default_factory=dict) components: Dict[str, Component] = field(default_factory=dict)
active_component: Optional[str] = None active_component: Optional[str] = None
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) kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
created_at: datetime = field(default_factory=datetime.now) created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now) modified_at: datetime = field(default_factory=datetime.now)
file_path: Optional[str] = None file_path: Optional[str] = None
# ── Component helpers ──
def add_component(self, component: Optional[Component] = None) -> Component: def add_component(self, component: Optional[Component] = None) -> Component:
"""Add a component to the project.""" """Add a component to the project."""
if component is None: if component is None:
@@ -296,6 +844,59 @@ class Project:
self.active_component = component_id self.active_component = component_id
self.modified_at = datetime.now() self.modified_at = datetime.now()
# ── Assembly helpers ──
def add_assembly(self, assembly: Optional[Assembly] = None) -> Assembly:
"""Add an assembly to the project."""
if assembly is None:
assembly = Assembly(name=f"Assembly {len(self.assemblies) + 1}")
self.assemblies[assembly.id] = assembly
if self.active_assembly is None:
self.active_assembly = assembly.id
self.modified_at = datetime.now()
return assembly
def remove_assembly(self, assembly_id: str) -> bool:
"""Remove an assembly from the project."""
if assembly_id in self.assemblies:
del self.assemblies[assembly_id]
if self.active_assembly == assembly_id:
self.active_assembly = next(iter(self.assemblies.keys()), None)
self.modified_at = datetime.now()
return True
return False
def get_active_assembly(self) -> Optional[Assembly]:
"""Get the currently active assembly."""
if self.active_assembly and self.active_assembly in self.assemblies:
return self.assemblies[self.active_assembly]
return None
def set_active_assembly(self, assembly_id: Optional[str]) -> None:
"""Set the active assembly."""
self.active_assembly = assembly_id
self.modified_at = datetime.now()
def get_component_by_id(self, component_id: str) -> Optional[Component]:
"""Look up a component by id across all project components."""
return self.components.get(component_id)
# ── Drawing helpers ──
def get_drawing_for(self, source_kind: str, source_id: str) -> Optional["TechnicalDrawing"]:
"""Return the drawing bound to *(source_kind, source_id)*, if any."""
for drawing in self.drawings:
if drawing.source_kind == source_kind and drawing.source_id == source_id:
return drawing
return None
def add_drawing(self, drawing: "TechnicalDrawing") -> "TechnicalDrawing":
"""Register *drawing* with the project and return it."""
self.drawings.append(drawing)
self.modified_at = datetime.now()
return drawing
def export_step(self, filepath: str) -> bool: def export_step(self, filepath: str) -> bool:
"""Export all visible bodies to STEP.""" """Export all visible bodies to STEP."""
all_bodies: List[OCCGeometryObject] = [] all_bodies: List[OCCGeometryObject] = []
@@ -363,3 +964,102 @@ class Project:
for comp in self.components.values(): for comp in self.components.values():
sketches.extend(comp.sketches.values()) sketches.extend(comp.sketches.values())
return sketches return sketches
def compute_source_fingerprint(
self, source_kind: str, source_id: str
) -> str:
"""Compute a simple fingerprint for a source reference."""
import hashlib
data = f"{source_kind}:{source_id}"
return hashlib.sha256(data.encode()).hexdigest()[:16]
# ── Technical Drawing models ───────────────────────────────────────────────
@dataclass
class DrawingView:
"""One projected view in a technical drawing."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
kind: str = "front" # front, back, top, bottom, right, left, isometric, custom
name: Optional[str] = None # human-readable label; defaults from kind
# View direction and up vector in world coords (for custom views).
# Ignored when kind is one of the standard presets.
direction: Optional[Tuple[float, float, float]] = None
up_vector: Optional[Tuple[float, float, float]] = None
show_hidden_lines: bool = False
show_centerlines: bool = False
scale: float = 1.0
# Sheet position (mm from sheet origin) — set by layout engine.
sheet_origin: Tuple[float, float] = (0.0, 0.0)
@dataclass
class DrawingAnnotation:
"""One annotation (dimension or note) on a technical drawing."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
kind: str = "dimension" # dimension, note, tolerance, surface_finish, weld_symbol
text: Optional[str] = None
visible: bool = True
# References to DrawingCandidate keys this annotation is bound to.
references: List[str] = field(default_factory=list)
# Sheet position (mm). For dimensions, anchor point; for notes, placement.
sheet_position: Tuple[float, float] = (0.0, 0.0)
# Associated view id (empty means global/note block).
view_id: str = ""
# Manual-dimension geometry in view-plane model coordinates (model
# units in the view's projection plane). Populated for user-placed
# dimensions, empty for reference-based annotations:
# "length": (point_on_edge1, point_on_edge2) — closest points
# "diameter": (left, right) — antipodal points across the centre
# "angle": (vertex, arm1_point, arm2_point)
anchors: List[Tuple[float, float]] = field(default_factory=list)
# Sub-kind of the manual dimension: "length" | "diameter" | "angle".
# Empty for non-dimension annotations.
dimension_kind: str = ""
# Unit vector along the measured distance in view-plane coordinates
# (length dimensions only). The model→sheet transform is a uniform
# scale + translation, so the direction is valid in sheet space too.
direction: Optional[Tuple[float, float]] = None
@dataclass
class TechnicalDrawing:
"""A complete technical drawing definition."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Drawing"
# Source geometry reference.
source_kind: str = "component" # component, assembly
source_id: str = ""
views: List[DrawingView] = field(default_factory=list)
annotations: List[DrawingAnnotation] = field(default_factory=list)
# Title block metadata.
title: str = ""
part_number: str = ""
material: str = ""
revision: str = ""
notes: str = ""
# Sheet size (A0..A4 or custom mm). Default A4 (ISO 5457 template).
sheet_size: str = "A4"
units: str = "mm" # mm, in
# Auto-generation flags. Auto dimensions are opt-in: the drawing
# workbench shows them only while the user has the toggle enabled.
auto_dimensions: bool = False
auto_views: bool = True
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
+13
View File
@@ -1,5 +1,6 @@
"""Rendering module.""" """Rendering module."""
# OCC/OpenGL viewport renderers
from fluency.rendering.base import ( from fluency.rendering.base import (
Renderer, Renderer,
RenderObject, RenderObject,
@@ -8,10 +9,22 @@ from fluency.rendering.base import (
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
from fluency.rendering.occ_renderer import OCCRenderer, OCCRenderObject from fluency.rendering.occ_renderer import OCCRenderer, OCCRenderObject
# Photorealistic render backends
from fluency.rendering.render_backend import (
RenderBackend,
RenderMaterial,
RenderCamera,
RenderSettings,
)
__all__ = [ __all__ = [
"Renderer", "Renderer",
"RenderObject", "RenderObject",
"RenderColor", "RenderColor",
"PygfxRenderer", "PygfxRenderer",
"PygfxRenderObject", "PygfxRenderObject",
"RenderBackend",
"RenderMaterial",
"RenderCamera",
"RenderSettings",
] ]
+156
View File
@@ -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())
+425
View File
@@ -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
File diff suppressed because it is too large Load Diff
+252
View File
@@ -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])))
+142
View File
@@ -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.01.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,
)
+17 -7
View File
@@ -187,19 +187,29 @@ class SolverSketch(SolverSystem):
def constrain_distance( def constrain_distance(
self, entity_a, entity_b, distance: float self, entity_a, entity_b, distance: float
) -> bool: ) -> bool:
"""Constrain distance between point-point or point-line.""" """Constrain distance between point-point or point-line.
python-solvespace's ``distance`` accepts (point, line) ordering only,
so line-point pairs are normalised; line-line pairs and a point
constrained to itself are rejected (the solver cannot hold them).
"""
try: try:
handle_a = entity_a.handle if isinstance(entity_a, SolverPoint) else entity_a.handle if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
handle_b = entity_b.handle if isinstance(entity_b, SolverPoint) else entity_b.handle logger.warning("distance: line-to-line distance is not supported")
return False
if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
# Normalise to (point, line) ordering.
entity_a, entity_b = entity_b, entity_a
if entity_a is entity_b and distance != 0.0:
logger.warning("distance: refusing point-to-itself constraint")
return False
handle_a = entity_a.handle
handle_b = entity_b.handle
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine): if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
self.distance(handle_a, handle_b, distance, self.wp) self.distance(handle_a, handle_b, distance, self.wp)
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
self.distance(handle_b, handle_a, distance, self.wp)
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint): elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
self.distance(handle_a, handle_b, distance, self.wp) self.distance(handle_a, handle_b, distance, self.wp)
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
self.distance(handle_a, handle_b, distance, self.wp)
else: else:
logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}") logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}")
return False return False
+372
View File
@@ -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
+213
View File
@@ -0,0 +1,213 @@
"""Tests for the A4 ISO 5457 sheet: layout regions, first-angle view
ordering, sheet furniture primitives, scale formatting, and the A4
normalisation of drawings loaded from .fluency files.
"""
import os
import sys
import unittest
# Allow running this file directly: ``python tests/test_iso5457_sheet.py``.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from fluency.models.data_model import DrawingView, TechnicalDrawing
from fluency.technical_drawing import (
_SHEET_HEIGHT_MM,
_SHEET_WIDTH_MM,
_format_drawing_scale,
_layout_views_on_sheet,
_sheet_frame_primitives,
)
def _view(kind):
return DrawingView(kind=kind, name=kind)
class TestSheetSize(unittest.TestCase):
"""The workbench renders a single A4 landscape sheet."""
def test_a4_dimensions(self):
self.assertEqual((_SHEET_WIDTH_MM, _SHEET_HEIGHT_MM), (297.0, 210.0))
def test_default_drawing_is_a4(self):
self.assertEqual(TechnicalDrawing().sheet_size, "A4")
class TestScaleFormatting(unittest.TestCase):
"""Title-block scale field formatting (``N : 1`` / ``1 : N``)."""
def test_none_and_one(self):
self.assertEqual(_format_drawing_scale(None), "1 : 1")
self.assertEqual(_format_drawing_scale(0.0), "1 : 1")
self.assertEqual(_format_drawing_scale(1.0), "1 : 1")
def test_enlarged(self):
self.assertEqual(_format_drawing_scale(2.0), "2 : 1")
self.assertEqual(_format_drawing_scale(1.5), "1.5 : 1")
def test_reduced(self):
self.assertEqual(_format_drawing_scale(0.5), "1 : 2")
self.assertEqual(_format_drawing_scale(0.33333), "1 : 3")
class TestFirstAngleLayout(unittest.TestCase):
"""View slots stay inside the ISO 5457 drawing-space frame, clear the
title-block reserve zone, and follow first-angle ordering (view from
the right left of front, view from the left right of front, top view
below front)."""
def _slots(self, kinds):
bboxes = {k: (0.0, 0.0, 40.0, 30.0) for k in kinds}
slots, scale, _rots = _layout_views_on_sheet(
[_view(k) for k in kinds], bboxes
)
return slots, scale
def test_slots_inside_frame(self):
slots, scale = self._slots(
("front", "top", "right", "left", "isometric")
)
self.assertIsNotNone(scale)
self.assertGreater(scale, 0)
for vid, (x, y, w, h) in slots.items():
# Drawing-space frame: 20..287 x 10..200.
self.assertGreaterEqual(x, 19.9, vid)
self.assertGreaterEqual(y, 9.9, vid)
self.assertLessEqual(x + w, 287.1, vid)
self.assertLessEqual(y + h, 200.1, vid)
# Title-block reserve zone: x >= 102, y <= 64.
self.assertTrue(
x + w <= 102.01 or y + h <= 0.01 or y >= 63.99,
(vid, (x, y, w, h)),
)
def test_first_angle_ordering(self):
slots, _ = self._slots(("front", "top", "right", "left"))
xf, wf = slots["front"][0], slots["front"][2]
yf = slots["front"][1]
# View from the right sits left of the front view.
self.assertLessEqual(
slots["right"][0] + slots["right"][2], xf + 0.01
)
# View from the left sits right of the front view.
self.assertGreaterEqual(slots["left"][0], xf + wf + 0.01)
# Top view sits below the front view.
self.assertLessEqual(slots["top"][1] + slots["top"][3], yf + 0.01)
def test_single_view_centered_upright(self):
slots, scale, rots = _layout_views_on_sheet(
[_view("front")], {"front": (0.0, 0.0, 40.0, 30.0)}
)
self.assertIsNotNone(scale)
self.assertEqual(rots.get("front", 0.0), 0.0)
self.assertGreater(scale, 0)
x, y, w, h = slots["front"]
self.assertGreaterEqual(x, 19.9)
self.assertGreaterEqual(y, 9.9)
class TestSheetFurniture(unittest.TestCase):
"""ISO 5457 furniture primitives mirror the reference SVG template."""
def _prims(self):
d = TechnicalDrawing(
title="Test Bracket",
part_number="PN-42",
material="Alu 6082",
revision="B",
)
return _sheet_frame_primitives(d, "1 : 2")
def test_all_geometry_inside_sheet(self):
for p in self._prims():
for (x, y) in p.points:
self.assertGreaterEqual(x, -0.01)
self.assertLessEqual(x, 297.01)
self.assertGreaterEqual(y, -0.01)
self.assertLessEqual(y, 210.01)
def test_drawing_space_frame(self):
# Frame rect 20,10 267x190: four edges present as line prims.
lines = [
(tuple(p.points[0]), tuple(p.points[1]))
for p in self._prims()
if p.kind == "line" and p.style == "frame"
]
for a, b in (
((20.0, 10.0), (287.0, 10.0)),
((287.0, 10.0), (287.0, 200.0)),
((287.0, 200.0), (20.0, 200.0)),
((20.0, 200.0), (20.0, 10.0)),
):
self.assertIn((a, b), lines)
def test_grid_reference_labels(self):
texts = [p.text for p in self._prims() if p.kind == "text" and p.text]
for t in ("1", "2", "3", "4", "5", "6", "A", "B", "C", "D", "A4"):
self.assertIn(t, texts)
def test_title_block_fields(self):
texts = [p.text for p in self._prims() if p.kind == "text" and p.text]
for expect in (
"Owner:",
"Drawing number:",
"Revision:",
"Issue date:",
"Sheet:",
"Language:",
"Title:",
"Approved by:",
"Created by:",
"Document type:",
"Part Material:",
"General tolerances:",
"Scale:",
"Test Bracket",
"PN-42",
"Alu 6082",
"B",
"1 : 2",
"EN",
"1 / 1",
"ISO 2768-m",
"Component Drawing",
):
self.assertIn(expect, texts)
def test_first_angle_projection_symbol(self):
# Two concentric circles at the symbol centre (273, 58) in sheet
# coords (SVG y-down 152 → 210 - 152 = 58).
circles = [
p
for p in self._prims()
if p.kind == "circle" and p.center == (273.0, 58.0)
]
self.assertEqual(len(circles), 2)
self.assertEqual(
sorted(c.radius for c in circles if c.radius), [2.5, 5.0]
)
class TestSheetSizeNormalisation(unittest.TestCase):
"""Drawings stored as A3 in .fluency files load as A4 — the renderer
only supports the A4 ISO 5457 sheet."""
def test_legacy_a3_loads_as_a4(self):
from fluency.io.project_io import _technical_drawing_from_dict
d = _technical_drawing_from_dict(
{
"id": "x",
"source_kind": "component",
"source_id": "c",
"sheet_size": "A3",
}
)
self.assertEqual(d.sheet_size, "A4")
if __name__ == "__main__":
unittest.main()
+282
View File
@@ -0,0 +1,282 @@
"""Smoke test for project_io save/load round-trip.
Builds a small project (a Component with a sketch, an extrude body, a
workplane, plus an assembly with two instances and a connector) and
verifies that saving then loading it preserves the data.
"""
import os
import sys
import tempfile
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, _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."""
def _build_project(self) -> Project:
project = Project(name="Test Project", description="A tiny test")
project.file_path = None # simulate untitled
comp = project.add_component(Component(name="Part1"))
# Add a workplane.
wp = Workplane(
name="Top",
origin=(0.0, 0.0, 0.0),
normal=(0.0, 0.0, 1.0),
x_dir=(1.0, 0.0, 0.0),
)
comp.add_workplane(wp)
# Add a sketch with a square.
sk = comp.add_sketch()
sk.occ_sketch.add_rectangle((0.0, 0.0), (10.0, 10.0))
sk.solve()
# Build a face geometry for the sketch (needed for export / restore).
faces = sk.occ_sketch.detect_faces()
if faces:
sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
# Extrude into a body.
if sk.geometry:
kernel = project.kernel
body_shape = kernel.extrude(sk.geometry, height=20.0)
body = comp.add_body(
Body(
name="Block",
geometry=body_shape,
source_sketch=sk,
source_operation="extrude",
)
)
body.color = (0.4, 0.2, 0.8)
# Add an assembly with two instances and a mated connector pair.
asm = project.add_assembly(Assembly(name="Asm1"))
ac1 = asm.add_component_instance(comp.id, name="Inst1")
ac2 = asm.add_component_instance(comp.id, name="Inst2")
c1 = ac1.add_connector(
position=(5.0, 5.0, 0.0),
normal=(0.0, 0.0, 1.0),
x_dir=(1.0, 0.0, 0.0),
)
c2 = ac2.add_connector(
position=(10.0, 10.0, 5.0),
normal=(0.0, 0.0, -1.0),
x_dir=(1.0, 0.0, 0.0),
)
# Record a mated pair (UI normally does this on connector-pick).
conn = asm.add_connection(ac1.id, ac2.id)
conn.first_connector_id = c1.id
conn.second_connector_id = c2.id
c1.partner_ac_id = ac2.id
c1.partner_connector_id = c2.id
c2.partner_ac_id = ac1.id
c2.partner_connector_id = c1.id
c1.is_grounded = True
return project
def test_round_trip(self):
original = self._build_project()
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "test.fluency")
saved_path = save_project(original, path)
self.assertTrue(os.path.exists(saved_path))
self.assertGreater(os.path.getsize(saved_path), 100)
loaded, view_state = load_project(saved_path)
# ── Project metadata ──
self.assertEqual(loaded.name, "Test Project")
self.assertEqual(loaded.description, "A tiny test")
self.assertEqual(len(loaded.components), 1)
self.assertEqual(len(loaded.assemblies), 1)
# ── Component / Workplane / Sketch / Body ──
comp = next(iter(loaded.components.values()))
self.assertEqual(comp.name, "Part1")
self.assertEqual(len(comp.workplanes), 1)
self.assertEqual(len(comp.sketches), 1)
self.assertEqual(len(comp.bodies), 1)
sk = next(iter(comp.sketches.values()))
self.assertIsNotNone(sk.occ_sketch)
# OCCSketch replayed the rectangle: 4 points + 4 lines + 1 implicit
# origin anchor (first-point-fix). We only check that the entities
# exist.
self.assertGreaterEqual(sk.occ_sketch.get_entity_count(), 4)
# Solved geometry should round-trip through STEP.
self.assertIsNotNone(sk.geometry)
body = next(iter(comp.bodies.values()))
self.assertIsNotNone(body.geometry)
self.assertEqual(body.name, "Block")
self.assertEqual(tuple(body.color), (0.4, 0.2, 0.8))
# BRep topology should still be valid.
self.assertGreater(body.get_mesh(loaded.kernel, 0.5)[0].size, 0)
# ── Assembly / connector / connection ──
asm = next(iter(loaded.assemblies.values()))
self.assertEqual(len(asm.components), 2)
self.assertEqual(len(asm.connections), 1)
ac1, ac2 = list(asm.components.values())
self.assertEqual(len(ac1.connectors), 1)
conn = next(iter(ac1.connectors.values()))
self.assertEqual(conn.position, (5.0, 5.0, 0.0))
# The grounded-reference flag was re-applied to the first connector.
self.assertTrue(conn.is_grounded)
# Rigid-group BFS should still link the two instances.
self.assertEqual(set(asm.get_rigid_group(ac1.id)), {ac1.id, ac2.id})
class TestProjectIOWithConstraints(unittest.TestCase):
"""Round-trip with parametric constraints on the sketch.
Builds a slightly more interesting sketch (rectangle with horizontal +
vertical constraints + a distance) so the constraint-log replay path
is exercised, not just the entity-construction path.
"""
def _build_project(self) -> Project:
project = Project(name="Constraints Project", description="")
comp = project.add_component(Component(name="Part1"))
# A square with a 25.4mm horizontal distance + a vertical distance,
# both anchored on a single fixed corner. This is the minimum
# number of constraints to fully define a square in 2D.
sk = comp.add_sketch()
sk.occ_sketch.set_workplane(
(0.0, 0.0, 0.0),
(0.0, 0.0, 1.0),
(1.0, 0.0, 0.0),
)
p1 = sk.occ_sketch.add_point(0.0, 0.0) # fixed anchor (auto)
p2 = sk.occ_sketch.add_point(25.4, 0.0)
p3 = sk.occ_sketch.add_point(25.4, 25.4)
p4 = sk.occ_sketch.add_point(0.0, 25.4)
sk.occ_sketch.add_line(p1, p2)
sk.occ_sketch.add_line(p2, p3)
sk.occ_sketch.add_line(p3, p4)
sk.occ_sketch.add_line(p4, p1)
# Constraint the right side to a known length (instead of relying
# on the construction positions, which would just be redundant).
sk.occ_sketch.constrain_distance(p2, p3, 25.4)
sk.solve()
sk.is_fully_constrained = sk.occ_sketch.is_fully_constrained()
faces = sk.occ_sketch.detect_faces()
if faces:
sk.geometry = sk.occ_sketch.build_face_geometry(faces[0])
# View state to persist.
view_state = {
"active_tab": 0,
"active_component_id": comp.id,
"active_sketch_id": sk.id,
"camera_eye": [50.0, 50.0, 50.0],
"camera_at": [0.0, 0.0, 0.0],
"camera_up": [0.0, 0.0, 1.0],
"panel_focus": "sketch",
"assembly_view_active": False,
}
self._view_state = view_state
return project
def test_round_trip_with_view_state(self):
original = self._build_project()
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "constrained.fluency")
save_project(original, path, view_state=self._view_state)
loaded, view_state = load_project(path)
# View state must survive (modulo float-to-list round-tripping).
self.assertEqual(view_state.get("active_component_id"),
self._view_state["active_component_id"])
self.assertEqual(view_state.get("active_sketch_id"),
self._view_state["active_sketch_id"])
self.assertEqual(view_state.get("camera_eye"),
self._view_state["camera_eye"])
self.assertEqual(view_state.get("panel_focus"),
self._view_state["panel_focus"])
# Sketch must have replayed its constraints and remain solvable.
comp = next(iter(loaded.components.values()))
sk = next(iter(comp.sketches.values()))
# Re-solve on the loaded sketch to confirm the post-replay
# configuration is still consistent.
self.assertTrue(sk.occ_sketch.solve())
# The right edge of the square should still be 25.4mm tall.
import math
for lid, line in sk.occ_sketch._lines.items():
sid, eid = line
sx, sy = sk.occ_sketch._points[sid]
ex, ey = sk.occ_sketch._points[eid]
length = math.hypot(ex - sx, ey - sy)
if abs(length) > 1e-3:
self.assertAlmostEqual(
length, 25.4, places=3,
msg=f"Constraint replay broke: line {lid} = {length}",
)
break
if __name__ == "__main__":
unittest.main()
+15
View File
@@ -0,0 +1,15 @@
"""Fluency CAD UI package.
Contains the Qt widgets, dialogs, and the main window:
ui/
dialogs.py All 4 modal dialogs (Extrude, Revolve, Offset, WorkplaneOrientation)
sketch_widget.py Sketch2DWidget (2D sketcher with constraint solver)
viewer_widget.py Viewer3DWidget (3D viewer / OCC canvas)
main_window.py MainWindow (application shell)
The public classes are re-exported from `fluency.main` so existing code that
does `from fluency.main import MainWindow` continues to work.
"""
__all__: list[str] = []
File diff suppressed because it is too large Load Diff
+906
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+130 -115
View File
@@ -1,7 +1,6 @@
"""Tests for Fluency CAD geometry kernel.""" """Tests for Fluency CAD geometry kernel."""
import pytest import pytest
import numpy as np
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch from fluency.geometry_occ.sketch import OCCSketch
@@ -195,10 +194,14 @@ class TestOCCSketch:
sk = OCCSketch() sk = OCCSketch()
sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir) sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir)
# 20x20 square in UV # 20x20 square in UV
p0 = sk.add_point(-10, -10); p1 = sk.add_point(10, -10) p0 = sk.add_point(-10, -10)
p2 = sk.add_point(10, 10); p3 = sk.add_point(-10, 10) p1 = sk.add_point(10, -10)
sk.add_line(p0, p1); sk.add_line(p1, p2) p2 = sk.add_point(10, 10)
sk.add_line(p2, p3); sk.add_line(p3, p0) 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() geom = sk.get_geometry()
# The face must carry the plane normal for the kernel. # The face must carry the plane normal for the kernel.
@@ -220,10 +223,14 @@ class TestOCCSketch:
sk = OCCSketch() sk = OCCSketch()
sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0)) sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0))
a = sk.add_point(-10, -10); b = sk.add_point(10, -10) a = sk.add_point(-10, -10)
c = sk.add_point(10, 10); d = sk.add_point(-10, 10) b = sk.add_point(10, -10)
sk.add_line(a, b); sk.add_line(b, c) c = sk.add_point(10, 10)
sk.add_line(c, d); sk.add_line(d, a) 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) ctr = sk.add_point(0, 0)
sk.add_circle(ctr, 3.0) sk.add_circle(ctr, 3.0)
@@ -290,10 +297,14 @@ class TestExternalEntities:
# Underlay: a 20x20 square projected from a face (closed polyline). # Underlay: a 20x20 square projected from a face (closed polyline).
sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)]) 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. # User profile: a 5x5 square — this is what should be extruded.
a = sk.add_point(2, 2); b = sk.add_point(8, 2) a = sk.add_point(2, 2)
c = sk.add_point(8, 8); d = sk.add_point(2, 8) b = sk.add_point(8, 2)
sk.add_line(a, b); sk.add_line(b, c) c = sk.add_point(8, 8)
sk.add_line(c, d); sk.add_line(d, a) 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() faces = sk.detect_faces()
# Only the user-drawn face (5x5 square) should be detected. # Only the user-drawn face (5x5 square) should be detected.
assert len(faces) == 1 assert len(faces) == 1
@@ -310,10 +321,14 @@ class TestExternalEntities:
def test_external_entities_excluded_from_get_polygon_points(self): def test_external_entities_excluded_from_get_polygon_points(self):
sk = OCCSketch() sk = OCCSketch()
sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)]) 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) a = sk.add_point(1, 1)
c = sk.add_point(2, 2); d = sk.add_point(1, 2) b = sk.add_point(2, 1)
sk.add_line(a, b); sk.add_line(b, c) c = sk.add_point(2, 2)
sk.add_line(c, d); sk.add_line(d, a) 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() poly = sk.get_polygon_points()
# The user square (1..2 range) should appear, not the 0..100 underlay. # 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) assert all(1.0 <= p.x <= 2.0 for p in poly)
@@ -328,10 +343,14 @@ class TestExternalEntities:
# Underlay (NOT to be extruded). # Underlay (NOT to be extruded).
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)]) sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
# User profile: a 2x2 square inside the underlay. # User profile: a 2x2 square inside the underlay.
a = sk.add_point(1, 1); b = sk.add_point(3, 1) a = sk.add_point(1, 1)
c = sk.add_point(3, 3); d = sk.add_point(1, 3) b = sk.add_point(3, 1)
sk.add_line(a, b); sk.add_line(b, c) c = sk.add_point(3, 3)
sk.add_line(c, d); sk.add_line(d, a) 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() geom = sk.get_geometry()
# Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400. # Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
kernel = OCGeometryKernel() kernel = OCGeometryKernel()
@@ -498,10 +517,9 @@ class TestExtrudeCutFix:
and the tool is no longer needed. and the tool is no longer needed.
""" """
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox 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.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp from OCP.BRepGProp import BRepGProp
import math
k = OCGeometryKernel() k = OCGeometryKernel()
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape() target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
@@ -511,17 +529,18 @@ class TestExtrudeCutFix:
# expected volume easy to compute. # expected volume easy to compute.
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.gp import gp_Pnt, gp_Vec from OCP.gp import gp_Pnt, gp_Vec
# 20x20 square at (0,0,0), extruded along +Z by 200. # 20x20 square at (0,0,0), extruded along +Z by 200.
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
mp = 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.Add(gp_Pnt(x, y, 0))
mp.Close() mp.Close()
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
face = BRepBuilderAPI_MakeFace(mp.Wire()).Face() face = BRepBuilderAPI_MakeFace(mp.Wire()).Face()
tool_shape = BRepPrimAPI_MakePrism( tool_shape = BRepPrimAPI_MakePrism(face, gp_Vec(0, 0, 200)).Shape()
face, gp_Vec(0, 0, 200)
).Shape()
tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"}) tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
# Before cut: target is 100^3 = 1_000_000. # 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 # 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). # (the prism only intersects the box in z=[0,100], i.e. 100 deep).
g1 = GProp_GProps() g1 = GProp_GProps()
BRepGProp.VolumeProperties_s( BRepGProp.VolumeProperties_s(k._get_shape(target_obj_geometry), g1)
k._get_shape(target_obj_geometry), g1
)
assert abs(g1.Mass() - 960_000.0) < 1.0 assert abs(g1.Mass() - 960_000.0) < 1.0
def test_boolean_difference_does_not_leave_separate_cavity_body(self): def test_boolean_difference_does_not_leave_separate_cavity_body(self):
@@ -551,10 +568,9 @@ class TestExtrudeCutFix:
target, so a single body remains. target, so a single body remains.
""" """
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
from OCP.TopExp import TopExp_Explorer from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_SOLID from OCP.TopAbs import TopAbs_SOLID
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject from fluency.geometry_occ.kernel import OCGeometryKernel
k = OCGeometryKernel() k = OCGeometryKernel()
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape() target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
@@ -562,6 +578,7 @@ class TestExtrudeCutFix:
# Tool: small box at the centre, fully inside the target. # Tool: small box at the centre, fully inside the target.
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
tool_shape = BBox(20, 20, 20).Shape() tool_shape = BBox(20, 20, 20).Shape()
tool_obj = OCCGeometryObject(tool_shape, {}) tool_obj = OCCGeometryObject(tool_shape, {})
@@ -595,91 +612,59 @@ class TestBodyVisibilityToggle:
def _make_window(self): def _make_window(self):
import os import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow from fluency.main import MainWindow
return MainWindow() return MainWindow()
def test_body_list_uses_checkable_items(self): 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 from PySide6.QtCore import Qt
win = self._make_window() win = self._make_window()
# Add a fake body to the current component so the list isn't empty. # Add a fake body to the current component so the list isn't empty.
from fluency.models.data_model import Body from fluency.models.data_model import Body
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from fluency.geometry_occ.kernel import OCCGeometryObject
box = OCCGeometryObject( box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
)
win._current_component.bodies["a"] = Body(name="A", geometry=box) win._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists() win._refresh_lists()
items = win._body_list.findItems("A", Qt.MatchExactly) items = win._body_list.findItems("A", Qt.MatchExactly)
assert len(items) == 1 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. # And the body id is stored on the item for the toggle handler.
assert items[0].data(Qt.UserRole) == "a" assert items[0].data(Qt.UserRole) == "a"
# Default state is checked (= visible). # Default state is visible.
assert items[0].checkState() == Qt.Checked assert win._current_component.bodies["a"].visible is True
def test_toggling_visibility_updates_body_model(self): 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 from PySide6.QtCore import Qt
win = self._make_window() win = self._make_window()
from fluency.models.data_model import Body from fluency.models.data_model import Body
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from fluency.geometry_occ.kernel import OCCGeometryObject
box = OCCGeometryObject( box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
)
win._current_component.bodies["a"] = Body(name="A", geometry=box) win._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists() win._refresh_lists()
item = win._body_list.findItems("A", Qt.MatchExactly)[0] item = win._body_list.findItems("A", Qt.MatchExactly)[0]
# Toggle off. # Toggle off.
item.setCheckState(Qt.Unchecked)
win._on_body_visibility_changed(item) win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is False assert win._current_component.bodies["a"].visible is False
# Toggle back on. # Toggle back on.
item.setCheckState(Qt.Checked)
win._on_body_visibility_changed(item) win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is True 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): def math_hypot(x, y):
import math import math
return math.hypot(x, y) return math.hypot(x, y)
@@ -698,10 +683,13 @@ class TestConstraintTagRendering:
def _make_widget_with_sketch(self, sk): def _make_widget_with_sketch(self, sk):
"""Build a Sketch2DWidget in offscreen mode and attach *sk* to it.""" """Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
import os import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
from fluency.main import Sketch2DWidget from fluency.main import Sketch2DWidget
w = Sketch2DWidget() w = Sketch2DWidget()
w.set_sketch(sk) w.set_sketch(sk)
return w return w
@@ -824,6 +812,7 @@ class TestConstraintTagRendering:
class _BadRound: class _BadRound:
def __round__(self, ndigits=0): def __round__(self, ndigits=0):
raise TypeError("cannot round") raise TypeError("cannot round")
sk._entities[c.id].geometry = (_BadRound(), _BadRound()) sk._entities[c.id].geometry = (_BadRound(), _BadRound())
tags = w._compute_constraint_tags() tags = w._compute_constraint_tags()
assert all(t["center"] is not None for t in 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): def _make_window_with_box(self, box_side=100.0):
import os import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow from fluency.main import MainWindow
from fluency.models.data_model import Sketch, Body from fluency.models.data_model import Sketch, Body
from fluency.geometry_occ.kernel import OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch from fluency.geometry_occ.sketch import OCCSketch
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
@@ -896,7 +886,6 @@ class TestExtrudeRedesign:
return win, sketch, sk, box_obj return win, sketch, sk, box_obj
def _add_circle(self, sk, r=10.0): def _add_circle(self, sk, r=10.0):
from fluency.geometry_occ.sketch import OCCSketch
c = sk.add_point(0, 0) c = sk.add_point(0, 0)
sk.add_circle(c, r) sk.add_circle(c, r)
sk.solve() sk.solve()
@@ -905,6 +894,7 @@ class TestExtrudeRedesign:
def _geometry_volume(self, win, geom): def _geometry_volume(self, win, geom):
from OCP.GProp import GProp_GProps from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp from OCP.BRepGProp import BRepGProp
sh = win._kernel._get_shape(geom) sh = win._kernel._get_shape(geom)
g = GProp_GProps() g = GProp_GProps()
BRepGProp.VolumeProperties_s(sh, g) BRepGProp.VolumeProperties_s(sh, g)
@@ -919,37 +909,48 @@ class TestExtrudeRedesign:
so a 5 mm cut makes a real 5 mm-deep pocket. so a 5 mm cut makes a real 5 mm-deep pocket.
""" """
import math import math
win, sketch, sk, box_obj = self._make_window_with_box(100.0) win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0) face_geom = self._add_circle(sk, r=10.0)
# Plain cut, length=5, NOT inverted. Pre-redesign this would have # Plain cut, length=5, NOT inverted. Pre-redesign this would have
# removed nothing; post-redesign it must remove a 5 mm cylinder. # removed nothing; post-redesign it must remove a 5 mm cylinder.
result = win._compute_extrude_result( result = win._compute_extrude_result(
sketch, face_geom, sketch,
length=5.0, symmetric=False, invert=False, face_geom,
cut=True, union=False, through_all=False, length=5.0,
symmetric=False,
invert=False,
cut=True,
union=False,
through_all=False,
) )
assert result is not None assert result is not None
assert result["target_body"] is not None assert result["target_body"] is not None
assert result["target_body"].name == "Box1" assert result["target_body"].name == "Box1"
vol = self._geometry_volume(win, result["result_geom"]) 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 assert abs(vol - expected) < 1.0
def test_cut_through_all_passes_through(self): 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 import math
win, sketch, sk, box_obj = self._make_window_with_box(100.0) win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0) face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result( result = win._compute_extrude_result(
sketch, face_geom, sketch,
face_geom,
length=5.0, # ignored when through_all length=5.0, # ignored when through_all
symmetric=False, invert=False, symmetric=False,
cut=True, union=False, through_all=True, invert=False,
cut=True,
union=False,
through_all=True,
) )
assert result is not None assert result is not None
vol = self._geometry_volume(win, result["result_geom"]) vol = self._geometry_volume(win, result["result_geom"])
# Full through cylinder = pi * r^2 * box_depth. # 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 assert abs(vol - expected) < 1.0
def test_cut_auto_targets_source_body_not_existing_zero(self): def test_cut_auto_targets_source_body_not_existing_zero(self):
@@ -960,30 +961,23 @@ class TestExtrudeRedesign:
""" """
import math import math
import os import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow from fluency.main import MainWindow
from fluency.models.data_model import Sketch, Body from fluency.models.data_model import Sketch, Body
from fluency.geometry_occ.kernel import OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch from fluency.geometry_occ.sketch import OCCSketch
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
win = MainWindow() win = MainWindow()
# First body in the dict: a 50-millimetre box ALSO. # First body in the dict: a 50-millimetre box ALSO.
first = OCCGeometryObject( first = OCCGeometryObject(BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {})
BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {} win._current_component.bodies["first"] = Body(name="First", geometry=first)
)
win._current_component.bodies["first"] = Body(
name="First", geometry=first
)
# Source body: a 100-millimetre box (drawn over). # Source body: a 100-millimetre box (drawn over).
src = OCCGeometryObject( src = OCCGeometryObject(BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {})
BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {} win._current_component.bodies["src"] = Body(name="Src", geometry=src)
)
win._current_component.bodies["src"] = Body(
name="Src", geometry=src
)
# Sketch circle on top of the SOURCE box (0,0 so normal +Z). # Sketch circle on top of the SOURCE box (0,0 so normal +Z).
sk = OCCSketch() sk = OCCSketch()
sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0)) sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
@@ -999,16 +993,21 @@ class TestExtrudeRedesign:
face_geom = sk.get_geometry() face_geom = sk.get_geometry()
result = win._compute_extrude_result( result = win._compute_extrude_result(
sketch, face_geom, sketch,
length=5.0, symmetric=False, invert=False, face_geom,
cut=True, union=False, through_all=True, length=5.0,
symmetric=False,
invert=False,
cut=True,
union=False,
through_all=True,
) )
assert result is not None assert result is not None
# Target is the source box, NOT the dict's first body. # Target is the source box, NOT the dict's first body.
assert result["target_body"].name == "Src" assert result["target_body"].name == "Src"
vol = self._geometry_volume(win, result["result_geom"]) vol = self._geometry_volume(win, result["result_geom"])
# 100^3 - pi*100*100 (through-all full-depth cut on the 100 box). # 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 assert abs(vol - expected) < 1.0
def test_union_default_builds_outward(self): def test_union_default_builds_outward(self):
@@ -1019,17 +1018,23 @@ class TestExtrudeRedesign:
rather than "subtracting" from the existing box. rather than "subtracting" from the existing box.
""" """
import math import math
win, sketch, sk, box_obj = self._make_window_with_box(100.0) win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0) face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result( result = win._compute_extrude_result(
sketch, face_geom, sketch,
length=10.0, symmetric=False, invert=False, face_geom,
cut=False, union=True, through_all=False, length=10.0,
symmetric=False,
invert=False,
cut=False,
union=True,
through_all=False,
) )
assert result is not None assert result is not None
vol = self._geometry_volume(win, result["result_geom"]) vol = self._geometry_volume(win, result["result_geom"])
# 100^3 + pi*100*10 — material added on top. # 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 assert abs(vol - expected) < 1.0
def test_plain_extrude_untouched_by_source_body(self): 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) win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0) face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result( result = win._compute_extrude_result(
sketch, face_geom, sketch,
length=10.0, symmetric=False, invert=False, face_geom,
cut=False, union=False, through_all=False, length=10.0,
symmetric=False,
invert=False,
cut=False,
union=False,
through_all=False,
) )
assert result is not None assert result is not None
# No boolean target; result is the standalone tool extrusion. # No boolean target; result is the standalone tool extrusion.
@@ -1047,7 +1057,8 @@ class TestExtrudeRedesign:
vol = self._geometry_volume(win, result["result_geom"]) vol = self._geometry_volume(win, result["result_geom"])
# Standalone cylinder 10 mm tall. # Standalone cylinder 10 mm tall.
import math 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): def test_freshly_picked_sketch_is_auto_selected(self):
"""After _on_face_picked, the new sketch is the current list row. """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 The user should be able to click Extrude/Cut immediately without
first hunting for the new sketch in the left list. 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) win, _, sk, box_obj = self._make_window_with_box(100.0)
# Simulate _on_face_picked by calling it through a fake face # Simulate _on_face_picked by calling it through a fake face
# shape — but the simplest behavioural check is to call the # 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 # set as _current_sketch, and it appears (and is selected) in
# the list after _refresh_lists + setCurrentRow. # the list after _refresh_lists + setCurrentRow.
from fluency.models.data_model import Sketch from fluency.models.data_model import Sketch
sketch = Sketch(name="Sketch on face 99") sketch = Sketch(name="Sketch on face 99")
sketch._source_body_id = "b1" sketch._source_body_id = "b1"
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0)) 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): def test_preview_callback_invoked_on_value_change(self):
"""The live preview callback fires on spinbox/checkbox changes.""" """The live preview callback fires on spinbox/checkbox changes."""
import os import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
from fluency.main import ExtrudeDialog from fluency.main import ExtrudeDialog
@@ -1110,8 +1123,10 @@ class TestExtrudeRedesign:
def test_preview_hidden_event_sends_none(self): def test_preview_hidden_event_sends_none(self):
"""hideEvent should deliver None to the callback so the host clears.""" """hideEvent should deliver None to the callback so the host clears."""
import os import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
from fluency.main import ExtrudeDialog from fluency.main import ExtrudeDialog
+916
View File
@@ -0,0 +1,916 @@
"""Tests for the technical drawing workbench.
Covers the manual-dimension pipeline (model-space annotations
sheet-space candidates placed primitives), the 2D pick geometry
helpers, and persistence of drawings (manual dimensions included) in
the .fluency project file.
"""
import json
import math
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from fluency.models.data_model import (
Body,
Component,
DrawingAnnotation,
DrawingView,
Project,
TechnicalDrawing,
)
from fluency.technical_drawing import (
build_manual_candidates,
generate_drawing,
_layout_views_on_sheet,
)
from fluency.io.project_io import (
_technical_drawing_from_dict,
_technical_drawing_to_dict,
load_project,
save_project,
)
from fluency.ui.technical_drawing_widget import (
_closest_point_on_segment,
_closest_points_on_segments,
_line_intersection,
_point_to_segment,
)
# ── Fixtures ───────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def qapp():
"""Offscreen QApplication for widget-level tests."""
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
yield app
def _drawing_with_views(view_kinds=("front",)):
drawing = TechnicalDrawing(source_kind="component", source_id="comp-1")
for kind in view_kinds:
drawing.views.append(DrawingView(kind=kind))
return drawing
def _manual_annotation(
dimension_kind: str,
view_id: str,
anchors,
direction=None,
) -> DrawingAnnotation:
return DrawingAnnotation(
kind="dimension",
dimension_kind=dimension_kind,
view_id=view_id,
anchors=list(anchors),
direction=direction,
)
# ── build_manual_candidates ────────────────────────────────────────────────
class TestBuildManualCandidates:
def test_length_candidate_reprojects_anchors(self):
drawing = _drawing_with_views()
ann = _manual_annotation(
"length", "front", ((0.0, 0.0), (0.0, 12.5)), direction=(0.0, 1.0)
)
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(
drawing, {"front": (2.0, 10.0, 20.0)}
)
assert unresolved == []
assert resolved == [ann.id]
c = cands[0]
assert c.kind == "length"
assert c.view_id == "front"
assert c.key == f"manual:{ann.id}"
assert c.references == (ann.id,)
assert c.value == pytest.approx(12.5)
assert c.label == "12.50"
# sheet = model * scale + offset
assert c.anchor_points[0] == pytest.approx((10.0, 20.0))
assert c.anchor_points[1] == pytest.approx((10.0, 45.0))
assert c.direction == (0.0, 1.0)
def test_length_without_transform_is_unresolved(self):
drawing = _drawing_with_views()
ann = _manual_annotation("length", "front", ((0.0, 0.0), (3.0, 4.0)))
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(drawing, {})
assert cands == []
assert unresolved == [ann.id]
cands, resolved, unresolved = build_manual_candidates(
drawing, {"front": (1.0, 0.0, 0.0)}
)
assert unresolved == []
c = cands[0]
assert c.value == pytest.approx(5.0)
assert c.direction == pytest.approx((0.6, 0.8))
def test_diameter_candidate(self):
drawing = _drawing_with_views()
ann = _manual_annotation("diameter", "top", ((-5.0, 0.0), (5.0, 0.0)))
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(
drawing, {"top": (1.0, 0.0, 0.0)}
)
assert unresolved == []
c = cands[0]
assert c.kind == "diameter"
assert c.value == pytest.approx(10.0)
assert c.label == "Ø10.00"
assert c.direction == ()
def test_angle_candidate(self):
drawing = _drawing_with_views()
ann = _manual_annotation(
"angle", "front", ((0.0, 0.0), (10.0, 0.0), (0.0, 10.0))
)
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(
drawing, {"front": (1.0, 0.0, 0.0)}
)
assert unresolved == []
c = cands[0]
assert c.kind == "angle"
assert c.value == pytest.approx(90.0)
assert c.label == "90.00°"
assert len(c.anchor_points) == 3
def test_degenerate_angle_is_unresolved(self):
# Collinear arms → 180° → not a usable angle dimension.
drawing = _drawing_with_views()
ann = _manual_annotation(
"angle", "front", ((0.0, 0.0), (10.0, 0.0), (-10.0, 0.0))
)
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(
drawing, {"front": (1.0, 0.0, 0.0)}
)
assert cands == []
assert resolved == []
assert unresolved == [ann.id]
def test_hidden_annotation_is_skipped(self):
drawing = _drawing_with_views()
ann = _manual_annotation("length", "front", ((0.0, 0.0), (1.0, 0.0)))
ann.visible = False
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(
drawing, {"front": (1.0, 0.0, 0.0)}
)
assert cands == []
assert resolved == []
assert unresolved == []
# ── generate_drawing: auto vs manual dimensions ───────────────────────────
@pytest.fixture(scope="module")
def kernel():
from fluency.geometry_occ.kernel import OCGeometryKernel
return OCGeometryKernel()
@pytest.fixture(scope="module")
def box_project(kernel):
"""Project with one 10 x 20 x 5 box (x: 0..10, y: 0..20, z: 0..5)."""
from fluency.geometry.base import Point2D
points = [Point2D(0, 0), Point2D(10, 0), Point2D(10, 20), Point2D(0, 20)]
polygon = kernel.create_polygon(points)
box = kernel.extrude(polygon, 5.0)
body = Body(name="Box", geometry=box)
comp = Component(name="BoxComp")
comp.bodies[body.id] = body
project = Project()
project.components[comp.id] = comp
project.active_component = comp.id
return project, comp
class TestGenerateDrawingManualDimensions:
def test_manual_dimension_placed_with_auto_off(self, kernel, box_project):
project, comp = box_project
drawing = TechnicalDrawing(
source_kind="component",
source_id=comp.id,
views=[DrawingView(kind="front")],
auto_dimensions=False,
)
# Distance between the two vertical edges of the box front face.
ann = _manual_annotation(
"length", "front", ((0.0, 0.0), (10.0, 0.0)), direction=(1.0, 0.0)
)
drawing.annotations.append(ann)
result = generate_drawing(drawing, project, kernel)
assert result.view_transforms, "view transforms must be published"
manual_keys = [
p.candidate_key for p in result.primitives if p.candidate_key
]
assert f"manual:{ann.id}" in manual_keys
label_texts = [
p.text
for p in result.primitives
if p.candidate_key == f"manual:{ann.id}" and p.kind == "text"
]
assert label_texts == ["10.00"]
# Auto off → no auto-placed dimensions.
assert not any(
k for k in manual_keys if not k.startswith("manual:")
), "auto dimensions must stay out while auto_dimensions is off"
assert ann.id in result.resolved_annotation_ids
def test_auto_off_places_no_auto_dimensions(self, kernel, box_project):
project, comp = box_project
drawing = TechnicalDrawing(
source_kind="component",
source_id=comp.id,
views=[DrawingView(kind="front")],
auto_dimensions=False,
)
result = generate_drawing(drawing, project, kernel)
dim_keys = [p.candidate_key for p in result.primitives if p.candidate_key]
assert dim_keys == [], f"expected no dimensions, got {dim_keys}"
def test_auto_on_places_auto_dimensions(self, kernel, box_project):
project, comp = box_project
drawing = TechnicalDrawing(
source_kind="component",
source_id=comp.id,
views=[DrawingView(kind="front")],
auto_dimensions=True,
)
result = generate_drawing(drawing, project, kernel)
dim_keys = [p.candidate_key for p in result.primitives if p.candidate_key]
assert dim_keys, "auto dimensions expected with auto_dimensions on"
assert all(not k.startswith("manual:") for k in dim_keys)
def test_auto_and_manual_coexist(self, kernel, box_project):
project, comp = box_project
drawing = TechnicalDrawing(
source_kind="component",
source_id=comp.id,
views=[DrawingView(kind="front")],
auto_dimensions=True,
)
ann = _manual_annotation(
"length", "front", ((0.0, 0.0), (10.0, 0.0)), direction=(1.0, 0.0)
)
drawing.annotations.append(ann)
result = generate_drawing(drawing, project, kernel)
dim_keys = {p.candidate_key for p in result.primitives if p.candidate_key}
assert f"manual:{ann.id}" in dim_keys
assert any(k for k in dim_keys if not k.startswith("manual:"))
def test_diameter_manual_on_cylinder(self, kernel):
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt
from fluency.geometry_occ.kernel import OCCGeometryObject
ax = gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1))
cyl = OCCGeometryObject(
BRepPrimAPI_MakeCylinder(ax, 4.0, 8.0).Shape(),
{"type": "cylinder"},
)
body = Body(name="Cyl", geometry=cyl)
comp = Component(name="CylComp")
comp.bodies[body.id] = body
project = Project()
project.components[comp.id] = comp
project.active_component = comp.id
drawing = TechnicalDrawing(
source_kind="component",
source_id=comp.id,
views=[DrawingView(kind="front")],
auto_dimensions=False,
)
ann = _manual_annotation("diameter", "front", ((-4.0, 0.0), (4.0, 0.0)))
drawing.annotations.append(ann)
result = generate_drawing(drawing, project, kernel)
label_texts = [
p.text
for p in result.primitives
if p.candidate_key == f"manual:{ann.id}" and p.kind == "text"
]
assert label_texts == ["Ø8.00"]
# ── Circle centres: ISO center marks + centre-point dimensioning ──────────
def _cylinder_project(kernel):
"""One Ø8 x 8 cylinder (axis +Z) as a draw-able component.
HLR may split a circle's edge into sampled segments for some shapes,
so tests that need a guaranteed circle primitive build it directly
(see :class:`TestCircleCenterMarks` / :class:`TestCircleCenterPick`).
"""
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt
from fluency.geometry_occ.kernel import OCCGeometryObject
ax = gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1))
cyl = OCCGeometryObject(
BRepPrimAPI_MakeCylinder(ax, 4.0, 8.0).Shape(),
{"type": "cylinder"},
)
body = Body(name="Cyl", geometry=cyl)
comp = Component(name="CylComp")
comp.bodies[body.id] = body
project = Project()
project.components[comp.id] = comp
project.active_component = comp.id
return project, comp
class TestCircleCenterMarks:
"""ISO 14128 center marks: a thin cross at each projected circle's
centre, crossing at the centre and extending past the circle edge."""
def test_center_marks_emitted_for_circles(self):
from fluency.technical_drawing import _assemble_view
# Synthetic view plane: a 20 × 16 box with a r4 circle at (10, 8).
edges = [
((0.0, 0.0), (20.0, 0.0), "line", "visible"),
((20.0, 0.0), (20.0, 16.0), "line", "visible"),
((20.0, 16.0), (0.0, 16.0), "line", "visible"),
((0.0, 16.0), (0.0, 0.0), "line", "visible"),
((10.0, 8.0), (14.0, 8.0), "circle_full", "visible"),
]
view = DrawingView(kind="top")
prims, _cands, _warns = _assemble_view(edges, [], view, (10, 10, 200, 150))
circles = [p for p in prims if p.kind == "circle"]
assert len(circles) == 1
c = circles[0]
cx, cy = c.center
marks = [p for p in prims if p.kind == "line" and p.style == "center"]
assert len(marks) == 2, "one horizontal and one vertical center mark"
horiz = next(p for p in marks if p.points[0][1] == p.points[1][1])
vert = next(p for p in marks if p.points[0][0] == p.points[1][0])
# The marks cross at the circle centre.
assert horiz.points[0][1] == cy and horiz.points[1][1] == cy
assert vert.points[0][0] == cx and vert.points[1][0] == cx
# And each extends past the circle edge.
half_h = abs(horiz.points[1][0] - horiz.points[0][0]) / 2.0
half_v = abs(vert.points[1][1] - vert.points[0][1]) / 2.0
assert half_h > c.radius
assert half_v > c.radius
class TestCircleCenterAnchors:
"""Distance picks between circle centres, and between a centre and an
edge, resolve to the right model-space anchor pair."""
def _w(self):
from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
return TechnicalDrawingWidget
def test_pick_point_kinds(self):
W = self._w()
assert W._pick_point({"kind": "point", "point": (1.0, 2.0)}) == (1.0, 2.0)
assert W._pick_point(
{"kind": "circle", "center": (3.0, 4.0), "radius": 1.0}
) == (3.0, 4.0)
assert W._pick_point({"kind": "segment", "p1": (0, 0), "p2": (1, 1)}) is None
def test_center_to_center(self):
W = self._w()
a = {"kind": "circle", "view_id": "v", "center": (0.0, 0.0), "radius": 2.0}
b = {"kind": "circle", "view_id": "v", "center": (5.0, 12.0), "radius": 3.0}
p1, p2 = W._distance_anchors(a, b)
assert p1 == (0.0, 0.0)
assert p2 == (5.0, 12.0)
def test_center_to_edge(self):
W = self._w()
a = {"kind": "point", "view_id": "v", "point": (4.0, 6.0)}
b = {"kind": "segment", "view_id": "v", "p1": (0.0, 0.0), "p2": (10.0, 0.0)}
p1, p2 = W._distance_anchors(a, b)
assert p1 == (4.0, 6.0)
# Closest point on the edge is straight below the centre.
assert p2 == pytest.approx((4.0, 0.0))
def test_edge_to_edge_unchanged(self):
W = self._w()
a = {"kind": "segment", "view_id": "v", "p1": (0.0, 0.0), "p2": (10.0, 0.0)}
b = {"kind": "segment", "view_id": "v", "p1": (2.0, 5.0), "p2": (8.0, 5.0)}
p1, p2 = W._distance_anchors(a, b)
assert p1 == pytest.approx((2.0, 0.0))
assert p2 == pytest.approx((2.0, 5.0))
# ── View layout: page fill, no overlaps, title-block clearance ───────────
class TestViewLayout:
"""_layout_views_on_sheet packs the views to fill the sheet, keeps
them apart and clear of the title block, and rotates individual
views when that makes the set fit more."""
A3W, A3H = 420.0, 297.0
# Title block box + 5 mm clearance zone (see _title_block_primitives).
TB = (235.0, 0.0, 420.0, 62.0)
@staticmethod
def _bbox(w, h):
return (0.0, 0.0, float(w), float(h))
def _layout(self, kinds, boxes):
views = [DrawingView(kind=k) for k in kinds]
return _layout_views_on_sheet(views, {k: boxes[k] for k in kinds})
def _assert_valid(self, slots):
for k, s in slots.items():
assert s[0] >= 10.0 - 1e-6 and s[1] >= 10.0 - 1e-6, (k, s)
assert s[0] + s[2] <= self.A3W - 10.0 + 1e-6, (k, s)
assert s[1] + s[3] <= self.A3H - 10.0 + 1e-6, (k, s)
x0, y0, w, h = s
x1, y1, w2, h2 = self.TB
assert x0 + w <= x1 or x1 + w2 <= x0 or y0 + h <= y1 or y1 + h2 <= y0, \
f"{k} intrudes title block: {s}"
ks = list(slots)
for i in range(len(ks)):
for j in range(i + 1, len(ks)):
a, b_ = slots[ks[i]], slots[ks[j]]
sep = (
a[0] + a[2] <= b_[0] + 0.1 or b_[0] + b_[2] <= a[0] + 0.1
or a[1] + a[3] <= b_[1] + 0.1 or b_[1] + b_[3] <= a[1] + 0.1
)
assert sep, f"{ks[i]} overlaps {ks[j]}: {a} / {b_}"
@staticmethod
def _fill(slots):
x0 = min(s[0] for s in slots.values())
y0 = min(s[1] for s in slots.values())
x1 = max(s[0] + s[2] for s in slots.values())
y1 = max(s[1] + s[3] for s in slots.values())
return (x1 - x0) * (y1 - y0) / (420.0 * 297.0)
def test_single_view_fills_page(self):
slots, scale, rots = self._layout(["front"], {"front": self._bbox(10, 20)})
self._assert_valid(slots)
# 12 views stay upright — no sideways single view.
assert rots == {"front": 0.0}
# The tall 10 × 20 view is scaled until it touches the full-height
# left strip's reduced height (0.8 × 277 mm).
assert scale == pytest.approx(221.6 / 20.0)
assert slots["front"][3] == pytest.approx(221.6)
def test_two_views_share_page(self):
slots, _scale, _rots = self._layout(
["front", "top"],
{"front": self._bbox(40, 40), "top": self._bbox(40, 20)},
)
self._assert_valid(slots)
assert self._fill(slots) > 0.30
def test_classic_three_view_keeps_cross(self):
boxes = {k: self._bbox(40, 40) for k in ("front", "top", "right")}
slots, _scale, rots = self._layout(list(boxes), boxes)
self._assert_valid(slots)
assert set(rots.values()) == {0.0}, "classic cross must not rotate"
# Top sits directly above front; right directly to its right.
assert abs(slots["top"][0] - slots["front"][0]) < 1e-6
assert slots["top"][1] > slots["front"][1] + slots["front"][3]
assert slots["right"][0] > slots["front"][0] + slots["front"][2]
assert abs(slots["right"][1] - slots["front"][1]) < 1e-6
def test_thin_part_gets_rotated_views(self):
boxes = {
"front": self._bbox(200, 30),
"top": self._bbox(30, 50),
"right": self._bbox(50, 30),
"left": self._bbox(50, 30),
"back": self._bbox(200, 30),
"bottom": self._bbox(30, 50),
}
slots, scale, rots = self._layout(list(boxes), boxes)
self._assert_valid(slots)
assert any(r == 90.0 for r in rots.values()), "rotation must kick in"
# Each slot is the (possibly swapped) model size times the scale.
for k, s in slots.items():
w, h = boxes[k][2], boxes[k][3]
sw, sh = s[2] / scale, s[3] / scale
assert (
(sw == pytest.approx(w) and sh == pytest.approx(h))
or (sw == pytest.approx(h) and sh == pytest.approx(w))
), (k, s, w, h)
def test_all_views_plus_isometric_fill_page(self):
boxes = {
"front": self._bbox(80, 40),
"top": self._bbox(80, 30),
"right": self._bbox(30, 40),
"left": self._bbox(30, 40),
"back": self._bbox(80, 40),
"bottom": self._bbox(80, 30),
"isometric": self._bbox(60, 60),
}
slots, _scale, _rots = self._layout(list(boxes), boxes)
self._assert_valid(slots)
assert self._fill(slots) > 0.55
# ── Project drawing persistence ────────────────────────────────────────────
def _drawing_with_manual_dim():
drawing = TechnicalDrawing(
source_kind="component",
source_id="comp-42",
views=[DrawingView(kind="front"), DrawingView(kind="top")],
auto_dimensions=True,
title="Persisted Drawing",
revision="B",
)
drawing.annotations.append(
_manual_annotation(
"length", "front", ((0.0, 0.0), (0.0, 12.5)), direction=(0.0, 1.0)
)
)
drawing.annotations.append(
_manual_annotation("diameter", "top", ((-5.0, 0.0), (5.0, 0.0)))
)
return drawing
class TestDrawingPersistence:
def test_drawing_dict_roundtrip(self):
drawing = _drawing_with_manual_dim()
data = _technical_drawing_to_dict(drawing)
restored = _technical_drawing_from_dict(json.loads(json.dumps(data)))
assert restored.id == drawing.id
assert restored.source_kind == "component"
assert restored.source_id == "comp-42"
assert restored.auto_dimensions is True
assert [v.kind for v in restored.views] == ["front", "top"]
assert len(restored.annotations) == 2
a = restored.annotations[0]
assert a.dimension_kind == "length"
assert a.view_id == "front"
assert a.anchors == [(0.0, 0.0), (0.0, 12.5)]
assert a.direction == (0.0, 1.0)
b = restored.annotations[1]
assert b.dimension_kind == "diameter"
assert b.anchors == [(-5.0, 0.0), (5.0, 0.0)]
def test_project_drawings_lookup(self):
project = Project()
drawing = _drawing_with_manual_dim()
project.add_drawing(drawing)
assert project.get_drawing_for("component", "comp-42") is drawing
assert project.get_drawing_for("assembly", "comp-42") is None
assert project.get_drawing_for("component", "other") is None
def test_project_save_load_roundtrip(self, tmp_path):
project = Project(name="Drawing Project")
drawing = _drawing_with_manual_dim()
project.add_drawing(drawing)
path = save_project(project, str(tmp_path / "proj.fluency"))
loaded, _view_state = load_project(path)
assert len(loaded.drawings) == 1
restored = loaded.drawings[0]
assert restored.source_id == "comp-42"
assert restored.auto_dimensions is True
assert restored.title == "Persisted Drawing"
assert len(restored.annotations) == 2
a = restored.annotations[0]
assert a.dimension_kind == "length"
assert a.view_id == "front"
assert a.anchors == [(0.0, 0.0), (0.0, 12.5)]
assert a.direction == (0.0, 1.0)
assert a.id == drawing.annotations[0].id
# The restored drawing must still build the same candidates.
cands, resolved, unresolved = build_manual_candidates(
restored, {"front": (1.0, 0.0, 0.0), "top": (1.0, 0.0, 0.0)}
)
assert unresolved == []
labels = sorted(c.label for c in cands)
assert labels == ["12.50", "Ø10.00"]
def test_load_ignores_corrupt_drawing_entry(self, tmp_path):
import zipfile
project = Project(name="Mixed")
project.add_drawing(_drawing_with_manual_dim())
path = save_project(project, str(tmp_path / "proj.fluency"))
with zipfile.ZipFile(path, "r") as zf:
names = zf.namelist()
contents = {n: zf.read(n) for n in names}
manifest = json.loads(contents["project.json"])
manifest["drawings"].append({"id": "broken", "views": "not-a-list"})
contents["project.json"] = json.dumps(manifest).encode("utf-8")
with zipfile.ZipFile(path, "w") as zf:
for name in names:
zf.writestr(name, contents[name])
loaded, _ = load_project(path)
# Corrupt entry skipped, valid one kept.
assert len(loaded.drawings) == 1
assert loaded.drawings[0].source_id == "comp-42"
def test_auto_dimensions_default_off(self):
assert TechnicalDrawing().auto_dimensions is False
# ── Pick geometry helpers ──────────────────────────────────────────────────
class TestPickGeometry:
def test_point_to_segment_inside(self):
q, d = _point_to_segment((5.0, 3.0), (0.0, 0.0), (10.0, 0.0))
assert q == pytest.approx((5.0, 0.0))
assert d == pytest.approx(3.0)
def test_point_to_segment_clamps_at_endpoint(self):
q, d = _point_to_segment((-2.0, 1.0), (0.0, 0.0), (10.0, 0.0))
assert q == pytest.approx((0.0, 0.0))
assert d == pytest.approx(math.hypot(2.0, 1.0))
def test_crossing_segments(self):
q1, q2, d = _closest_points_on_segments(
(0.0, 0.0), (10.0, 0.0), (4.0, -2.0), (4.0, 8.0)
)
assert q1 == pytest.approx((4.0, 0.0))
assert q2 == pytest.approx((4.0, 0.0))
assert d == pytest.approx(0.0, abs=1e-9)
def test_parallel_overlapping_segments(self):
# The classic "distance between two parallel edges" pick:
# result must be the true perpendicular distance.
q1, q2, d = _closest_points_on_segments(
(0.0, 0.0), (10.0, 0.0), (2.0, 5.0), (8.0, 5.0)
)
assert d == pytest.approx(5.0)
assert q1[1] == pytest.approx(0.0)
assert q2[1] == pytest.approx(5.0)
assert q1[0] == pytest.approx(q2[0])
def test_parallel_disjoint_segments(self):
q1, q2, d = _closest_points_on_segments(
(0.0, 0.0), (2.0, 0.0), (5.0, 3.0), (7.0, 3.0)
)
assert d == pytest.approx(math.hypot(3.0, 3.0))
def test_line_intersection(self):
pt = _line_intersection(
(0.0, 0.0), (10.0, 0.0), (4.0, -2.0), (4.0, 8.0)
)
assert pt == pytest.approx((4.0, 0.0))
def test_line_intersection_parallel_is_none(self):
assert _line_intersection(
(0.0, 0.0), (10.0, 0.0), (2.0, 5.0), (8.0, 5.0)
) is None
def test_closest_point_on_segment(self):
q = _closest_point_on_segment((4.0, 9.0), (0.0, 0.0), (10.0, 0.0))
assert q == pytest.approx((4.0, 0.0))
# ── Widget: dimension tool plumbing (offscreen) ────────────────────────────
class TestDrawingWidgetTools:
def _widget(self, qapp):
from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
return TechnicalDrawingWidget()
def test_widget_starts_without_pick_mode(self, qapp):
w = self._widget(qapp)
assert w._canvas._pick_mode == ""
assert not w._auto_dim_check.isChecked()
assert not any(b.isChecked() for b in w._tool_buttons.values())
def test_tool_toggle_enters_pick_mode(self, qapp):
w = self._widget(qapp)
btn = w._tool_buttons["distance"]
btn.setChecked(True)
assert w._canvas._pick_mode == "distance"
assert "Distance" in w._status_label.text()
assert "edge" in w._status_label.text()
# Switching tools re-targets the canvas and unchecks the old tool.
w._tool_buttons["diameter"].setChecked(True)
assert btn.isChecked() is False
assert w._canvas._pick_mode == "diameter"
# Escape path: cancels the tool, clears all buttons and mode.
w._cancel_pick()
assert w._canvas._pick_mode == ""
for other in w._tool_buttons.values():
assert not other.isChecked()
def test_add_manual_dimension_appends_and_emits(self, qapp):
w = self._widget(qapp)
w.set_drawing(
TechnicalDrawing(source_kind="component", source_id="c1")
)
changes = []
w.drawing_changed.connect(lambda: changes.append(1))
w._add_manual_dimension(
"length",
anchors=((0.0, 0.0), (10.0, 0.0)),
view_id="front",
direction=(1.0, 0.0),
)
anns = w._drawing.annotations
assert len(anns) == 1
assert anns[0].dimension_kind == "length"
assert anns[0].view_id == "front"
assert anns[0].anchors == [(0.0, 0.0), (10.0, 0.0)]
assert changes == [1]
def test_clear_removes_manual_dimensions_only(self, qapp):
w = self._widget(qapp)
drawing = TechnicalDrawing(source_kind="component", source_id="c1")
drawing.annotations.append(
DrawingAnnotation(kind="note", text="keep me")
)
drawing.annotations.append(
_manual_annotation(
"length", "front", ((0.0, 0.0), (5.0, 0.0))
)
)
w.set_drawing(drawing)
w._on_clear_clicked()
assert len(drawing.annotations) == 1
assert drawing.annotations[0].kind == "note"
def test_adopt_stored_project_drawing(self, qapp, kernel):
w = self._widget(qapp)
project = Project()
comp = Component(name="ExistingComp")
project.components[comp.id] = comp
stored = TechnicalDrawing(
source_kind="component", source_id=comp.id
)
stored.annotations.append(
_manual_annotation(
"diameter", "front", ((-4.0, 0.0), (4.0, 0.0))
)
)
project.add_drawing(stored)
w.set_project(project, kernel)
w.set_active_component(comp)
# The stored drawing is re-adopted (not replaced).
assert w._drawing is stored
assert len(w._drawing.annotations) == 1
def test_new_source_creates_and_registers_drawing(self, qapp, kernel):
w = self._widget(qapp)
project = Project()
comp = Component(name="NewComp")
project.components[comp.id] = comp
w.set_project(project, kernel)
w.set_active_component(comp)
assert w._drawing is not None
assert w._drawing.source_kind == "component"
assert w._drawing.source_id == comp.id
assert len(project.drawings) == 1
assert project.drawings[0] is w._drawing
class TestCircleCenterPick:
"""Clicking a circle's centre mark while a distance tool is active
picks a point feature at the circle centre (model coords + radius).
HLR only projects a true circle for some hole shapes (a plain
cylinder discretises into segments), so the render result is built
directly with one guaranteed circle primitive.
"""
def _canvas_with_circle(self, qapp):
from PySide6.QtCore import QPointF
from fluency.technical_drawing import (
DrawingPrimitive,
DrawingRenderResult,
)
from fluency.ui.technical_drawing_widget import DrawingCanvas
# One r4 circle at model (20, 15), drawn at 10× scale at the
# sheet centre: model (20,15) → sheet (200, 150).
circle = DrawingPrimitive(
kind="circle",
points=(),
style="visible",
center=(200.0, 150.0),
radius=40.0,
view_id="top",
)
result = DrawingRenderResult(
primitives=(circle,),
candidates=(),
resolved_annotation_ids=(),
unresolved_annotation_ids=(),
source_fingerprint="",
warnings=(),
view_transforms={"top": (10.0, 0.0, 0.0)},
)
canvas = DrawingCanvas()
canvas.resize(840, 600)
canvas.set_render_result(result)
canvas.set_pick_mode("distance")
return canvas, QPointF
def test_pick_center_mark_returns_point(self, qapp):
canvas, QPointF = self._canvas_with_circle(qapp)
# Sheet → device position of the circle centre.
rect = canvas._sheet_rect()
scale = rect.width() / 420.0
pos = QPointF(rect.x() + 200.0 * scale, rect.y() + (297.0 - 150.0) * scale)
hit = canvas._pick_feature(pos)
assert hit is not None, "clicking the centre mark must hit something"
assert hit["kind"] == "point"
assert hit["view_id"] == "top"
assert hit["radius"] == pytest.approx(4.0, abs=1e-6)
# The picked model point is the inverse-transformed sheet centre:
# (200, 150) at 10× scale → (20, 15).
assert hit["point"] == pytest.approx((20.0, 15.0), abs=1e-9)
def test_distance_tool_accepts_center_then_edge(self, qapp, kernel):
from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
w = TechnicalDrawingWidget()
project, comp = _cylinder_project(kernel)
w.set_project(project, kernel)
w.set_active_component(comp)
assert w._drawing is not None
# First pick: a circle centre at model (10, 12) — the dict a
# centre-mark click produces (see test above).
w._first_pick = {
"kind": "point",
"view_id": "top",
"point": (10.0, 12.0),
"radius": 4.0,
}
# Second pick: a horizontal edge 6 mm above the centre.
second = {
"kind": "segment",
"view_id": "top",
"p1": (0.0, 18.0),
"p2": (20.0, 18.0),
}
w._on_edge_pick(second, "distance")
assert w._drawing.annotations, "a manual dimension must be appended"
ann = w._drawing.annotations[-1]
assert ann.dimension_kind == "length"
assert ann.view_id == "top"
# First anchor is the picked centre; the second is the closest
# point on the edge, straight above it.
assert ann.anchors[0] == pytest.approx((10.0, 12.0), abs=1e-9)
assert ann.anchors[1] == pytest.approx((10.0, 18.0), abs=1e-6)
Generated
-1580
View File
File diff suppressed because it is too large Load Diff