Compare commits

...

44 Commits

Author SHA1 Message Date
bherbruck 4927214976 Release 0.18.2
The tree is main at 57bcc4083 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 22:09:18 -04:00
bherbruck 57bcc40838 Release 0.18.2 (#3970)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:08:36 -04:00
bherbruck 9974413a5b Ribbon: Finish Sketch follows the last sketch panel (#3958)
* Ribbon: Finish Sketch follows the last sketch panel

The Sketch tab's exit group sat alone at the far right of the ribbon,
about 600 px from the sketch tools on a 1600 px window. It now comes right
after the last panel, as before the redesign. Its room is still kept at
every width, and when panels go into the » menu it moves to the right end
after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ui_layout test: Finish follows the last sketch panel, right end only with the » menu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:08:14 -04:00
bherbruck 818af2b856 Icon audit: regenerate the requested-icons manifest (#3969)
The menus no longer ask for offset_one_side, offset_symmetric,
offset_two_sides, pattern_center or pattern_flip, so the manifest check
failed on main and on every UI pull request.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:08:10 -04:00
bherbruck 80238a7cfb Sketch: a quick double Esc finishes the sketch (#3950)
* Sketch: a quick double Esc finishes the sketch

One Esc still puts the tool down, then clears the selection, and with
nothing left to cancel keeps the sketch open (#1804). A second press within
0.4 s finishes the sketch, keeping it, the same as Finish Sketch, also when
the first press cancelled a tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sketch: a double Esc takes away a blank sketch; only Esc twice in a row counts

A quick double Esc on a sketch the user added nothing to removes it in one
undo step, the view and tab going back as on Finish, so no empty sketch is
left behind. A sketch with anything in it is still finished and kept.

The double press counts only for two Esc presses in a row with no tool or
dialog left after the first: any other key in between resets it, and while
a tool is still out (its first Esc only ended a chain) the press puts it
down as before. Tests that press Esc on its own now wait past the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:57:32 -04:00
bherbruck bd65a561c8 Ribbon: New Sketch on the Surface tab (#3964)
Sketches drive surface extrudes, revolves, sweeps and lofts, so the Surface
tab gets the same Sketch panel as Model and Sheet Metal. A sketch started
there keeps the tab until it opens.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:57:10 -04:00
phanoob bdd2fb1a5c Fillet and chamfer previews keep moving while a radius or distance changes (#3150)
Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:44:38 +00:00
JZiegener e64f1e0427 Rejoin the split Files table in ATTRIBUTION.md (#3143)
A blank line left after the oracle rows ended the table, so GitHub
rendered the 339 rows after it as one paragraph of pipe-separated text.
2026-10-21 00:44:21 +00:00
CamWight ac362945cc English (UK) as a language: a plain catalogue of the 25 messages UK English spells differently (#2501) (#2502)
* claim: #2501

* English (UK) as a language: a plain catalogue of the 25 messages UK English spells differently (#2501)

assets/locales/en-GB.json holds only the English messages whose spelling differs in UK English (Centre, Customise, Organisation, Licence, colours, towards, ...), in UK spelling; every other message falls back to English. One registration in i18n.rs (English first, then by id), a test that shows UK spelling and English for the rest, a row in ATTRIBUTION.md, and the docs/localization.md note. The translation-coverage checks skip a regional English catalogue, which has no gaps to report.

Fixes #2501

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:44:19 +00:00
JEREMY 382680d8e8 fix(skills): refresh Boolean checking facts that fail on current release (#1835) (#2093)
* fix(skills): refresh Boolean checking facts that fail on current release (#1835)

* fix(skills): restore warning coverage in test_run and use present tense in Boolean facts

* fix(skills): the slot keeps its side at zero since the solver takes the shortest step

The upstream solver now takes the shortest step that fits (#2060), so the slot
that other constraints also held slides to the origin when the distance reaches
0 and keeps the side it was drawn on when the value comes back. It no longer
comes back mirrored, so the trap and its test state the current behaviour.

---------

Co-authored-by: jeremymiribung-blip <jeremymiribung-blip@users.noreply.github.com>
2026-10-21 00:41:45 +00:00
Yeonghun Lee 316b49cd84 Extrude from a whole face rounded by fillets: join, and cuts whose walls only touch (#2040) (#2041)
* Extrude: a join from a whole face rounded by fillets sweeps the face itself (#2040)

A face's sketch fits an edge that is no line or arc (the quarter-ellipses
where fillets meet a slanted face) as splines a hair off the body's edges,
so a join from the face touched the body along a face it did not match and
failed with "boolean Union: no result" after minutes. When the profile is
a whole planar face of the one target, the join now sweeps the body's own
face, which shares its edges and is sewn on along it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Extrude: a cut into a whole face keeps the walls that cut and moves the others off (#2040)

A cut from a whole planar face sweeps walls from each of its edges. Where
the face beside an edge turns away from the sweep (n_N . -n_P >= 0) the wall
only touches the body along that edge, or lies in the face beside it, and
the boolean failed on it with "no result". The cut now keeps the walls that
go into the body, exactly (the body's own edges), runs the outline round
the others well outside the body, and starts a little out from the face.
A cut whose walls all only touch takes a slab beyond its depth plane.

Not covered: a cutting wall tangent to the face beside a touching one (a
round's wall meeting an end face, as in the issue's part); the boolean
still fails there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Extrude: leave a whole face outlined by lines and arcs to the profile's own tool

Only a face whose outline the projection had to fit needs the body's own face as the tool. A square drawn over a box's top already meets the body exactly, and the swept face lost the names a sketch on the new top attaches to (stacked_face_tests, #489).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the whole cut tool from a face under the kernel guard (#2040)

Review: `builder::vertex`, `builder::line`, `Face::try_new` and
`builder::translated` ran outside `crate::guard`; only the sweep was
guarded. `cut_tool` now runs all of it under one guard.

Adds an end-to-end test of a cut into a face the body stands out past
below it (the block's top strip between its rounds): it takes only
what lies over the face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: the slab cut of whole_face_tool, on a spline prism (#2040)

A prism on a closed spline standing on a wider plate, cut 3 into its whole
top face: the slab takes the face's area times the depth and leaves the
plate alone. A test-only counter asserts the slab was the tool, so the test
cannot pass through the profile's own tool. Shortens the name of the
rounded block's top-face cut test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Kernel: the face's outline as a Vec now that polyline returns a closed chain (#2040)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:41:41 +00:00
phanoob b0932a487e Document tabs: a right-click menu to close a tab, the others or all (#3055) (#3152)
* claim: #3055

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document tabs: a right-click menu closes the tab, the others or all of them (#3055)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:39:45 +00:00
rlartot edad8078ba Ribbon tooltips: a short written help for the main tools (#2758) (#3089)
* claim: #2758

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2qZQXd3qSwu9mzaYcB9yW

* Ribbon tooltips: a short written help for six tools (#2758)

Extrude, Revolve, Fillet, Chamfer, Shell and Hole get two sentences under the
tool name in the ribbon tooltip (en, ja, zh). Text only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2qZQXd3qSwu9mzaYcB9yW

* fr: aide des infobulles (6 outils) (#2758)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Ribbon help: also in the panel drop-downs (#2758)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:39:43 +00:00
Luke Jones 26c0c19ef7 Preserve imported part names and IGES assembly structure (#1609)
Build on #1426's import components and nested STEP assembly instances.
Replace placeholder solid labels with meaningful part or instance names.
Translate IGES groups and subfigure instances through the existing STEP
component path, preserving nesting and placements and sewing surfaces
within each group. Bound assembly walks and reuse work buffers.

Related to:
- tangency-labs/solidcraft#1386
- tangency-labs/solidcraft#1426

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:39:08 +00:00
raykholo 2f43ad5c5b Loft: a circle lofted to a non-star-shaped profile is split once round (#2570) (#3670)
* claim: #2570

* Loft: a circle lofted to a non-star-shaped profile is split once round (#2570)

A circle section was split at the lead section's vertex directions, kept in
the lead's order and forced to increase by whole turns. A non-star-shaped
lead (a C, a comb) turns back on itself, so the circle wound round many
times and closed with a negative arc: self-crossing walls and a huge mesh
(which froze the web build while meshing it).

The circle is now split exactly once round: at the lead's directions when
they go round once in order (clockwise on a plane facing the other way),
otherwise at the lead's perimeter fractions, evenly as the fallback. Every
arc is finite and positive and they total one turn; the unbounded loop is
gone. Section preparation is split out as loft_section_loops so the
resampled sections can be checked.
2026-10-21 00:30:58 +00:00
rlartot 6a8604c55d Fix lofts through sections turning past 90 degrees (#2847) (#3452)
* claim: #2847

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* loft: keep sections turned past 90 degrees facing the same way (#2847)

The loft oriented each section by the sign of its normal against the first section's, so on planes turning about an axis every section beyond 90 degrees was reversed and the side faces folded. Follow the facing neighbour to neighbour instead.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:15:41 +00:00
bherbruck b621819c0c Release 0.18.1
The tree is main at 522ae7755 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 17:39:11 -04:00
bherbruck ec552a5300 Release 0.18.0
The tree is main at c3151fb7f exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 16:28:59 -04:00
bherbruck e0cc740cc3 Release 0.17.0
The tree is main at 511faedcf exactly; the 0.16.x release commits are kept as a parent so release only moves forward.
2026-10-20 11:41:48 -04:00
bherbruck 5de48e916d Release 0.16.2 2026-10-19 00:39:30 +00:00
bherbruck 80af7bbe3e Release 0.16.2
Version 0.16.2: 0.16.1 with the rigid orbit fix (#2753). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:38:53 +00:00
bherbruck 191e983423 Fix orbit about the picked point (regression in 0.16.0) (#2753)
Orbiting about the point under the cursor (or the origin) turned the camera
about its target and then panned to put the pivot back. The pan works at the
target's depth with the parallel-view scale, so in a perspective view the
pivot slid off its pixel and the model swung about the camera.

The camera now turns rigidly about the pivot: Camera::orbit_about keeps the
pivot's coordinates in the camera's frame and rebuilds the target from the
new orientation, so the pivot stays exactly on its pixel in both projections.
The Shift snap spring and view.navigate's pivot orbit use the same.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit e88779218)
2026-10-19 00:38:52 +00:00
bherbruck 2b9445b049 Release 0.16.1 2026-10-18 20:16:21 +00:00
bherbruck 7ddf48ca16 Release 0.16.1
Version 0.16.1: 0.16.0 with the orbit fix (reverts #2142). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:04 +00:00
bherbruck d2f6848377 Revert #2142: orbit about the picked point like 0.15.0
#2142 made orbiting about the point under the cursor turn the camera in place instead of turning the part about that point. Restore the 0.15.0 behaviour.

This reverts commit cac4bccea8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:00 +00:00
bherbruck 0b6d6fecd8 Release 0.16.0 2026-10-18 18:16:11 +00:00
bherbruck fa691707c2 Release 0.15.0 2026-10-17 13:54:24 +00:00
bherbruck b9a55c74e2 Release 0.15.0
Version 0.15.0; the pending design-format changes are folded into format 26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:53:44 +00:00
bherbruck 2bb9c81069 Release 0.14.0 2026-10-16 09:19:14 +00:00
bherbruck cedb51b8ea Release 0.14.0
Version 0.14.0; the pending design-format changes are folded into format 25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:19:10 +00:00
bherbruck 66d13059dd Release 0.13.1 2026-10-15 21:56:43 +00:00
bherbruck 800ea40c0d Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:56:35 +00:00
bherbruck 20442eecd3 Agent chat on Windows: start npm agents through their .cmd shim
npm puts a Unix shell script with the bare name (codex-acp,
claude-code-acp, gemini) beside the .cmd shim. The lookup tried the bare
name first, so every npm-installed agent failed with "not a valid Win32
application (os error 193)". Windows now only considers .exe/.cmd/.bat,
and a command saved with the bare path runs the shim beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 60ea8c69d2)
2026-10-15 21:51:01 +00:00
bherbruck 22d1cd7032 Release 0.13.0 2026-10-15 20:34:13 +00:00
bherbruck 6a0dc18655 Fix the Windows 32-bit and macOS release builds
On 32-bit Windows SetWindowLongPtrW is SetWindowLongW, which takes and
returns i32, so the window-procedure hook didn't compile for i686.
The macOS app bundle now signs the bundled solidcraft-cli before the
bundle itself; codesign refuses a bundle with an unsigned helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 94e76cd97e)
2026-10-15 20:34:12 +00:00
bherbruck e006dbf1af Release 0.13.0
The tree is the 0.13.0 version commit (8241a750fa) on main exactly; the previous release is kept as a parent so release only moves forward.
2026-10-15 19:59:50 +00:00
bherbruck 8241a750fa Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:59:42 +00:00
bherbruck 41779cf7bb Release 0.12.0: release branch follows main again after the 0.11.1 patch
The tree is main at c36a6f9f exactly; the 0.11.1 patch commit is kept as a parent so release only moves forward.
2026-10-15 13:32:01 +00:00
bherbruck b01b9689c8 Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:41:41 +00:00
bherbruck 4426da6f5d Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU (#1190)
* Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU

wgpu started Vulkan, D3D12 and OpenGL together and preferred Vulkan, so
every Windows start loaded all installed Vulkan drivers. An old Intel
Vulkan driver (igvk64.dll) crashed 0.10.1 at startup inside the driver.

The app now probes D3D12, then Vulkan, then OpenGL, one backend at a
time, and starts only the first with a hardware adapter (WARP when none
has one). WGPU_BACKEND still wins; graphics.json's "backend" sets the
order by hand; the software renderer (safe mode, the retry) uses D3D12
alone. A new Windows CI job starts the real app under several settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: a test filter YAML parses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: the startup smoke test writes graphics.json where the app reads it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: run the WARP shader tests one at a time

WARP's shader JIT (d3d10warp.dll) crashed twice in 124 runs of the gpu
tests with four test threads, with a null read inside its own optimizer
while several devices compiled compute pipelines at once. Serial runs
did not crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 95ea05e7c8)
2026-10-14 20:41:37 +00:00
bherbruck 5ead82e627 Release 0.11.0: release branch follows main again after the 0.10.1 patch
The tree is main at cccbe125 exactly; c29b0d2f (0.10.1) is kept as a parent so release only moves forward.
2026-10-14 20:07:41 +00:00
bherbruck c29b0d2f13 Release 0.10.1 2026-10-14 14:41:19 +00:00
bherbruck 480fd8d517 Fix the startup crash on D3D12 with FXC: CPU fallback for the tessellation pipeline, FXC-friendly shader (#1098) (#1099)
* GPU tessellation: a pipeline the device cannot build falls back to CPU meshing instead of crashing (#1098)

Build the tessellation compute pipeline inside error scopes; TessGpu::new returns an error,
and the viewport logs it and meshes bodies on the CPU. A Windows CI job builds the pipeline
with D3D12's FXC on the WARP adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tessellation shader: basis functions in one-dimensional local arrays, so D3D12's FXC compiles it (#1098)

FXC has to unroll loops that write an array inside a struct (Basis.n0/n1) or a 2-D array
(ndu) by a counter, and cannot with run-time trip counts (X3511). A2.2 basis functions in a
local 1-D array returned whole; derivatives from the degree p-1 functions; the pole step
offsets computed rather than indexed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:41:16 +00:00
bherbruck 4c09fa5355 Hovered and selected faces light solidly again over GPU-drawn bodies (#1064)
Since the model's mesh went back to the reference mesher (#940), the GPU viewport drew
opaque bodies from their display plans while hover and selection lit faces with the
reference mesh's triangles. Two triangulations of the same face at equal depth fought, so
a hovered face shimmered in wedges instead of lighting whole.

Faces lit over a body drawn from its plan now use the plan's own triangles
(ModelBody::plan_mesh, evaluated once on the CPU), and the highlight pass gets a small
depth bias toward the eye so f32 rounding between the GPU and CPU evaluations of the same
triangles cannot speckle it either.

Test: every face of a box with a pocket and a round boss, hovered and rendered offscreen
through the viewport's real pipelines on the GPU path, lights the same pixels as on the
CPU path (skipped without a GPU adapter).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:56:47 +00:00
49 changed files with 2149 additions and 223 deletions
+1 -1
View File
@@ -94,7 +94,6 @@ Editable drawing code, native projects and shipped exports are collected under `
| tests/oracle/61-shaft-keyway/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/61-shaft-keyway/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/ibm-plex-mono/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
@@ -119,6 +118,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/locales/cs.json | potuzai | Czech translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/da.json | SolidCraft contributors | Original Danish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/en-GB.json | SolidCraft contributors | English (UK): the English catalog's messages whose spelling differs, in UK spelling | MIT OR Apache-2.0 |
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
Generated
+17 -17
View File
@@ -4162,7 +4162,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4193,11 +4193,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.18.1"
version = "0.18.2"
[[package]]
name = "solidcraft-cli"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"image",
"serde_json",
@@ -4208,7 +4208,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"proc-macro2",
"quote",
@@ -4217,7 +4217,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"log",
"serde",
@@ -4234,7 +4234,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"log",
"schemars",
@@ -4253,7 +4253,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"serde",
"serde_json",
@@ -4262,7 +4262,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"log",
"naga",
@@ -4279,7 +4279,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4302,7 +4302,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"log",
"proptest",
@@ -4322,7 +4322,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4331,7 +4331,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"image",
"moxcms",
@@ -4345,7 +4345,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"serde",
"serde_json",
@@ -4356,14 +4356,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"arboard",
"egui",
@@ -4390,7 +4390,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"eframe",
"egui",
@@ -5920,7 +5920,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.18.1"
version = "0.18.2"
dependencies = [
"serde_json",
"sha2 0.10.9",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.18.1"
version = "0.18.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -141,18 +141,13 @@ new false
normal false
offset false
offset_face false
offset_one_side false
offset_symmetric false
offset_two_sides false
open false
orbit false
origin false
pan false
params false
paste false
pattern_center false
pattern_circ false
pattern_flip false
pattern_path false
pattern_rect false
perforate false
+27
View File
@@ -0,0 +1,27 @@
{
"Center X": "Centre X",
"Center Y": "Centre Y",
"Center of mass": "Centre of mass",
"Customize Toolbar": "Customise Toolbar",
"Mass Center": "Mass Centre",
"Modeled": "Modelled",
"cmd.construct_point_center": "Point At Centre Of Circle/Sphere/Torus",
"cmd.inspect_center_of_mass": "Centre of Mass",
"cmd.sketch_arc_center_point": "Centre Point Arc",
"cmd.sketch_circle_center": "Centre Diameter Circle",
"cmd.sketch_rectangle_center": "Centre Rectangle",
"cmd.sketch_slot_arc_center": "Centre Point Arc Slot",
"cmd.sketch_slot_center_point": "Centre Point Slot",
"cmd.sketch_slot_center_to_center": "Centre to Centre Slot",
"pattern.alignment_hint": "Counts include the original. Centre balances copies; Flip chooses the extra side for even totals.",
"render.opaque_preview_help": "Opaque PBR and simple colours; blended transparency. Refraction, HDRI and final rendering are not available yet.",
"sketch_text.align_center": "Centre",
"ui.center": "Centre",
"ui.centerline": "Centreline",
"ui.customizeToolbar": "Customise Toolbar…",
"ui.design_properties.license": "Licence",
"ui.design_properties.organization": "Organisation",
"ui.space_mouse_step_pull": "Pull the cap towards you, hold it, then let go.",
"ui.zoom_to_cursor_tip": "The wheel zooms towards the point under the cursor (off: towards the view's centre)",
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software centre. SolidCraft doesn't check."
}
+6
View File
@@ -855,6 +855,12 @@
"help.licences": "Licences",
"help.mit": "MIT licence",
"help.notice": "NOTICE",
"help.solid.chamfer": "Cuts the selected edges with a flat bevel of the distance you set. Use it to break sharp edges or to ease the entry of a pin or screw.",
"help.solid.extrude": "Pushes a sketch profile into 3D along a distance, adding or removing material. Use it to turn a flat outline into a block, a boss or a pocket.",
"help.solid.fillet": "Rounds the selected edges with a smooth circular blend of the radius you set. Use it to soften sharp edges for strength, comfort or looks.",
"help.solid.hole": "Drills a round hole of the diameter you set at a chosen point on a face. Use it for screw clearances, pins and other simple round openings.",
"help.solid.revolve": "Spins a sketch profile around an axis to make a round solid. Use it for shafts, knobs, bowls and any part that is round about a centre line.",
"help.solid.shell": "Hollows out a solid, leaving walls of the thickness you set, and can open the faces you pick. Use it for boxes, covers and other thin-walled parts.",
"help.third_party": "Third-party licences",
"home.documentation": "Documentation",
"home.in_this_browser": "in this browser",
+6
View File
@@ -855,6 +855,12 @@
"help.licences": "Licences",
"help.mit": "Licence MIT",
"help.notice": "NOTICE",
"help.solid.chamfer": "Coupe les arêtes sélectionnées par un biseau plat de la distance indiquée. À utiliser pour casser des arêtes vives ou faciliter l'entrée d'une goupille ou d'une vis.",
"help.solid.extrude": "Pousse le profil d'une esquisse en 3D sur une distance, en ajoutant ou en retirant de la matière. À utiliser pour transformer un contour plat en bloc, en bossage ou en poche.",
"help.solid.fillet": "Arrondit les arêtes sélectionnées par un raccord circulaire lisse du rayon indiqué. À utiliser pour adoucir des arêtes vives par souci de solidité, de confort ou d'aspect.",
"help.solid.hole": "Perce un trou rond du diamètre indiqué en un point choisi d'une face. À utiliser pour les passages de vis, les goupilles et autres ouvertures rondes simples.",
"help.solid.revolve": "Fait tourner le profil d'une esquisse autour d'un axe pour obtenir un solide de révolution. À utiliser pour les arbres, boutons, bols et toute pièce ronde autour d'une ligne centrale.",
"help.solid.shell": "Évide un solide en laissant des parois de l'épaisseur indiquée, et peut ouvrir les faces choisies. À utiliser pour les boîtes, couvercles et autres pièces à parois minces.",
"help.third_party": "Licences tierces",
"home.documentation": "Documentation",
"home.in_this_browser": "dans ce navigateur",
+6
View File
@@ -855,6 +855,12 @@
"help.licences": "ライセンス",
"help.mit": "MITライセンス",
"help.notice": "NOTICE",
"help.solid.chamfer": "選択した辺を、指定した距離で平らに斜めに削ります。鋭い角を落とすときや、ピンやねじを入れやすくするときに使います。",
"help.solid.extrude": "スケッチの輪郭を指定した距離だけ立体方向に押し出し、材料を足したり削ったりします。平面の図形をブロック、突起、ポケットにするときに使います。",
"help.solid.fillet": "選択した辺を、指定した半径のなめらかな円弧で丸めます。強度や持ちやすさ、見た目のために鋭い角を和らげるときに使います。",
"help.solid.hole": "面上の指定した位置に、指定した直径の丸い穴をあけます。ねじのクリアランスやピン穴など、単純な丸い開口に使います。",
"help.solid.revolve": "スケッチの輪郭を軸のまわりに回転させ、丸い立体を作ります。軸、つまみ、ボウルなど、中心線まわりに丸い部品に使います。",
"help.solid.shell": "立体の中をくり抜き、指定した厚さの壁を残します。選んだ面を開口にすることもできます。箱、カバーなどの薄肉の部品に使います。",
"help.third_party": "サードパーティライセンス",
"home.documentation": "ドキュメント",
"home.in_this_browser": "このブラウザ内",
+6
View File
@@ -855,6 +855,12 @@
"help.licences": "许可证",
"help.mit": "MIT 许可证",
"help.notice": "NOTICE",
"help.solid.chamfer": "用指定的距离在所选边上切出一个平直的斜面。用于去除锐边,或使销钉、螺钉更容易插入。",
"help.solid.extrude": "将草图轮廓按指定距离拉伸为三维形体,可以增加或去除材料。用于把平面轮廓变成方块、凸台或凹槽。",
"help.solid.fillet": "用指定半径的平滑圆弧将所选的边倒圆。用于出于强度、手感或外观的考虑而柔化锐边。",
"help.solid.hole": "在面上的指定位置钻出指定直径的圆孔。用于螺钉过孔、销孔等简单的圆形开口。",
"help.solid.revolve": "将草图轮廓绕一条轴旋转,生成圆形的实体。用于轴、旋钮、碗等围绕中心线呈圆形的零件。",
"help.solid.shell": "将实体掏空,留下指定厚度的壁,还可以打开所选的面。用于盒子、盖子等薄壁零件。",
"help.third_party": "第三方许可证",
"home.documentation": "文档",
"home.in_this_browser": "在此浏览器中",
+91
View File
@@ -3387,6 +3387,84 @@ struct ExtrudeConstructor {
interval: (f64, f64),
}
/// The tool of an Extrude whose profile is a whole planar face of its one target (the face's
/// outline projected into the sketch), started at the face (#2040). The projection fits an edge
/// that is no line or arc, so the profile's tool lies a hair off the body all round its outline,
/// and the boolean fails there. A join takes the body's own face swept instead, which is sewn on
/// along it. A cut whose walls take nothing from the body (all of it within the depth lies over
/// the face) takes a slab beyond the depth plane instead, which meets the body only across that
/// plane; one whose walls cut into the body at some edges keeps those walls, exactly, and moves
/// the others off it (`kernel::cut_tool`). `None` (the profile's own tool) otherwise. With the
/// interval along the normal it spans.
fn whole_face_tool(
st: &ModelState,
targets: &[String],
ss: &SolvedSketch,
r: &Region2,
operation: Operation,
lo: f64,
hi: f64,
) -> Option<(Traced, (f64, f64))> {
if !matches!(operation, Operation::Join | Operation::Cut) || hi - lo <= 0.0 {
return None;
}
// An outline of lines and arcs is the face's own: the profile's tool already meets the body
// exactly, and keeps the names later features attach to (#489).
if r.outer.segs.iter().all(|s| matches!(s, solidcraft_geom::Seg2::Line { .. } | solidcraft_geom::Seg2::Arc { .. })) {
return None;
}
let mut candidates = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name));
let target = candidates.next()?;
if candidates.next().is_some() {
return None;
}
let face = kernel::matching_face(&target.body, &ss.plane, r, &[lo, hi])?;
let n = ss.plane.normal();
let tol = target.body.size() * 1e-6 + 1e-9;
let up = face.normal.dot(n) > 0.0;
let (at_lo, at_hi) = ((face.height - lo).abs() <= tol, (face.height - hi).abs() <= tol);
let roles = |region| crate::lineage::ProfileRoles {
sketch: Some(ss),
region: Some(region),
caps: [Some(crate::lineage::extrude_cap_role(lo)), Some(crate::lineage::extrude_cap_role(hi))],
};
match operation {
// Out of the body from the face.
Operation::Join if (up && at_lo) || (!up && at_hi) => {
let built = centered_extrude(&ss.plane, r, lo, hi, Some(ss)).ok()?;
let [built] = built.as_slice() else { return None };
let exact = kernel::face_prism(&target.body, face, hi - lo, true, &built.body).ok()?;
kernel::can_join_touching_faces(&target.body, &exact).then(|| (roles(r).tool(exact, Some(&built.body)), (lo, hi)))
}
// Into the body from the face, its walls touching it at most.
Operation::Cut if ((up && at_hi) || (!up && at_lo)) && !kernel::cut_takes_only_the_face(&target.body, face, hi - lo) => {
// Some walls go into the body: those that only touch it are moved off.
let built = centered_extrude(&ss.plane, r, lo, hi, Some(ss)).ok()?;
let [built] = built.as_slice() else { return None };
let (tool, off) = kernel::cut_tool(&target.body, face, hi - lo, Some(&built.body))?;
let span = if up { (lo, hi + off) } else { (lo - off, hi) };
Some((roles(r).tool(tool, Some(&built.body)), span))
}
Operation::Cut if (up && at_hi) || (!up && at_lo) => {
// Over the whole body, from the depth plane out past it.
let reach = target.body.size() * 2.0 + (hi - lo);
let c = r.outer.centroid();
let p = |x: f64, y: f64| Vec2::new(c.x + x * reach, c.y + y * reach);
let line = |a: Vec2, b: Vec2| solidcraft_geom::Seg2::Line { a, b };
let corners = [p(-1.0, -1.0), p(1.0, -1.0), p(1.0, 1.0), p(-1.0, 1.0)];
let segs = (0..4).filter_map(|i| Some(line(*corners.get(i)?, *corners.get((i + 1) % 4)?))).collect();
let slab = Region2 { outer: solidcraft_geom::Loop2 { segs }, holes: vec![] };
let (l, h) = if up { (lo, hi + reach) } else { (lo - reach, hi) };
let built = centered_extrude(&ss.plane, &slab, l, h, None).ok()?;
let [built] = built.as_slice() else { return None };
#[cfg(test)]
whole_face_tests::slab_taken();
Some((roles(&slab).tool(built.body.clone(), None), (l, h)))
}
_ => None,
}
}
/// Construct planar caps locally before placing the completed tool.
fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketch: Option<&SolvedSketch>) -> Result<Vec<Traced>> {
if !plane.origin.is_finite()
@@ -3696,6 +3774,15 @@ fn feature_tools_with_constructor(
None
};
for r in regions.iter().filter(|_| !touching_batch) {
if regions.len() == 1
&& let Some((tool, (l2, h2))) = whole_face_tool(st, &plain_targets, &ss, r, *operation, lo + off, hi + off)
{
untrimmed &= l2 == lo + off && h2 == hi + off;
tools.push(tool);
footprints.push(r.clone());
extents.push((l2, h2));
continue;
}
if *operation == Operation::Join {
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?;
if exact.iter().all(|tool| {
@@ -6314,6 +6401,10 @@ mod chain_tests {
#[path = "eval_cost_tests.rs"]
mod cost_tests;
#[cfg(test)]
#[path = "eval_whole_face_tests.rs"]
mod whole_face_tests;
#[cfg(test)]
mod body_name_tests {
use super::*;
+85
View File
@@ -0,0 +1,85 @@
//! Extrudes from a whole face whose outline is no lines and arcs (#2040), checked through the
//! tool `whole_face_tool` picks.
use std::cell::Cell;
use serde_json::json;
use solidcraft_geom::{Plane, Vec2, Vec3};
use solidcraft_kernel as kernel;
use solidcraft_sketch::{CurveKind, Sketch};
use crate::*;
thread_local! {
static SLABS: Cell<usize> = const { Cell::new(0) };
}
/// A cut took a slab over the whole body as its tool, on this thread.
pub(super) fn slab_taken() {
SLABS.with(|n| n.set(n.get() + 1));
}
/// A closed spline through six points round the origin, on the plane at height `z`.
fn spline_sketch(z: f64) -> FeatureKind {
let mut sk = Sketch::new();
let fit = [(8.0, 0.0), (4.0, 6.0), (-3.0, 7.0), (-7.0, 1.0), (-4.0, -6.0), (3.0, -5.0)];
let mut pts: Vec<usize> = fit.iter().map(|&(x, y)| sk.add_point(Vec2::new(x, y), None).unwrap()).collect();
pts.push(pts[0]);
sk.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true }, None).unwrap();
let plane = Plane { origin: Vec3::new(0.0, 0.0, z), ..Plane::XY };
FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch: sk }
}
fn extrude(doc: &mut Document, sketch: u64, extent: serde_json::Value, operation: Operation) {
let kind = FeatureKind::Extrude {
thin: None,
sketch: SketchId(sketch),
profiles: ProfileSel::All,
extent: serde_json::from_value(extent).unwrap(),
operation,
targets: vec![],
};
doc.add_feature(kind, None).unwrap();
}
fn volume(model: &Model) -> f64 {
let state = model.state();
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some()));
assert_eq!(state.bodies.len(), 1);
assert!(state.bodies[0].body.validity().is_empty(), "{:?}", state.bodies[0].body.validity());
kernel::measure(&state.bodies[0].body).unwrap().volume
}
/// A prism 10 tall on a closed spline, standing on a 40 x 40 x 5 plate, cut 3 into its whole
/// top face. The walls only touch the prism, so the cut takes a slab over the whole body from 3
/// below the face up: it must take the face's area times 3 and leave the plate alone.
#[test]
fn slab_cut_into_a_spline_prism() {
let mut doc = Document::new("spline prism");
let plate = FeatureKind::Box {
corner: point_expr(Vec3::new(-20.0, -20.0, 0.0)),
length: "40 mm".into(),
width: "40 mm".into(),
height: "5 mm".into(),
operation: Operation::NewBody,
};
doc.add_feature(plate, None).unwrap();
let base = doc.add_feature(spline_sketch(5.0), None).unwrap();
extrude(&mut doc, base, json!({"distance": "10 mm"}), Operation::Join);
let mut model = Model::new();
model.evaluate(&doc);
let joined = volume(&model);
let area = (joined - 40.0 * 40.0 * 5.0) / 10.0;
assert!(area > 100.0, "the prism's area {area}");
let top = doc.add_feature(spline_sketch(15.0), None).unwrap();
extrude(&mut doc, top, json!({"distance": "3 mm", "direction": "negative"}), Operation::Cut);
SLABS.with(|n| n.set(0));
model.evaluate(&doc);
let cut = volume(&model);
assert_eq!(SLABS.with(Cell::get), 1, "the cut took the slab");
assert!((joined - cut - area * 3.0).abs() < 1e-3 * area, "took {}, expected {}", joined - cut, area * 3.0);
let bounds = model.state().bodies[0].mesh().bounds();
assert!((bounds.max.z - 12.0).abs() < 1e-6, "top at {}", bounds.max.z);
assert!(bounds.min.dist(Vec3::new(-20.0, -20.0, 0.0)) < 1e-6, "plate from {:?}", bounds.min);
}
@@ -1150,3 +1150,76 @@ fn joined_loft_frames_reference_mesh_covers_each_face_once() {
}
}
}
/// The block of #2040 with `extra` run after it.
fn rounded_block_and(extra: Value) -> Session {
let mut script: Value = serde_json::from_str(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")).unwrap();
script["commands"].as_array_mut().unwrap().push(extra);
replay(&script.to_string())
}
/// #2040: the slanted face of a block whose ends fillets round, extruded as a join. Its outline's
/// quarter-ellipses come into the face's sketch as splines a hair off the body's edges, and the
/// join failed with "boolean Union: no result"; it now sweeps the body's own face. It adds the
/// face's area times its height: a 10 x 12 face less four corners of (1 - pi/4) r * r*sqrt(2).
#[test]
fn extrude_join_from_a_face_rounded_by_fillets() {
let at = json!([16.191269086108846, -6.702792508916929, 4.676550460347439]);
let before = volume(&replay(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")), "Body1");
let r = 3.8_f64;
let area = 10.0 * 12.0 - 4.0 * (1.0 - std::f64::consts::FRAC_PI_4) * r * r * 2f64.sqrt();
let joined = rounded_block_and(json!({"command": "solid.extrude", "params": {"face": at, "distance": 5, "operation": "join"}}));
let bodies = &joined.model.state().bodies;
assert_eq!(bodies.len(), 1, "the join makes no second body");
assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity());
let v = volume(&joined, "Body1");
assert!((v - (before + area * 5.0)).abs() < 0.05, "join {v} expected {}", before + area * 5.0);
}
/// #2040: a cut 3 mm into the slanted face of the block rounded along its top only (its bottom
/// edge left sharp). The walls from the bottom edge go into the body and the others only touch
/// it, along edges with curved faces beside them: the cut failed with "boolean Cut: no result".
/// In the face's plane the body's section at depth t is the block's section (10 wide, h high, its
/// top corners rounded) moved down the face by t: the cut takes
/// √2 ∫₀³ (10 (h − t/√2) − 2 (1 − π/4) r²) dt.
#[test]
fn extrude_cut_into_a_face_beside_rounds() {
let at = json!([16.191269086108846, -6.702792508916929, 4.676550460347439]);
let top_only = |extra: Option<Value>| {
let mut script: Value = serde_json::from_str(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")).unwrap();
let commands = script["commands"].as_array_mut().unwrap();
let fillet = commands.last_mut().unwrap();
fillet["params"]["edges"] = json!([[10, -10, 8.485281374238571], [10, 0, 8.485281374238571]]);
commands.extend(extra);
replay(&script.to_string())
};
let before = volume(&top_only(None), "Body1");
let cut = top_only(Some(json!({"command": "solid.extrude", "params": {"face": at, "distance": 3, "direction": "negative", "operation": "cut"}})));
let bodies = &cut.model.state().bodies;
assert_eq!(bodies.len(), 1);
assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity());
let (h, r, d, w) = (8.485281374238571_f64, 3.8_f64, 3.0_f64, 10.0_f64);
let corner = (1.0 - std::f64::consts::FRAC_PI_4) * r * r;
let taken = 2f64.sqrt() * w * h * d - w * d * d / 2.0 - 2.0 * 2f64.sqrt() * corner * d;
let v = before - volume(&cut, "Body1");
assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}");
}
/// #2040: a cut 2 mm into the top face of the block rounded at its ends, the strip 10 − 2r wide
/// between the rounds. Below the face the rounds stand out past its outline, so a slab over the
/// whole body would take them too: the cut takes only what lies over the face, at height z from
/// x = 0 to the slanted face at x = x0 + z.
#[test]
fn extrude_cut_into_a_rounded_block_top() {
let (h, r, x0, d) = (8.485281374238571_f64, 3.8_f64, 11.514718625761429_f64, 2.0_f64);
let before = volume(&replay(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")), "Body1");
let cut = rounded_block_and(
json!({"command": "solid.extrude", "params": {"face": [5, -5, h], "distance": d, "direction": "negative", "operation": "cut"}}),
);
let bodies = &cut.model.state().bodies;
assert_eq!(bodies.len(), 1);
assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity());
let taken = (10.0 - 2.0 * r) * (x0 * d + (h * h - (h - d) * (h - d)) / 2.0);
let v = before - volume(&cut, "Body1");
assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}");
}
+6 -5
View File
@@ -20,11 +20,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("open")
.key("Ctrl+O")
.noundo()
.params("path: .solidcraft design, or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step)"),
.params("path: .solidcraft design, or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step)"),
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
.at("SOLID", "INSERT")
.icon("import")
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: auto|solid|product|instance (default auto: keep meaningful solid names, replace placeholders with the nearest named product, else instance; solid: the solid's own name; product: the part's name; instance: the occurrence's name, else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
.at("SOLID", "INSERT")
.icon("import")
@@ -89,9 +89,10 @@ fn step_options(p: &Value, cmd: &str) -> Result<StepOptions> {
};
let names = match p.get("names") {
None | Some(Value::Null) => solidcraft_io::StepNames::default(),
Some(v) => {
v.as_str().and_then(solidcraft_io::StepNames::parse).ok_or_else(|| bad(cmd, "`names` must be \"solid\", \"product\" or \"instance\""))?
}
Some(v) => v
.as_str()
.and_then(solidcraft_io::StepNames::parse)
.ok_or_else(|| bad(cmd, "`names` must be \"auto\", \"solid\", \"product\" or \"instance\""))?,
};
Ok(StepOptions { y_up, names })
}
+29 -2
View File
@@ -170,8 +170,8 @@ fn wrong_up_and_names_are_refused() {
assert_eq!(kinds(&s), ["BoxFeature", "BaseFeature"]);
}
/// `names` says what the bodies are called: the solid's own name (the default), the part's, or
/// the assembly occurrence's. Repeated names are numbered as ever.
/// Explicit naming choices select the solid, part or occurrence; automatic naming keeps
/// meaningful solid names.
#[test]
fn the_bodies_are_named_after_the_solid_the_product_or_the_instance() {
let path = frame_file("names");
@@ -185,6 +185,7 @@ fn the_bodies_are_named_after_the_solid_the_product_or_the_instance() {
// The post is placed twice: one component, two occurrences, its body built once (#1386).
let solids = ["Cut-Extrude3", "Boss-Extrude1"];
assert_eq!(named(json!({"path": path})), solids);
assert_eq!(named(json!({"path": path, "names": "auto"})), solids);
assert_eq!(named(json!({"path": path, "names": "solid"})), solids);
assert_eq!(named(json!({"path": path, "names": "product"})), ["Plate", "Post"]);
assert_eq!(named(json!({"path": path, "names": "Instance"})), ["Plate-1", "Post-1"]);
@@ -228,3 +229,29 @@ fn a_step_file_can_be_inserted_at_a_matrix() {
assert!(e.contains("not rigid"), "{e}");
assert!(d.doc.features.is_empty() && d.doc.occurrences.is_empty());
}
/// Generated body labels become part names, and the saved assembly retains its components
/// and repeated placements without needing the source file.
#[test]
fn automatic_part_names_and_assembly_survive_reopening() {
let path = frame_file("automatic");
let text = std::fs::read_to_string(&path).unwrap().replace("'Cut-Extrude3'", "'Body1'").replace("'Boss-Extrude1'", "'Body 2 (3)'");
std::fs::write(&path, text).unwrap();
let mut s = Session::default();
let r = run(&mut s, "file.insert_step", json!({"path": path}));
assert_eq!(body_names(&s), ["Plate", "Post"]);
let top = r["component"].as_u64().unwrap();
let parts: Vec<_> = s.doc.components.iter().filter(|c| c.parent == top).collect();
assert_eq!(parts.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["Plate", "Post"]);
assert_eq!(s.doc.occurrences.iter().filter(|o| o.component == parts[1].id && o.parent == top).count(), 2);
let design = std::path::Path::new(&path).with_file_name("automatic.solidcraft");
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
std::fs::remove_file(&path).unwrap();
let mut again = Session::default();
run(&mut again, "doc.open", json!({"path": design.to_string_lossy()}));
assert_eq!(body_names(&again), ["Plate", "Post"]);
assert_eq!(again.doc.components, s.doc.components);
assert_eq!(again.doc.occurrences, s.doc.occurrences);
assert!(near(extent(&again).0, [0.0, 0.0, 0.0]) && near(extent(&again).1, [40.0, 34.0, 20.0]));
assert!((total_volume(&mut again) - (40.0 * 4.0 * 20.0 + 2.0 * 4.0 * 30.0 * 4.0)).abs() < 1e-6);
}
+31 -12
View File
@@ -101,8 +101,10 @@ pub fn is_iges_path(path: &str) -> bool {
/// nothing), so the caller chooses.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum StepNames {
/// The solid's own name in the file, else the name of the product that holds it.
/// Keep meaningful solid names; replace placeholders with the nearest named product.
#[default]
Auto,
/// The solid's own name in the file, else the name of the product that holds it.
Solid,
/// The name of the product (part) that holds the body, else the solid's name.
Product,
@@ -112,9 +114,10 @@ pub enum StepNames {
}
impl StepNames {
/// `solid`, `product` or `instance` (any case); `None` for anything else.
/// `auto`, `solid`, `product` or `instance` (any case); `None` for anything else.
pub fn parse(s: &str) -> Option<StepNames> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Some(StepNames::Auto),
"solid" => Some(StepNames::Solid),
"product" => Some(StepNames::Product),
"instance" => Some(StepNames::Instance),
@@ -126,7 +129,16 @@ impl StepNames {
/// A name that says nothing about its body: empty, or the placeholder a file or the importer
/// gives to something it has no name for.
fn generic_name(n: &str) -> bool {
n.trim().is_empty() || n.starts_with('(') || n.eq_ignore_ascii_case("import") || n.eq_ignore_ascii_case("unnamed")
let n = n.trim();
n.is_empty()
|| n.starts_with('(')
|| n.eq_ignore_ascii_case("import")
|| n.eq_ignore_ascii_case("unnamed")
|| ["body", "solid", "shell"].iter().any(|prefix| {
n.get(..prefix.len()).is_some_and(|s| s.eq_ignore_ascii_case(prefix))
&& n.get(prefix.len()..)
.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit() || c.is_ascii_whitespace() || matches!(c, '_' | '-' | '(' | ')')))
})
}
/// The name an imported body gets under the `names` choice; the solid's name when the chosen
@@ -134,6 +146,8 @@ fn generic_name(n: &str) -> bool {
fn body_name(b: &solidcraft_kernel::ImportedBody, names: StepNames) -> String {
let usable = |n: &&String| !generic_name(n);
let chosen = match names {
StepNames::Auto if !generic_name(&b.name) => Some(&b.name),
StepNames::Auto => b.path.iter().rev().find(usable).or_else(|| b.instance.as_ref().filter(usable)),
StepNames::Solid => None,
StepNames::Product => b.path.last().filter(usable),
StepNames::Instance => b.instance.as_ref().filter(usable).or(b.path.last().filter(usable)),
@@ -146,8 +160,7 @@ pub struct StepFeature {
/// Timeline name: the root product's name, else the file name.
pub name: String,
pub kind: solidcraft_doc::FeatureKind,
/// Body names from the file, as [`StepNames`] chose them (by default the solid names, else
/// the product names).
/// Body names from the file, as [`StepNames`] chose them (automatic by default).
pub body_names: Vec<String>,
pub warnings: Vec<String>,
/// The file as read. Hold it until the feature has been evaluated: the timeline then reuses
@@ -748,7 +761,7 @@ mod tests {
let step = String::from_utf8(export_with_metadata(&m.state(), &[], Format::Step, "t", &meta).unwrap()).unwrap();
assert!(step.contains("FILE_DESCRIPTION(('Vented Geh\\X2\\00E4\\X0\\use for a Pi'),'2;1');"), "{}", &step[..300]);
assert!(step.contains("FILE_NAME('t.step','',('Jane O''Brien'),('ACME'),"), "{}", &step[..300]);
assert_eq!(step_import_feature(step.as_bytes(), "t.step").unwrap().body_names, ["Body1"], "still reads back");
assert_eq!(step_import_feature(step.as_bytes(), "t.step").unwrap().body_names, ["t"], "still reads back");
let iges = String::from_utf8(export_with_metadata(&m.state(), &[], Format::Iges, "t", &meta).unwrap()).unwrap();
assert!(iges.contains("12HJane O'Brien") && iges.contains("4HACME"));
let model_xml = |bytes: Vec<u8>| {
@@ -812,7 +825,7 @@ mod tests {
let f = step_import_feature(s.as_bytes(), "/tmp/My Part.step").unwrap();
// Named after the exported product (the export was called `t`).
assert_eq!(f.name, "t");
assert_eq!(f.body_names, ["Body1"]);
assert_eq!(f.body_names, ["t"]);
assert_eq!(f.body_names.len(), 1);
assert!(matches!(&f.kind, FeatureKind::Import { file, .. } if file == "My Part.step"));
let mut imported = Document::new("Imported STEP round trip");
@@ -862,9 +875,8 @@ mod tests {
step_export_products(&products, 0, &StepHeader::default()).unwrap()
}
/// The bodies of a STEP file are named after the solid (the default), the product that holds
/// them, or the assembly occurrence; a name the file leaves out or fills with a placeholder
/// falls back to the next one.
/// Automatic names keep meaningful solid labels and replace placeholders with part names.
/// Explicit choices still select the solid, product or occurrence.
#[test]
fn step_import_names_follow_the_choice() {
let text = frame_step();
@@ -872,10 +884,15 @@ mod tests {
assert_eq!(names(&text, StepNames::Solid), ["Cut-Extrude3", "Boss-Extrude1", "Boss-Extrude1"]);
assert_eq!(names(&text, StepNames::Product), ["Plate", "Post", "Post"]);
assert_eq!(names(&text, StepNames::Instance), ["Plate-1", "Post-1", "Post-2"]);
// Without a choice it is the solid's name, as before.
// Meaningful solid names survive automatic naming, including SolidCraft exports.
assert_eq!(step_import_feature(text.as_bytes(), "frame.step").unwrap().body_names, names(&text, StepNames::Solid));
let generic = text.replace("'Cut-Extrude3'", "'Body1'").replace("'Boss-Extrude1'", "'Body 2 (3)'");
assert_eq!(step_import_feature(generic.as_bytes(), "frame.step").unwrap().body_names, ["Plate", "Post", "Post"]);
assert_eq!(names(&generic, StepNames::Solid), ["Body1", "Body 2 (3)", "Body 2 (3)"]);
let named = generic.replace("'Body1'", "'Body housing'");
assert_eq!(names(&named, StepNames::Auto), ["Body housing", "Post", "Post"]);
// The timeline entry is the root product's name whatever the bodies are called.
for choice in [StepNames::Solid, StepNames::Product, StepNames::Instance] {
for choice in [StepNames::Auto, StepNames::Solid, StepNames::Product, StepNames::Instance] {
assert_eq!(step_import_feature_named(text.as_bytes(), "frame.step", choice).unwrap().name, "Frame");
}
// A product the file leaves unnamed says nothing: its bodies keep the solid's name.
@@ -887,6 +904,8 @@ mod tests {
assert_eq!(names(&part, StepNames::Solid), ["Body1"]);
assert_eq!(names(&part, StepNames::Product), ["Bracket"]);
assert_eq!(names(&part, StepNames::Instance), ["Bracket"]);
assert_eq!(names(&part, StepNames::Auto), ["Bracket"]);
assert_eq!(StepNames::parse(" AuTo "), Some(StepNames::Auto));
assert_eq!(StepNames::parse(" Product "), Some(StepNames::Product));
assert_eq!(StepNames::parse("INSTANCE"), Some(StepNames::Instance));
assert_eq!(StepNames::parse("label"), None);
+47 -12
View File
@@ -1986,6 +1986,19 @@ fn loft_reversed(sections: &[(Plane, Loop2)]) -> Result<bool> {
}
fn loft_numbered_wires(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Result<(Vec<mt::Wire>, Vec<Vec<Vec<usize>>>)> {
let mut wires: Vec<mt::Wire> = Vec::new();
// Per section, the profile segments under each of its wire's edges.
let mut edge_sources: Vec<Vec<Vec<usize>>> = Vec::new();
for (p, r) in loft_section_loops(sections, reversed)? {
wires.push(uniform_wire(&p, &r.lp)?);
edge_sources.push(r.from);
}
Ok((wires, edge_sources))
}
/// The sections of a loft as the wires are built from them: in loft order, oriented alike,
/// re-split to the same number of segments and started where they line up best.
pub(crate) fn loft_section_loops(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Result<Vec<(Plane, NumberedLoop)>> {
let n = sections.iter().map(|(_, l)| l.lp.segs.len()).max().unwrap_or(0);
if n == 0 || n > MAX_SEGS {
return Err(KernelError::Invalid("empty section".into()));
@@ -2000,28 +2013,51 @@ fn loft_numbered_wires(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Re
// with the most segments.
let n0 = ordered.first().map(|(p, _)| p.normal()).unwrap_or(Vec3::Z);
let parallel = ordered.iter().all(|(p, _)| p.normal().dot(n0).abs() > 1.0 - 1e-9);
// Whether a section faces the first one's way is followed neighbour to neighbour, not by its
// normal against the first's: planes turning about an axis pass 90 degrees and would be
// flipped, folding the loft (#2847).
let mut facing = true;
let mut before = n0;
let oriented: Vec<(Plane, NumberedLoop)> = ordered
.iter()
.map(|(p, l)| {
let ccw = p.normal().dot(n0) >= 0.0;
(*p, l.oriented(!ccw))
let n = p.normal();
if n.dot(before) < 0.0 {
facing = !facing;
}
before = n;
(*p, l.oriented(!facing))
})
.collect();
let lead = oriented.iter().max_by_key(|(_, l)| l.lp.segs.len()).ok_or_else(|| KernelError::Invalid("no sections".into()))?;
let lead_c = lead.0.to_world(lead.1.lp.centroid());
let lead_dirs: Vec<Vec3> = lead.1.lp.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect();
let mut wires: Vec<mt::Wire> = Vec::new();
// Per section, the profile segments under each of its wire's edges.
let mut edge_sources: Vec<Vec<Vec<usize>>> = Vec::new();
// The share of the lead's perimeter before each of its vertices: where a circle section
// breaks when the lead's vertex directions do not go round in order.
let lead_len: f64 = lead.1.lp.segs.iter().map(Seg2::length).sum();
let fractions: Vec<f64> = lead
.1
.lp
.segs
.iter()
.scan(0.0, |before, s| {
let f = *before / lead_len;
*before += s.length();
Some(f)
})
.collect();
let mut out: Vec<(Plane, NumberedLoop)> = Vec::new();
for (p, l) in &oriented {
let c = l.lp.centroid();
// Break angles: the lead section's vertex directions seen in this plane.
let angles: Vec<f64> = lead_dirs.iter().map(|d| Vec2::new(d.dot(p.x), d.dot(p.y)).angle()).collect();
let mut r = l.resample(n, &angles);
// Circle resampling creates increasing local angles. An antiparallel sketch
// plane must run clockwise locally to keep the same winding in world space.
// Curving a run through a right angle does not reverse a section's winding.
if parallel && p.normal().dot(n0) < 0.0 {
// plane must run clockwise locally to keep the same winding in world space (the lead's
// directions go round clockwise there). Curving a run through a right angle does not
// reverse a section's winding.
let flip = parallel && p.normal().dot(n0) < 0.0;
let mut r = l.resample(n, &angles, &fractions, flip);
if flip {
r = r.oriented(true);
}
// Start where every vertex lines up best with the lead's vertex of the same number (the
@@ -2036,10 +2072,9 @@ fn loft_numbered_wires(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Re
if let Some(k) = (0..m).min_by(|a, b| fit(*b).total_cmp(&fit(*a))) {
r.rotate_left(k);
}
wires.push(uniform_wire(p, &r.lp)?);
edge_sources.push(r.from);
out.push((*p, r));
}
Ok((wires, edge_sources))
Ok(out)
}
/// Ruled faces between each two neighbouring section wires; each comes from the section edges
+54 -57
View File
@@ -9,6 +9,8 @@
use std::collections::HashMap;
use std::fmt::Write as _;
mod assembly;
/// Largest IGES file we read.
pub const MAX_IGES_BYTES: usize = 512 << 20;
const MAX_ENTITIES: usize = 2_000_000;
@@ -1240,77 +1242,42 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
// Entities used by others (a B-rep's faces, a trimmed surface's curves) are not roots;
// physically dependent entities have status digits 01 in columns 3–4, but files are lax:
// collect references instead.
let mut solids: Vec<(u64, String)> = Vec::new();
let assembly = assembly::Assembly::read(&t, &seqs)?;
let mut solids = HashMap::new();
let mut styles: Vec<(u64, [f32; 3])> = Vec::new();
let mut n = 0usize;
let mut fallback = String::new();
let watch = crate::watch::current();
for &s in &seqs {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
let Some(de) = f.des.get(&s) else { continue };
if de.typ != 186 {
continue;
}
n += 1;
// A name property (406 form 15) among the solid's trailing pointers, else the label.
let named = f.params.get(&s).and_then(|p| {
p.iter().skip(4).filter_map(|x| int(x)).filter(|x| *x > 0).find_map(|ptr| {
let d = f.des.get(&usize::try_from(ptr).ok()?)?;
(d.typ == 406 && d.form == 15).then(|| f.params.get(&usize::try_from(ptr).ok()?)?.get(2).cloned()).flatten()
})
});
let name = match named.filter(|x| !x.trim().is_empty()) {
Some(x) => x.trim().to_string(),
None if de.label.is_empty() => format!("Body{n}"),
None => de.label.clone(),
let name = if let Some(name) = assembly::entity_name(&f, s) {
name
} else {
fallback.clear();
let _ = write!(fallback, "Body{n}");
fallback.as_str()
};
match brep(&mut t, s as i64, &mut styles) {
Ok((id, color)) => {
let named = {
// Re-label the solid with its name.
let line = t.o.lines.get(id as usize - 1).cloned().unwrap_or_default();
line.replacen("('',", &format!("('{}',", name.replace('\'', "''")), 1)
};
if let Some(l) = t.o.lines.get_mut(id as usize - 1) {
*l = named;
*l = l.replacen("('',", &format!("('{}',", name.replace('\'', "''")), 1);
}
if let Some(c) = color {
styles.push((id, c));
}
solids.push((id, name));
solids.insert(s, id);
}
Err(e) => t.warn(format!("solid {name}: not read: {e}")),
}
}
// Trimmed and bounded surfaces not used by anything else: sewn into shells.
let mut faces = Vec::new();
let mut sw = Sewer { tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() };
if solids.is_empty() {
for &s in &seqs {
let Some(de) = f.des.get(&s) else { continue };
if de.typ != 144 && de.typ != 143 && !(de.typ == 128 && is_root(&f, s)) {
continue;
}
match trimmed(&mut t, &mut sw, s as i64) {
Ok(fb) => faces.push(fb),
Err(e) => t.warn(format!("surface {s}: not read: {e}")),
}
}
if !faces.is_empty() {
orient(&mut faces);
let (shell, closed) = write_shell(&mut t.o, &sw, &faces, &mut styles);
let item = if closed {
t.o.add(format!("MANIFOLD_SOLID_BREP('Body1',#{shell})"))
} else {
t.warn("the surfaces do not close into a solid: imported as an open body");
t.o.add(format!("SHELL_BASED_SURFACE_MODEL('Body1',(#{shell}))"))
};
solids.push((item, "Body1".into()));
}
}
if solids.is_empty() {
let why = t.warnings.first().cloned().unwrap_or_else(|| "no solids or trimmed surfaces".into());
return Err(format!("IGES import: nothing to read ({why})"));
}
let items = solids.iter().map(|(i, _)| format!("#{i}")).collect::<Vec<_>>().join(",");
t.o.add(format!("ADVANCED_BREP_SHAPE_REPRESENTATION('',({items}),#{ctx})"));
assembly.write(&mut t, &seqs, &solids, &mut styles, ctx)?;
for (item, c) in styles {
let rgb = t.o.add(format!("COLOUR_RGB('',{},{},{})", r(c[0] as f64), r(c[1] as f64), r(c[2] as f64)));
let fasc = t.o.add(format!("FILL_AREA_STYLE_COLOUR('',#{rgb})"));
@@ -1331,12 +1298,42 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
Ok((s, t.warnings))
}
/// A surface entity nobody refers to (a bare B-spline patch is a face of its own).
fn is_root(f: &Iges, seq: usize) -> bool {
let target = seq.to_string();
!f.params
.iter()
.any(|(s, p)| *s != seq && f.des.get(s).is_some_and(|d| d.typ != 406 && d.typ != 402) && p.iter().skip(1).any(|x| x.trim() == target))
/// Sew surfaces within their owning group, keeping different parts separate.
fn sew_surfaces(
t: &mut Tr,
seqs: impl Iterator<Item = usize>,
name: &str,
styles: &mut Vec<(u64, [f32; 3])>,
faces: &mut Vec<FaceB>,
sw: &mut Sewer,
) -> R<Option<u64>> {
faces.clear();
sw.verts.clear();
sw.grid.clear();
sw.edges.clear();
sw.by_ends.clear();
let watch = crate::watch::current();
for seq in seqs {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
match trimmed(t, sw, seq as i64) {
Ok(face) => faces.push(face),
Err(e) => t.warn(format!("surface {seq}: not read: {e}")),
}
}
if faces.is_empty() {
return Ok(None);
}
orient(faces);
let (shell, closed) = write_shell(&mut t.o, sw, faces, styles);
let name = name.replace('\'', "''");
Ok(Some(if closed {
t.o.add(format!("MANIFOLD_SOLID_BREP('{name}',#{shell})"))
} else {
t.warn("the surfaces do not close into a solid: imported as an open body");
t.o.add(format!("SHELL_BASED_SURFACE_MODEL('{name}',(#{shell}))"))
}))
}
/// A trimmed (144) or bounded (143) surface, or a bare B-spline surface, as a face.
+280
View File
@@ -0,0 +1,280 @@
//! IGES groups (402) and subfigures (308/408), from the published IGES specification.
//! Definitions become STEP products; instances refer to them without copying geometry.
//! Reference: https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nbsir88-3813.pdf
use super::*;
use std::collections::HashSet;
const MAX_GROUPS: usize = 10_000;
const MAX_INSTANCES: usize = 100_000;
const MAX_REFERENCES: usize = 4 * MAX_ENTITIES;
struct Group {
seq: usize,
members: Vec<usize>,
}
struct Instance {
definition: usize,
transform: Xf,
}
pub(super) struct Assembly {
groups: Vec<Group>,
indices: HashMap<usize, usize>,
instances: HashMap<usize, Instance>,
referenced: HashSet<usize>,
surface_references: HashSet<usize>,
}
impl Assembly {
pub(super) fn read(t: &Tr, seqs: &[usize]) -> R<Self> {
let mut out = Self {
groups: Vec::new(),
indices: HashMap::new(),
instances: HashMap::new(),
referenced: HashSet::new(),
surface_references: HashSet::new(),
};
let mut references = 0usize;
let watch = crate::watch::current();
for &seq in seqs {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
let de = t.de(seq as i64)?;
if de.typ == 308 || (de.typ == 402 && matches!(de.form, 1 | 7 | 14 | 15)) {
if out.groups.len() >= MAX_GROUPS {
return Err("too many IGES assembly definitions".into());
}
let field = if de.typ == 308 { 3 } else { 1 };
let count = usize::try_from(t.i(seq as i64, field)?).map_err(|_| "negative IGES group size")?;
references = references.saturating_add(count);
if count > MAX_LIST || references > MAX_REFERENCES {
return Err("IGES assembly references exceed the work limit".into());
}
let p = t.p(seq as i64)?;
let entries = p.get(field + 1..field + 1 + count).ok_or("truncated IGES group")?;
let mut members = Vec::with_capacity(count);
for entry in entries {
let child = int(entry).and_then(|x| usize::try_from(x).ok()).filter(|x| *x > 0).ok_or("invalid IGES group reference")?;
t.de(child as i64)?;
members.push(child);
out.referenced.insert(child);
}
out.indices.insert(seq, out.groups.len());
out.groups.push(Group { seq, members });
} else if de.typ == 408 {
if out.instances.len() >= MAX_INSTANCES {
return Err("too many IGES assembly instances".into());
}
let definition = usize::try_from(t.i(seq as i64, 1)?).map_err(|_| "invalid IGES subfigure reference")?;
if t.de(definition as i64)?.typ != 308 {
return Err("IGES instance does not refer to a subfigure definition".into());
}
let p = t.p(seq as i64)?;
let scale = match p.get(5).map(|s| s.trim()) {
None | Some("") => 1.0,
Some(s) => num(s).filter(|x| *x != 0.0).ok_or("invalid IGES instance scale")?,
};
let [x, y, z] = t.pt(seq as i64, 2)?;
let local = Xf([[scale, 0.0, 0.0, x], [0.0, scale, 0.0, y], [0.0, 0.0, scale, z]]);
let transform = t.own_xf(seq as i64, 0)?.then(&local).then(&t.own_xf(definition as i64, 0)?);
if !transform.0.iter().flatten().all(|v| v.is_finite()) {
return Err("non-finite IGES instance placement".into());
}
out.instances.insert(seq, Instance { definition, transform });
out.referenced.insert(definition);
}
}
// Index geometry references once instead of rescanning all parameters for each patch.
for (&seq, params) in &t.f.params {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
if t.f.des.get(&seq).is_some_and(|d| matches!(d.typ, 308 | 402 | 406 | 408)) {
continue;
}
for p in params.iter().skip(1) {
let p = p.trim();
if p.starts_with('0') {
continue;
}
if let Ok(id) = p.parse::<usize>()
&& id != seq
&& t.f.des.get(&id).is_some_and(|d| d.typ == 128)
{
out.surface_references.insert(id);
}
}
}
let mut depths = HashMap::with_capacity(out.groups.len());
let mut placements = out.groups.iter().filter(|g| !out.referenced.contains(&g.seq)).count()
+ out.instances.keys().filter(|s| !out.referenced.contains(*s)).count();
for group in &out.groups {
placements += group.members.iter().filter(|s| out.indices.contains_key(*s) || out.instances.contains_key(*s)).count();
if placements > MAX_INSTANCES {
return Err("too many IGES assembly placements".into());
}
out.depth(group.seq, 0, &mut depths)?;
}
Ok(out)
}
fn depth(&self, seq: usize, depth: usize, seen: &mut HashMap<usize, Option<usize>>) -> R<usize> {
if depth >= MAX_DEPTH {
return Err("IGES assembly nesting is too deep".into());
}
if let Some(height) = seen.get(&seq) {
return height.ok_or_else(|| "cyclic IGES assembly".into());
}
seen.insert(seq, None);
let group = self.indices.get(&seq).and_then(|i| self.groups.get(*i)).ok_or("missing IGES assembly definition")?;
let mut height = 1;
for child in &group.members {
let definition = self.instances.get(child).map(|i| i.definition).unwrap_or(*child);
if self.indices.contains_key(&definition) {
height = height.max(1 + self.depth(definition, depth + 1, seen)?);
}
}
if height > MAX_DEPTH {
return Err("IGES assembly nesting is too deep".into());
}
seen.insert(seq, Some(height));
Ok(height)
}
pub(super) fn write(&self, t: &mut Tr, seqs: &[usize], solids: &HashMap<usize, u64>, styles: &mut Vec<(u64, [f32; 3])>, ctx: u64) -> R<()> {
let app = t.o.add("APPLICATION_CONTEXT('mechanical design')");
let pc = t.o.add(format!("PRODUCT_CONTEXT('',#{app},'mechanical')"));
let dc = t.o.add(format!("PRODUCT_DEFINITION_CONTEXT('part definition',#{app},'design')"));
let origin = t.o.placement([0.0; 3], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let mut products = HashMap::with_capacity(self.groups.len() + 1);
let mut items = Vec::new();
let mut list = String::new();
let mut fallback = String::new();
let mut made = 0usize;
let f = t.f;
let mut faces = Vec::new();
let mut sewer = Sewer { tol: t.tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() };
let watch = crate::watch::current();
// A synthetic root holds loose geometry and top-level groups/instances.
for group in std::iter::once(None).chain(self.groups.iter().map(Some)) {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
let seq = group.map(|g| g.seq).unwrap_or(0);
let name = if seq == 0 {
"Import"
} else if let Some(name) = entity_name(f, seq) {
name
} else {
fallback.clear();
let _ = write!(fallback, "Group {seq}");
fallback.as_str()
};
items.clear();
let members = group.map(|g| g.members.as_slice()).unwrap_or(seqs);
items.extend(members.iter().filter(|s| seq != 0 || !self.referenced.contains(*s)).filter_map(|s| solids.get(s).copied()));
let surfaces = members
.iter()
.copied()
.filter(|s| seq != 0 || !self.referenced.contains(s))
.filter(|s| f.des.get(s).is_some_and(|d| matches!(d.typ, 143 | 144) || (d.typ == 128 && !self.surface_references.contains(s))));
if let Some(item) = sew_surfaces(t, surfaces, if seq == 0 { "Body1" } else { name }, styles, &mut faces, &mut sewer)? {
items.push(item);
}
made += items.len();
list.clear();
let _ = write!(list, "#{origin}");
for item in &items {
let _ = write!(list, ",#{item}");
}
let rep = t.o.add(format!("ADVANCED_BREP_SHAPE_REPRESENTATION('',({list}),#{ctx})"));
let name = name.replace('\'', "''");
let product = t.o.add(format!("PRODUCT('{name}','{name}','',(#{pc}))"));
let formation = t.o.add(format!("PRODUCT_DEFINITION_FORMATION('','',#{product})"));
let pd = t.o.add(format!("PRODUCT_DEFINITION('design','',#{formation},#{dc})"));
let shape = t.o.add(format!("PRODUCT_DEFINITION_SHAPE('','',#{pd})"));
t.o.add(format!("SHAPE_DEFINITION_REPRESENTATION(#{shape},#{rep})"));
products.insert(seq, (pd, rep));
}
if made == 0 {
let why = t.warnings.first().map(String::as_str).unwrap_or("no solids or trimmed surfaces");
return Err(format!("IGES import: nothing to read ({why})"));
}
for group in std::iter::once(None).chain(self.groups.iter().map(Some)) {
let parent = group.map(|g| g.seq).unwrap_or(0);
let members = group.map(|g| g.members.as_slice()).unwrap_or(seqs);
for &child in members {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
if parent == 0 && self.referenced.contains(&child) {
continue;
}
let (definition, transform) = match self.instances.get(&child) {
Some(i) => (i.definition, i.transform),
None if self.indices.contains_key(&child) => (child, t.own_xf(child as i64, 0)?),
_ => continue,
};
let (parent_pd, parent_rep) = products.get(&parent).copied().ok_or("missing IGES assembly parent")?;
let (child_pd, child_rep) = products.get(&definition).copied().ok_or("missing IGES assembly child")?;
let label = entity_name(f, child).unwrap_or("").replace('\'', "''");
let nauo = t.o.add(format!("NEXT_ASSEMBLY_USAGE_OCCURRENCE('{child}','{label}','',#{parent_pd},#{child_pd},$)"));
let shape = t.o.add(format!("PRODUCT_DEFINITION_SHAPE('','',#{nauo})"));
let op = transformation(&mut t.o, transform)?;
let rr = t.o.add(format!("(REPRESENTATION_RELATIONSHIP('','',#{child_rep},#{parent_rep})REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{op})SHAPE_REPRESENTATION_RELATIONSHIP())"));
t.o.add(format!("CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{rr},#{shape})"));
}
}
Ok(())
}
}
fn transformation(out: &mut Out, xf: Xf) -> R<u64> {
if !xf.0.iter().flatten().all(|v| v.is_finite()) {
return Err("non-finite IGES assembly placement".into());
}
let [x, y, z] = [xf.dir([1.0, 0.0, 0.0]), xf.dir([0.0, 1.0, 0.0]), xf.dir([0.0, 0.0, 1.0])];
let [sx, sy, sz] = [norm(x), norm(y), norm(z)];
let scale = xf.scale();
if [sx, sy, sz, scale].iter().any(|s| !s.is_finite() || *s <= 1e-300) {
return Err("singular IGES assembly placement".into());
}
let (x, y, z, o) = (out.dir(mul(x, 1.0 / sx)), out.dir(mul(y, 1.0 / sy)), out.dir(mul(z, 1.0 / sz)), out.point(xf.point([0.0; 3])));
Ok(out.add(format!("CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM('','',#{x},#{y},#{o},{},#{z},{},{})", r(sx), r(sy), r(sz))))
}
/// Name properties follow the associativity pointers; member pointers are not properties.
pub(super) fn entity_name(f: &Iges, seq: usize) -> Option<&str> {
let de = f.des.get(&seq)?;
let p = f.params.get(&seq)?;
let count = |i: usize| p.get(i).and_then(|s| int(s)).and_then(|x| usize::try_from(x).ok()).filter(|n| *n <= MAX_LIST);
let end = match de.typ {
186 => count(3)?.checked_mul(2)?.checked_add(4)?,
308 => count(3)?.checked_add(4)?,
402 => count(1)?.checked_add(2)?,
408 => 6,
_ => p.len(),
};
let property = (|| {
let after = end.checked_add(count(end)?)?.checked_add(1)?;
let n = count(after)?;
p.get(after + 1..after + 1 + n)?.iter().find_map(|s| {
let id = int(s).and_then(|x| usize::try_from(x).ok())?;
let d = f.des.get(&id)?;
if d.typ != 406 || d.form != 15 {
return None;
}
f.params.get(&id)?.get(2).and_then(|s| name_text(s))
})
})();
property.or_else(|| if de.typ == 308 { p.get(2).and_then(|s| name_text(s)) } else { None }).or_else(|| name_text(&de.label))
}
/// Match the component-name limit before copying or repeating a shared name property.
fn name_text(s: &str) -> Option<&str> {
let end = s.char_indices().nth(128).map(|(i, _)| i).unwrap_or(s.len());
let name = s.get(..end)?.trim();
(!name.is_empty()).then_some(name)
}
+134 -6
View File
@@ -156,15 +156,116 @@ fn an_arc_revolved_about_its_centre_is_a_cap_not_a_ball() {
assert!(rel(area, cap) < 1e-3, "area {area} vs {cap}");
}
/// A unit-cube-like box (10 × 20 × 30) made of six trimmed bilinear B-spline patches whose
/// boundaries are lines: sewn into a closed solid.
/// An exported box inside a named group and nested subfigures, placed twice. The second
/// placement composes its translation and scale with a directory transformation.
fn assembly_file(form: i64, scale: f64) -> String {
let body = box_solid(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0)).unwrap();
let text = iges_export_bodies(&[ExportBody { name: "Body1".into(), body: &body, color: None }], &StepHeader::default()).unwrap();
let f = parse(&text).unwrap();
let mut ids: Vec<_> = f.des.keys().copied().collect();
ids.sort_unstable();
let solid = *ids.iter().find(|&&s| f.des[&s].typ == 186).unwrap();
let mut transforms: HashMap<_, _> = f.des.iter().map(|(seq, d)| (*seq, d.xform)).collect();
let mut ents: Vec<_> = ids
.iter()
.enumerate()
.map(|(k, seq)| {
assert_eq!(*seq, 2 * k + 1);
let d = &f.des[seq];
let p = f.params[seq]
.iter()
.skip(1)
.map(|s| if s.is_empty() || num(s).is_some() { s.clone() } else { format!("{}H{s}", s.chars().count()) })
.collect::<Vec<_>>()
.join(",");
(d.typ, d.form, p)
})
.collect();
let definition = 2 * ents.len() + 1;
ents.push((308, 0, format!("0,8HLead rod,1,{solid}")));
let first = 2 * ents.len() + 1;
ents.push((408, 0, format!("{definition},10.,0.,0.,1.")));
let rotation = 2 * ents.len() + 1;
ents.push((124, 0, "0.,-1.,0.,100.,1.,0.,0.,0.,0.,0.,1.,0.".into()));
let second = 2 * ents.len() + 1;
ents.push((408, 0, format!("{definition},0.,20.,0.,{scale}")));
transforms.insert(second, rotation as i64);
let frame = 2 * ents.len() + 1;
ents.push((308, 0, format!("1,5HFrame,2,{first},{second}")));
let property = 2 * ents.len() + 1;
ents.push((406, 15, "1,6HMotion".into()));
ents.push((402, form, format!("1,{frame},0,1,{property}")));
iges_file(&ents)
.lines()
.map(|line| {
if line.as_bytes()[72] != b'D' {
return line.to_string();
}
let seq: usize = line[73..80].trim().parse().unwrap();
match transforms.get(&seq) {
Some(xf) => format!("{}{:>8}{}", &line[..48], xf, &line[56..]),
None => line.to_string(),
}
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn trimmed_surfaces_sew_into_a_solid() {
let c =
|i: usize| -> [f64; 3] { [if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }] };
fn groups_and_subfigure_instances_keep_names_structure_and_placements() {
for (form, scale) in [(1, 1.0), (7, 2.0), (14, 1.0), (15, 2.0)] {
let imp = iges_import(&assembly_file(form, scale)).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert_eq!(imp.bodies.len(), 2);
assert_eq!(imp.tree.len(), 1);
let motion = &imp.tree[0].children[0];
assert_eq!(motion.name, "Motion");
let frame = &motion.children[0];
assert_eq!(frame.name, "Frame");
assert_eq!(frame.children.len(), 2);
assert!(frame.children.iter().all(|n| n.name == "Lead rod"));
assert!(imp.bodies.iter().all(|b| b.path == ["Import", "Motion", "Frame", "Lead rod"]));
assert_eq!(imp.bodies[0].source_item, imp.bodies[1].source_item);
let a = measure(&imp.bodies[0].body).unwrap();
let b = measure(&imp.bodies[1].body).unwrap();
assert!(rel(a.volume, 24.0) < 1e-6 && rel(b.volume, 24.0 * scale.powi(3)) < 1e-6);
assert!(a.bbox.min.dist(Vec3::new(10.0, 0.0, 0.0)) < 1e-6 && a.bbox.max.dist(Vec3::new(12.0, 3.0, 4.0)) < 1e-6);
assert!(b.bbox.min.dist(Vec3::new(80.0 - 3.0 * scale, 0.0, 0.0)) < 1e-6);
assert!(b.bbox.max.dist(Vec3::new(80.0, 2.0 * scale, 4.0 * scale)) < 1e-6);
}
}
#[test]
fn invalid_assembly_references_and_counts_fail_before_geometry() {
for (params, message) in [
("0,4HLoop,1,1".into(), "cyclic"),
("0,4HPart,1,999".into(), "missing entity"),
("0,4HPart,-1".into(), "negative IGES group size"),
(format!("0,4HPart,{}", MAX_LIST + 1), "work limit"),
] {
let error = iges_import(&iges_file(&[(308, 0, params)])).unwrap_err();
assert!(matches!(&error, crate::KernelError::Invalid(text) if text.contains(message)), "{error}");
}
let mut chain = Vec::new();
for k in 0..=MAX_DEPTH {
chain.push((308, 0, if k == MAX_DEPTH { "0,4HPart,0".into() } else { format!("0,4HPart,1,{}", 2 * k + 3) }));
}
let error = iges_import(&iges_file(&chain)).unwrap_err();
assert!(matches!(&error, crate::KernelError::Invalid(text) if text.contains("nesting is too deep")), "{error}");
let members = "1,".repeat(100_000);
let many = [(308, 0, "0,4HPart,0".into()), (308, 0, format!("1,5HFrame,100001,{members}1"))];
let error = iges_import(&iges_file(&many)).unwrap_err();
assert!(matches!(&error, crate::KernelError::Invalid(text) if text.contains("too many IGES assembly placements")), "{error}");
}
/// Six trimmed bilinear patches with line boundaries, forming a 10 × 20 × 30 box.
fn trimmed_box(ents: &mut Vec<(i64, i64, String)>, x: f64) -> Vec<usize> {
let c = |i: usize| -> [f64; 3] {
[x + if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }]
};
// Faces as corner quads (counter-clockwise seen from outside).
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
let mut ents: Vec<(i64, i64, String)> = Vec::new();
let mut faces = Vec::new();
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
for q in quads {
let (a, b, cc, d) = (c(q[0]), c(q[1]), c(q[2]), c(q[3]));
@@ -182,7 +283,15 @@ fn trimmed_surfaces_sew_into_a_solid() {
ents.push((142, 0, format!("0,{s_de},0,{comp},2")));
let cos = 2 * ents.len() - 1;
ents.push((144, 0, format!("{s_de},1,0,{cos}")));
faces.push(2 * ents.len() - 1);
}
faces
}
#[test]
fn trimmed_surfaces_sew_into_a_solid() {
let mut ents = Vec::new();
trimmed_box(&mut ents, 0.0);
let imp = iges_import(&iges_file(&ents)).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert!(imp.bodies[0].closed);
@@ -190,6 +299,25 @@ fn trimmed_surfaces_sew_into_a_solid() {
assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area);
}
#[test]
fn surfaces_in_separate_groups_remain_separate_parts() {
let mut ents = Vec::new();
for (name, x) in [("Left", 0.0), ("Right", 100.0)] {
let faces = trimmed_box(&mut ents, x);
let members = faces.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(",");
ents.push((308, 0, format!("0,{}H{name},{},{members}", name.len(), faces.len())));
}
let imp = iges_import(&iges_file(&ents)).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert_eq!(imp.bodies.len(), 2);
assert_eq!(imp.tree[0].children.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["Left", "Right"]);
for (body, x) in imp.bodies.iter().zip([0.0, 100.0]) {
let m = measure(&body.body).unwrap();
assert!(body.closed && rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6);
assert!(m.bbox.min.dist(Vec3::new(x, 0.0, 0.0)) < 1e-6 && m.bbox.max.dist(Vec3::new(x + 10.0, 20.0, 30.0)) < 1e-6);
}
}
#[test]
fn a_drill_point_round_trips_through_iges() {
// A blind hole with a cone tip: the tip's face closes at its apex (its seam runs out to the
+2
View File
@@ -153,6 +153,7 @@ mod tspline;
#[cfg(test)]
mod untouched_faces_tests;
pub mod watch;
mod whole_face;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_max, fillet_max_each};
pub use blend_corner::fillet_radii;
@@ -220,6 +221,7 @@ pub use sweep_path::{RailFit, SweepControl, SweepOrientation, sweep_path, sweep_
pub use topo::{CylinderFace, cylinder_end_at, cylinder_face, cylinder_face_at};
pub use topo::{TopoCounts, merged_topology, seam_flags};
pub use tspline::tspline_sheet;
pub use whole_face::{WholeFace, cut_takes_only_the_face, cut_tool, face_prism, matching_face};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum KernelError {
+73 -17
View File
@@ -85,9 +85,13 @@ impl NumberedLoop {
NumberedLoop { lp: pieces, from }
}
/// Re-split into exactly `n` segments: a full circle gets breaks at `angles` (or evenly),
/// other loops split their longest segments in half.
pub(crate) fn resample(&self, n: usize, angles: &[f64]) -> NumberedLoop {
/// Re-split into exactly `n` segments, other loops split their longest segments in half. A
/// full circle is split once round (one turn, every arc a positive sweep) to follow a lead
/// loop: at the lead's vertex directions `angles` when they go round in order (counter-
/// clockwise, or clockwise when `clockwise`), otherwise from the first of them at the lead's
/// perimeter `fractions` (the share of the lead's length before each vertex; evenly when
/// those are unusable).
pub(crate) fn resample(&self, n: usize, angles: &[f64], fractions: &[f64], clockwise: bool) -> NumberedLoop {
let lp = &self.lp;
let circle = lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. }))
&& lp.segs.windows(2).all(|w| match (w.first(), w.get(1)) {
@@ -96,22 +100,15 @@ impl NumberedLoop {
}
_ => false,
});
if circle && let Some(Seg2::Arc { center, radius, .. }) = lp.segs.first().copied() {
let mut a: Vec<f64> =
if angles.len() == n { angles.to_vec() } else { (0..n).map(|i| std::f64::consts::TAU * i as f64 / n as f64).collect() };
// Keep the given order but make them increase.
for i in 1..a.len() {
let Some(prev) = a.get(i - 1).copied() else { continue };
if let Some(x) = a.get_mut(i) {
while *x <= prev {
*x += std::f64::consts::TAU;
}
}
}
let segs: Vec<Seg2> = (0..n)
if circle
&& n > 0
&& let Some(Seg2::Arc { center, radius, .. }) = lp.segs.first().copied()
{
let a = circle_breaks(n, angles, fractions, clockwise);
let segs: Vec<Seg2> = (0..a.len())
.filter_map(|i| {
let s = *a.get(i)?;
let e = if i + 1 < n { *a.get(i + 1)? } else { a.first()? + std::f64::consts::TAU };
let e = if i + 1 < a.len() { *a.get(i + 1)? } else { a.first()? + std::f64::consts::TAU };
Some(Seg2::Arc { center, radius, start: s, sweep: e - s })
})
.collect();
@@ -190,6 +187,65 @@ impl NumberedRegion {
}
}
/// The smallest arc a circle is split into (radians).
const MIN_SWEEP: f64 = 1e-9;
/// `n` increasing break angles of one turn round a circle (each step and the closing step back
/// to the first, one turn on, positive) for a circle following a lead loop: `angles` (the lead's
/// vertex directions) when they go round once in order in the lead's direction (counter-clockwise,
/// or clockwise when `clockwise`), otherwise from the first of them at the lead's perimeter
/// `fractions`, or evenly. A non-star-shaped lead's directions turn back on themselves: forcing
/// them to increase wound the circle round many times (#2570).
fn circle_breaks(n: usize, angles: &[f64], fractions: &[f64], clockwise: bool) -> Vec<f64> {
use std::f64::consts::TAU;
let sign = if clockwise { -1.0 } else { 1.0 };
// Angles measured in the lead's direction of travel.
let along: Vec<f64> = angles.iter().map(|a| sign * a).collect();
let run = (along.len() == n).then(|| one_turn(&along)).flatten().unwrap_or_else(|| {
let start = along.first().copied().filter(|a| a.is_finite()).unwrap_or(0.0);
let even = || (0..n).map(|i| i as f64 / n as f64).collect::<Vec<f64>>();
let f = if usable_fractions(n, fractions) { fractions.to_vec() } else { even() };
f.iter().map(|f| start + TAU * f).collect()
});
let mut breaks: Vec<f64> = run.into_iter().map(|a| sign * a).collect();
if clockwise {
breaks.reverse();
}
breaks
}
/// `along` unwrapped into one increasing turn (each step the shortest positive one), or `None`
/// when they do not go round once in order.
fn one_turn(along: &[f64]) -> Option<Vec<f64>> {
let mut out: Vec<f64> = Vec::with_capacity(along.len());
for &x in along {
if !x.is_finite() {
return None;
}
let next = match out.last() {
Some(&prev) => {
let step = (x - prev).rem_euclid(std::f64::consts::TAU);
if step <= MIN_SWEEP {
return None;
}
prev + step
}
None => x,
};
out.push(next);
}
let (first, last) = (*out.first()?, *out.last()?);
(last - first < std::f64::consts::TAU - MIN_SWEEP).then_some(out)
}
/// `fractions` are `n` finite, strictly increasing shares of a turn from 0, all under one.
fn usable_fractions(n: usize, fractions: &[f64]) -> bool {
fractions.len() == n
&& fractions.first().is_some_and(|f| f.abs() < MIN_SWEEP)
&& fractions.windows(2).all(|w| matches!(w, [a, b] if b - a > MIN_SWEEP))
&& fractions.last().is_some_and(|f| f.is_finite() && *f < 1.0 - MIN_SWEEP)
}
/// Every number of `sets`, once each, in order.
fn union<'a>(sets: impl IntoIterator<Item = &'a [usize]>) -> Vec<usize> {
sets.into_iter().flatten().copied().collect::<BTreeSet<usize>>().into_iter().collect()
+1 -1
View File
@@ -796,7 +796,7 @@ fn unsupported_shell(m: &str) -> KernelError {
KernelError::Failed(format!("not supported yet: {m}"))
}
fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
pub(crate) fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
let n = (b - a).cross(c - a);
let Some(nn) = n.normalized() else { return f64::INFINITY };
let h = (p - a).dot(nn);
+1 -1
View File
@@ -101,7 +101,7 @@ pub(crate) fn point(cx: &Ctx, id: u64) -> R<mt::Point3> {
Ok(mt::Point3::new(g(0)?, g(1)?, g(2)?) * cx.len)
}
fn direction(cx: &Ctx, id: u64) -> R<mt::Vector3> {
pub(super) fn direction(cx: &Ctx, id: u64) -> R<mt::Vector3> {
let e = entity(cx, id)?;
if e.name() != "DIRECTION" {
return Err(format!("#{id}: expected DIRECTION, found {}", e.name()));
+31 -2
View File
@@ -562,10 +562,39 @@ impl Reader {
fn placement_matrix(&mut self, item: u64, u: Units) -> R<mt::Matrix4> {
let cx = Ctx { ex: &self.ex, len: u.len, ang: u.ang, tol: u.tol };
let e = geom::entity(&cx, item)?;
if e.name() == "CARTESIAN_TRANSFORMATION_OPERATOR_3D" {
if matches!(e.name(), "CARTESIAN_TRANSFORMATION_OPERATOR_3D" | "CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM") {
let p = e.params();
let o = geom::point(&cx, p.get(4).and_then(Param::as_ref_id).ok_or("transformation without origin")?)?;
return Ok(mt::Matrix4::from_translation(o.to_homogeneous().truncate()));
let direction = |i: usize, fallback: mt::Vector3| -> R<mt::Vector3> {
match p.get(i) {
None | Some(Param::Null) => Ok(fallback),
Some(Param::Ref(id)) => geom::direction(&cx, *id),
_ => Err("invalid transformation direction".into()),
}
};
let scale = |i: usize, fallback: f64| -> R<f64> {
match p.get(i) {
None | Some(Param::Null) => Ok(fallback),
Some(v) => v.as_f64().filter(|x| x.is_finite() && *x != 0.0).ok_or_else(|| "invalid transformation scale".into()),
}
};
let sx = scale(5, 1.0)?;
let (sy, sz) = (scale(7, sx)?, scale(8, sx)?);
let m = mt::Matrix4::from_cols(
(direction(2, mt::Vector3::unit_x())? * sx).extend(0.0),
(direction(3, mt::Vector3::unit_y())? * sy).extend(0.0),
(direction(6, mt::Vector3::unit_z())? * sz).extend(0.0),
o.to_homogeneous(),
);
if !to_array(&m).iter().flatten().all(|x| x.is_finite()) {
return Err("invalid transformation scale".into());
}
use mt::SquareMatrix as _;
let det = m.determinant();
if !det.is_finite() || det == 0.0 {
return Err("singular transformation".into());
}
return Ok(m);
}
Ok(geom::frame(&cx, item)?.matrix())
}
+78
View File
@@ -2964,6 +2964,53 @@ fn loft_pairs_the_vertices_of_matching_non_convex_sections() {
assert!(m.centroid.x.abs() < 0.05, "centre of mass x {}", m.centroid.x);
}
/// #2570: a circle lofted to a C-shaped outline. Seen from its centroid the C's vertices turn
/// back on themselves; the circle was still split at those directions, forced to increase by
/// whole turns, so it wound round several times (a closing arc of negative sweep), the walls
/// crossed and the mesh grew huge. The circle is now split once round, in both section orders
/// and from a plane facing the other way.
#[test]
fn loft_circle_to_non_star_profile_splits_circle_once() {
let c = Loop2::polygon(&[
Vec2::new(0.0, 0.0),
Vec2::new(30.0, 0.0),
Vec2::new(30.0, 8.0),
Vec2::new(8.0, 8.0),
Vec2::new(8.0, 22.0),
Vec2::new(30.0, 22.0),
Vec2::new(30.0, 30.0),
Vec2::new(0.0, 30.0),
]);
let circle = (Plane::XY, Loop2::circle(Vec2::ZERO, 20.0));
let up = (Plane::XY.offset(48.0), c.clone());
let down = (Plane::new(Vec3::new(0.0, 0.0, 48.0), Vec3::Y, Vec3::X).unwrap(), c);
for secs in [vec![circle.clone(), up.clone()], vec![up, circle.clone()], vec![circle, down]] {
let numbered: Vec<(Plane, crate::numbered::NumberedLoop)> =
secs.iter().map(|(p, _)| *p).zip(crate::numbered::number_loops(secs.iter().map(|(_, l)| l))).collect();
for reversed in [false, true] {
let loops = crate::build::loft_section_loops(&numbered, reversed).unwrap();
let arcs: Vec<f64> = loops
.iter()
.flat_map(|(_, r)| r.lp.segs.iter())
.filter_map(|s| match s {
Seg2::Arc { sweep, .. } => Some(*sweep),
_ => None,
})
.collect();
assert_eq!(arcs.len(), 8, "{arcs:?}");
assert!(arcs.iter().all(|w| w.is_finite() && w.abs() > 1e-9), "{arcs:?}");
// One way round: every arc the same way, one turn in all.
assert!(arcs.iter().all(|w| w.signum() == arcs[0].signum()), "{arcs:?}");
let total: f64 = arcs.iter().sum();
assert!((total.abs() - std::f64::consts::TAU).abs() < 1e-9, "{total} {arcs:?}");
}
let body = loft(&secs).unwrap();
assert!(measure(&body).unwrap().volume > 0.0);
let mesh = body.tessellate(0.05).unwrap();
assert!(mesh.triangles.len() < 4000, "{} triangles", mesh.triangles.len());
}
}
/// Two cylinders crossing at right angles need the kernel's own intersection: with no tries
/// left the boolean fails at once, a budget inside another uses the outer one's up, and none
/// lingers after.
@@ -2981,6 +3028,37 @@ fn an_attempt_budget_stops_the_boolean_and_is_shared_by_nesting() {
assert!(boolean(&a, &b, BoolOp::Intersect).unwrap().is_some());
}
/// #2847: identical elliptic spline sections (3 x 5 mm, 30 mm off the Z axis) on planes turning
/// about Z in 10 degree steps. The start vertex was matched by projecting the lead section's
/// vertex directions onto each plane, which squeezes them as the plane turns (and flips them past
/// 90 degrees), so the sections were paired round the wrong way and the loft folded (6 % of
/// Pappus' volume at 180 degrees). The volume follows Pappus: area x 30 mm x angle.
#[test]
fn loft_through_identical_sections_turning_about_an_axis_keeps_its_volume() {
let pts: Vec<Vec2> = (0..12).map(|a| Vec2::new(30.0 + 3.0 * (a as f64 * PI / 6.0).cos(), 5.0 * (a as f64 * PI / 6.0).sin())).collect();
// Periodic Catmull-Rom through the points, as cubic Beziers.
let segs: Vec<Seg2> = (0..12)
.map(|i| {
let p = |k: usize| pts[(i + k + 11) % 12];
let (p0, p1, p2, p3) = (p(0), p(1), p(2), p(3));
Seg2::Cubic { p0: p1, p1: p1 + (p2 - p0) * (1.0 / 6.0), p2: p2 - (p3 - p1) * (1.0 / 6.0), p3: p2 }
})
.collect();
let section = Loop2 { segs };
let area = section.signed_area().abs();
for span in [90, 120, 180] {
let secs: Vec<(Plane, Loop2)> = (0..=span / 10)
.map(|i| {
let t = (i as f64 * 10.0).to_radians();
(Plane { origin: Vec3::ZERO, x: Vec3::new(t.cos(), t.sin(), 0.0), y: Vec3::Z }, section.clone())
})
.collect();
let want = area * 30.0 * (span as f64).to_radians();
let m = measure(&loft(&secs).unwrap()).unwrap();
assert!(rel(m.volume, want) < 0.03, "{span} degrees: volume {} vs Pappus {want}", m.volume);
}
}
/// Moving an assembly places every body of it again: a copy placed for showing keeps its
/// revolved faces where its source turned them, so placing searches none of them again (that search was most
/// of the time an occurrence move took on a large assembly, #3044), and the copy is the same
+364
View File
@@ -0,0 +1,364 @@
//! Extrudes from a whole planar face of a body (#2040). The profile of a face is its outline
//! projected into a sketch, and an edge that is no line or arc comes in fitted: the elliptic
//! arcs where a fillet meets a slanted face are cubic splines a hair off the body's edges. A
//! join's tool then touches the body along a face it does not match, and a cut's walls lie a
//! hair inside or outside the body all round: the boolean finds pieces of no area and gives up.
//!
//! When the profile is the face's outline (to within the fit), the face itself is used: a join
//! sweeps the body's own face, which shares its edges with the body and is sewn on along it, and
//! a cut whose walls take nothing from the body (all of it within the cut's depth lies over the
//! face) is the body cut by a slab beyond the depth plane, which meets it only across that plane.
use solidcraft_geom::{Mesh, Plane, Region2, Vec2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, v3};
use crate::history::{Change, History};
use crate::{KernelError, Result};
/// Most boundary samples compared per outline.
const MAX_SAMPLES: usize = 20_000;
/// A planar face of `target` and where it lies.
#[derive(Clone, Copy, Debug)]
pub struct WholeFace {
/// The face's index in `target`.
pub face: usize,
/// Its outward normal.
pub normal: Vec3,
/// Its height along the sketch normal, from the sketch plane's origin.
pub height: f64,
}
/// How far a fitted outline may lie from the face's edges: the projection fits a smooth chain to
/// within 1e-4 of its extent (`doc::project`), and the mesh the edges are sampled from sags.
fn outline_tol(target: &Body) -> f64 {
(target.size() * 5e-4).max(1e-4)
}
/// The planar face of `target` at one of `heights` along `plane`'s normal (from its origin)
/// whose outline is `region`'s outer loop, to within the projection's fit, with no holes on
/// either side.
pub fn matching_face(target: &Body, plane: &Plane, region: &Region2, heights: &[f64]) -> Option<WholeFace> {
if !region.holes.is_empty() {
return None;
}
let n = plane.normal();
let tol = outline_tol(target);
let mesh = target.tessellate(tol * 0.2).ok()?;
let ring: Vec<Vec2> = region.outer.polyline(tol * 0.1).into_vec();
if ring.len() < 3 || ring.len() > MAX_SAMPLES {
return None;
}
let faces: Vec<&mt::Face> = target.solid.face_iter().collect();
for (i, f) in faces.iter().enumerate() {
let Some(m) = crate::blend::plane_normal(f) else { continue };
if m.dot(n).abs() < 1.0 - 1e-9 || f.boundaries().len() != 1 {
continue;
}
let edges = face_outline(&mesh, i);
let Some(height) = edges.first().and_then(|e| e.first()).map(|p| (*p - plane.origin).dot(n)) else { continue };
if !heights.iter().any(|h| (h - height).abs() <= tol) {
continue;
}
// The outline in the sketch's plane, at the face's height.
let at = |p: Vec2| plane.to_world(p) + n * height;
let ring3: Vec<Vec3> = ring.iter().map(|p| at(*p)).collect();
let segs: Vec<(Vec3, Vec3)> = edges.iter().flat_map(|e| e.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?)))).collect();
if segs.is_empty() || segs.len() > MAX_SAMPLES {
continue;
}
let near_edges = |p: Vec3| segs.iter().any(|(a, b)| p.dist_to_segment(*a, *b) <= tol);
let ring_segs: Vec<(Vec3, Vec3)> = (0..ring3.len()).filter_map(|k| Some((*ring3.get(k)?, *ring3.get((k + 1) % ring3.len())?))).collect();
let near_ring = |p: Vec3| ring_segs.iter().any(|(a, b)| p.dist_to_segment(*a, *b) <= tol);
let off_plane = segs.iter().any(|(a, _)| ((*a - plane.origin).dot(n) - height).abs() > tol);
if !off_plane && ring3.iter().all(|p| near_edges(*p)) && segs.iter().all(|(a, _)| near_ring(*a)) {
return Some(WholeFace { face: i, normal: m, height });
}
}
None
}
/// The boundary polylines of face `face` of `mesh`, seams left out.
fn face_outline(mesh: &Mesh, face: usize) -> Vec<Vec<Vec3>> {
let Ok(f) = u32::try_from(face) else { return Vec::new() };
mesh.face_edges(f).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).filter_map(|e| mesh.edges.get(e).cloned()).collect()
}
/// The prism `built` (made from a profile that [`matching_face`] matched to `face`) made again
/// from the face itself: the face swept `length` along its outward normal, `outward` or back
/// into the body. Each of its faces comes from the face of `built` it lies on, so the feature
/// names its faces as it named `built`'s.
pub fn face_prism(target: &Body, face: WholeFace, length: f64, outward: bool, built: &Body) -> Result<Body> {
if !length.is_finite() || length <= 0.0 {
return Err(KernelError::Invalid("a prism from a face needs a positive length".into()));
}
let f = target.solid.face_iter().nth(face.face).cloned().ok_or_else(|| KernelError::Invalid("no such face".into()))?;
let along = if outward { face.normal * length } else { face.normal * -length };
let swept = crate::guard("prism from a face", || Body::new(mt::builder::tsweep(&f, v3(along))))?;
traced_onto(swept, built, outline_tol(target))
}
/// `swept`, made in place of `built`, with each of its faces coming from the face of `built`
/// nearest it (so a feature names its faces as it named `built`'s).
fn traced_onto(swept: Body, built: &Body, tol: f64) -> Result<Body> {
let (mine, theirs) = (swept.tessellate(tol)?, built.tessellate(tol)?);
let mut faces = Vec::with_capacity(swept.face_count());
for i in 0..swept.face_count() {
let probe = face_probe(&mine, i).ok_or_else(|| KernelError::Failed("tool from a face: a face with no mesh".into()))?;
let from = nearest_face(&theirs, probe).ok_or_else(|| KernelError::Failed("tool from a face: no face under it".into()))?;
faces.push(crate::record::from_face(0, from, Change::Modified));
}
let history: History = crate::record::from_faces(&swept.solid, 1, faces);
swept.with_history_counts(history, &crate::record::counts(&[built]))
}
/// The centroid of face `face`'s largest triangle.
fn face_probe(mesh: &Mesh, face: usize) -> Option<Vec3> {
let f = u32::try_from(face).ok()?;
mesh.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, tf)| **tf == f)
.filter_map(|(t, _)| mesh.tri(t))
.max_by(|x, y| (x[1] - x[0]).cross(x[2] - x[0]).len().total_cmp(&(y[1] - y[0]).cross(y[2] - y[0]).len()))
.map(|[a, b, c]| (a + b + c) / 3.0)
}
/// The face of `mesh` nearest `p`.
fn nearest_face(mesh: &Mesh, p: Vec3) -> Option<usize> {
mesh.triangles
.iter()
.zip(&mesh.tri_face)
.filter_map(|(t, f)| mesh.tri(t).map(|[a, b, c]| (crate::polyhedron::point_tri(p, a, b, c), *f)))
.min_by(|x, y| x.0.total_cmp(&y.0))
.and_then(|(_, f)| usize::try_from(f).ok())
}
/// Whether a cut `depth` into `target` from `face` takes only what lies over the face: nothing of
/// the body stands beyond the face's plane, and everything of it less than `depth` below lies
/// over the face (on its outline at most). Then the prism over the face and a slab over the whole
/// body take the same material, and the slab meets the body only across its depth plane.
pub fn cut_takes_only_the_face(target: &Body, face: WholeFace, depth: f64) -> bool {
if !depth.is_finite() || depth <= 0.0 {
return false;
}
let tol = outline_tol(target);
let Ok(mesh) = target.tessellate(tol * 0.2) else { return false };
let Some(origin) = face_outline(&mesh, face.face).first().and_then(|e| e.first().copied()) else { return false };
let n = face.normal;
// The face's plane with the outline in it, flattened.
let Some(x) = (if n.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).cross(n).normalized() else { return false };
let y = n.cross(x);
let flat = |p: Vec3| Vec2::new((p - origin).dot(x), (p - origin).dot(y));
let segs: Vec<(Vec2, Vec2)> = face_outline(&mesh, face.face)
.iter()
.flat_map(|e| e.windows(2).filter_map(|w| Some((flat(*w.first()?), flat(*w.get(1)?)))).collect::<Vec<_>>())
.collect();
if segs.len() < 3 || segs.len() > MAX_SAMPLES {
return false;
}
let mut points = mesh.positions.clone();
points.extend(mesh.triangles.iter().filter_map(|t| mesh.tri(t)).map(|[a, b, c]| (a + b + c) / 3.0));
points.iter().all(|p| {
let h = (*p - origin).dot(n);
h <= tol && (h <= -depth + tol || over(&segs, flat(*p), tol))
})
}
/// How a wall swept into the body from an edge of the face meets the body along that edge, from
/// the face beside it (outward normal `n_N`) and the sweep's direction `−n_P`: `n_N · (−n_P)`
/// above zero leaves the body (the wall only touches it along the edge), zero lies in the face
/// beside it, below zero goes into the material (a real cut).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Wall {
Touches,
Cuts,
}
/// The walls of each edge of face `face` of `mesh` (by mesh edge), `None` where an edge's wall
/// changes from one to the other along it.
fn walls(mesh: &Mesh, face: usize, n: Vec3, tol: f64) -> Option<Vec<(usize, Wall)>> {
let f = u32::try_from(face).ok()?;
let mut out = Vec::new();
for e in mesh.face_edges(f) {
if mesh.seams.get(e).copied().unwrap_or(false) {
continue;
}
let other = mesh.edge_faces.get(e)?.iter().copied().find(|g| *g != f)?;
let pts = mesh.edges.get(e)?;
let tris: Vec<[Vec3; 3]> = mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g == other).filter_map(|(t, _)| mesh.tri(t)).collect();
let dist = |p: Vec3, t: &[Vec3; 3]| crate::polyhedron::point_tri(p, t[0], t[1], t[2]);
// Each point's nearest triangle of the face beside, by its normal.
let mut signs = pts.iter().filter_map(|p| {
let [a, b, c] = *tris.iter().min_by(|x, y| dist(*p, x).total_cmp(&dist(*p, y)))?;
let m = (b - a).cross(c - a).normalized()?;
Some(if -m.dot(n) >= -tol { Wall::Touches } else { Wall::Cuts })
});
let first = signs.next()?;
if signs.any(|s| s != first) {
return None;
}
out.push((e, first));
}
Some(out)
}
/// Whether `q` lies inside the outline made of `segs` (in any order and direction, by even-odd
/// crossings) or within `tol` of it.
fn over(segs: &[(Vec2, Vec2)], q: Vec2, tol: f64) -> bool {
let on = segs.iter().any(|(a, b)| {
let ab = *b - *a;
let t = if ab.len2() > 0.0 { ((q - *a).dot(ab) / ab.len2()).clamp(0.0, 1.0) } else { 0.0 };
q.dist(*a + ab * t) <= tol
});
on || segs.iter().filter(|(a, b)| (a.y > q.y) != (b.y > q.y) && q.x < a.x + (q.y - a.y) / (b.y - a.y) * (b.x - a.x)).count() % 2 == 1
}
/// Even-odd containment of `q` in the closed polygon `poly`.
fn inside(poly: &[Vec2], q: Vec2) -> bool {
let m = poly.len();
(0..m)
.filter(|i| match (poly.get(*i), poly.get((i + 1) % m)) {
(Some(a), Some(b)) => (a.y > q.y) != (b.y > q.y) && q.x < a.x + (q.y - a.y) / (b.y - a.y) * (b.x - a.x),
_ => false,
})
.count()
% 2
== 1
}
/// A tool cutting `depth` into `target` from its planar face `face`, as the face swept would, with
/// the walls that only touch the body moved off it: the face's outline keeps its edges whose walls
/// cut into the body, exactly (the body's own edges), and runs round the others well outside it.
/// The tool starts a little out from the face, so its cap does not lie on it. `None` when no wall
/// cuts (the face's prism takes only what is over it, see [`cut_takes_only_the_face`]), when an
/// edge's wall changes from touching to cutting along it, or when anything of the body within the
/// depth lies where the outline runs round. Its faces come from those of `built` (the profile's
/// own tool) they lie nearest, when given. With how far out from the face it starts.
pub fn cut_tool(target: &Body, face: WholeFace, depth: f64, built: Option<&Body>) -> Option<(Body, f64)> {
// Every step reads or builds truck's shapes, so all of it runs under the guard.
crate::guard("cut tool from a face", || Ok(build_cut_tool(target, face, depth, built))).ok().flatten()
}
fn build_cut_tool(target: &Body, face: WholeFace, depth: f64, built: Option<&Body>) -> Option<(Body, f64)> {
use mt::{BoundedCurve, ParametricCurve};
use std::f64::consts::{PI, TAU};
if !depth.is_finite() || depth <= 0.0 {
return None;
}
let size = target.size();
let tol = outline_tol(target);
let mesh = target.tessellate(tol * 0.2).ok()?;
let n = face.normal;
let walls = walls(&mesh, face.face, n, 1e-6)?;
if !walls.iter().any(|(_, w)| *w == Wall::Cuts) || !walls.iter().any(|(_, w)| *w == Wall::Touches) {
return None;
}
let f = target.solid.face_iter().nth(face.face)?.clone();
let wire = f.absolute_boundaries().first()?.clone();
// Each edge of the face's outline (in order) with its wall, matched to the mesh's edges by
// their middles.
let mid = |e: &mt::Edge| {
let c = e.curve();
let (t0, t1) = c.range_tuple();
crate::body::from_p3(c.subs((t0 + t1) * 0.5))
};
let wall_of = |e: &mt::Edge| {
let m = mid(e);
walls
.iter()
.filter_map(|(i, w)| {
let pts = mesh.edges.get(*i)?;
Some((pts.windows(2).filter_map(|s| Some(m.dist_to_segment(*s.first()?, *s.get(1)?))).fold(f64::INFINITY, f64::min), *w))
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.filter(|(d, _)| *d <= tol)
.map(|(_, w)| w)
};
let edges: Vec<(mt::Edge, Wall)> = wire.edge_iter().map(|e| wall_of(e).map(|w| (e.clone(), w))).collect::<Option<_>>()?;
// Start at a cutting edge that follows a touching one.
let k = edges.len();
let start =
(0..k).find(|i| edges.get(*i).is_some_and(|e| e.1 == Wall::Cuts) && edges.get((i + k - 1) % k).is_some_and(|e| e.1 == Wall::Touches))?;
let edges: Vec<(mt::Edge, Wall)> = edges.iter().cycle().skip(start).take(k).cloned().collect();
// The plane's frame, round the face's middle.
let origin = face_probe(&mesh, face.face)?;
let x = (if n.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).cross(n).normalized()?;
let y = n.cross(x);
let flat = |p: Vec3| Vec2::new((p - origin).dot(x), (p - origin).dot(y));
let lift = |q: Vec2| origin + x * q.x + y * q.y;
let outline: Vec<(Vec2, Vec2)> = face_outline(&mesh, face.face)
.iter()
.flat_map(|e| e.windows(2).filter_map(|w| Some((flat(*w.first()?), flat(*w.get(1)?)))).collect::<Vec<_>>())
.collect();
let centre = outline.iter().fold(Vec2::ZERO, |a, (p, _)| a + *p) / outline.len().max(1) as f64;
let far = size * 2.0;
let at = |v: &mt::Vertex| flat(crate::body::from_p3(v.point()));
// Which way the outline runs round its middle.
let turn: f64 = edges.iter().map(|(e, _)| (at(e.front()) - centre).cross(at(e.back()) - centre)).sum::<f64>().signum();
let mut out: Vec<mt::Edge> = Vec::new();
let mut detours: Vec<Vec<Vec2>> = Vec::new();
let mut i = 0;
while let Some((e, w)) = edges.get(i) {
if *w == Wall::Cuts {
out.push(e.clone());
i += 1;
continue;
}
// A run of touching edges from vertex `a` to vertex `b`: round it, far out.
let a = e.front().clone();
let mut j = i;
while edges.get(j).is_some_and(|e| e.1 == Wall::Touches) {
j += 1;
}
let b = edges.get(j - 1)?.0.back().clone();
let (pa, pb) = (at(&a), at(&b));
let (da, db) = ((pa - centre).normalized()?, (pb - centre).normalized()?);
let (t0, mut t1) = (da.y.atan2(da.x), db.y.atan2(db.x));
// Round the same way as the run, which goes from a to b with the outline.
while (t1 - t0) * turn <= 0.0 {
t1 += TAU * turn;
}
let steps = (((t1 - t0).abs() / (PI / 8.0)).ceil() as usize).clamp(2, 64);
// A polygon round a circle `far` out (its corners 1/cos(π/16) further), clear of it.
let r = far / (PI / 16.0).cos();
let mut path = vec![pa];
path.extend((0..=steps).map(|s| {
let t = t0 + (t1 - t0) * s as f64 / steps as f64;
centre + Vec2::new(t.cos(), t.sin()) * if s == 0 || s == steps { far } else { r }
}));
path.push(pb);
let mut verts = vec![a];
verts.extend(path.iter().skip(1).take(path.len().saturating_sub(2)).map(|q| mt::builder::vertex(crate::body::p3(lift(*q)))));
verts.push(b);
out.extend(verts.windows(2).filter_map(|v| Some(mt::builder::line(v.first()?, v.get(1)?))));
detours.push(path);
i = j;
}
// Nothing of the body within the depth may lie where the outline runs round.
let clear = mesh.positions.iter().all(|p| {
let h = (*p - origin).dot(n);
let q = flat(*p);
h >= tol || h <= -depth + tol || !detours.iter().any(|d| inside(d, q)) || over(&outline, q, tol)
});
if !clear {
return None;
}
let mut base = mt::Face::try_new(vec![mt::Wire::from(out)], f.surface()).ok()?;
if !f.orientation() {
base.invert();
}
// Out from the face, then swept back through it and the depth.
let off = (size * 1e-2).max(tol * 10.0);
let base = mt::builder::translated(&base, v3(n * off));
let tool = Body::new(mt::builder::tsweep(&base, v3(n * -(off + depth)))).ok()?;
match built {
Some(built) => traced_onto(tool, built, tol).ok().map(|t| (t, off)),
None => Some((tool, off)),
}
}
#[cfg(test)]
#[path = "whole_face_tests.rs"]
mod tests;
+110
View File
@@ -0,0 +1,110 @@
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use super::*;
use crate::{BoolOp, boolean, box_solid, measure};
fn square(a: Vec2, b: Vec2) -> Region2 {
let p = Vec2::new;
let line = |a: Vec2, b: Vec2| Seg2::Line { a, b };
Region2 { outer: Loop2 { segs: vec![line(a, p(b.x, a.y)), line(p(b.x, a.y), b), line(b, p(a.x, b.y)), line(p(a.x, b.y), a)] }, holes: vec![] }
}
#[test]
fn a_profile_matches_the_face_it_outlines_and_no_other() {
let block = box_solid(Vec3::ZERO, Vec3::new(20.0, 10.0, 5.0)).unwrap();
let top = Plane { origin: Vec3::new(0.0, 0.0, 5.0), ..Plane::XY };
let whole = matching_face(&block, &top, &square(Vec2::new(0.0, 0.0), Vec2::new(20.0, 10.0)), &[0.0]).unwrap();
assert!((whole.normal - Vec3::Z).len() < 1e-9 && whole.height.abs() < 1e-9, "{whole:?}");
// A smaller rectangle on the same face is not the face.
assert!(matching_face(&block, &top, &square(Vec2::new(1.0, 1.0), Vec2::new(19.0, 9.0)), &[0.0]).is_none());
// Nor is the bottom face, though its outline is the same.
assert!(matching_face(&block, &top, &square(Vec2::ZERO, Vec2::new(20.0, 10.0)), &[-3.0]).is_none());
}
#[test]
fn a_cut_over_the_whole_top_takes_only_the_face_and_a_step_beside_it_does_not() {
let block = box_solid(Vec3::ZERO, Vec3::new(20.0, 10.0, 5.0)).unwrap();
let top =
matching_face(&block, &Plane { origin: Vec3::new(0.0, 0.0, 5.0), ..Plane::XY }, &square(Vec2::ZERO, Vec2::new(20.0, 10.0)), &[0.0]).unwrap();
assert!(cut_takes_only_the_face(&block, top, 2.0));
// A step standing beside the top face: a slab would take it too.
let step = box_solid(Vec3::new(20.0, 0.0, 0.0), Vec3::new(30.0, 10.0, 8.0)).unwrap();
let stepped = boolean(&block, &step, BoolOp::Union).unwrap().unwrap();
let face = matching_face(&stepped, &Plane { origin: Vec3::new(0.0, 0.0, 5.0), ..Plane::XY }, &square(Vec2::ZERO, Vec2::new(20.0, 10.0)), &[0.0])
.unwrap();
assert!(!cut_takes_only_the_face(&stepped, face, 2.0));
}
#[test]
fn a_prism_from_the_face_joins_by_its_shared_edges() {
let block = box_solid(Vec3::ZERO, Vec3::new(20.0, 10.0, 5.0)).unwrap();
let plane = Plane { origin: Vec3::new(0.0, 0.0, 5.0), ..Plane::XY };
let region = square(Vec2::ZERO, Vec2::new(20.0, 10.0));
let face = matching_face(&block, &plane, &region, &[0.0]).unwrap();
let built = crate::extrude(&plane, std::slice::from_ref(&region), 0.0, 3.0).unwrap().remove(0);
let tool = face_prism(&block, face, 3.0, true, &built).unwrap();
assert!(tool.history().is_some_and(|h| h.operands == 1));
let joined = boolean(&block, &tool, BoolOp::Union).unwrap().unwrap();
assert!((measure(&joined).unwrap().volume - 20.0 * 10.0 * 8.0).abs() < 1e-6);
}
/// The block of #2040: one side at 45°, its top (and, with `bottom`, its bottom) rounded 3.8 mm
/// at both ends.
fn rounded_block(bottom: bool) -> Body {
let p = Vec2::new;
let line = |a: Vec2, b: Vec2| Seg2::Line { a, b };
let h = 8.485281374238571;
let segs = vec![
line(p(0.0, 0.0), p(11.514718625761429, 0.0)),
line(p(11.514718625761429, 0.0), p(20.0, h)),
line(p(20.0, h), p(0.0, h)),
line(p(0.0, h), p(0.0, 0.0)),
];
let plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
let block = crate::extrude(&plane, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], 0.0, 10.0).unwrap().remove(0);
let mut edges = vec![Vec3::new(10.0, -10.0, h), Vec3::new(10.0, 0.0, h)];
if bottom {
edges.extend([Vec3::new(5.757359312880714, 0.0, 0.0), Vec3::new(5.757359312880714, -10.0, 0.0)]);
}
crate::fillet(&block, &edges, 3.8).unwrap()
}
fn slanted_face(b: &Body) -> WholeFace {
let n = Vec3::new(1.0, 0.0, -1.0).normalized().unwrap();
let face = b.solid.face_iter().position(|f| crate::blend::plane_normal(f).is_some_and(|m| m.dot(n) > 1.0 - 1e-9)).unwrap();
WholeFace { face, normal: n, height: 0.0 }
}
/// Into the slanted face, the walls from its top edge and the rounds beside it leave the body
/// (n_N · −n_P = +0.707 down to 0), those from the end faces lie in them (0), and those from the
/// bottom edge and its rounds go into it (−0.707 up to 0): the bottom ones are kept, the others
/// moved off.
#[test]
fn the_walls_into_a_slanted_face_touch_at_the_top_and_cut_at_the_bottom() {
let b = rounded_block(true);
let face = slanted_face(&b);
let mesh = b.tessellate(outline_tol(&b) * 0.2).unwrap();
let walls = walls(&mesh, face.face, face.normal, 1e-6).unwrap();
assert_eq!(walls.iter().filter(|(_, w)| *w == Wall::Cuts).count(), 3, "{walls:?}");
assert_eq!(walls.iter().filter(|(_, w)| *w == Wall::Touches).count(), 5, "{walls:?}");
assert!(!cut_takes_only_the_face(&b, face, 3.0));
assert!(cut_tool(&b, face, 3.0, None).is_some());
}
/// A cut 3 mm into the slanted face of the block rounded along its top: the walls from the top
/// are moved off it, the one from the bottom edge kept. What it takes: in the face's plane, the
/// body's section at depth t is the block's section (10 wide, h high, its top corners rounded)
/// moved down the face by t, so the cut takes √2 ∫₀³ (10 (h − t/√2) − 2 (1 − π/4) r²) dt.
#[test]
fn a_cut_into_a_slanted_face_beside_rounds_takes_what_lies_over_it() {
let b = rounded_block(false);
let face = slanted_face(&b);
let (tool, _) = cut_tool(&b, face, 3.0, None).unwrap();
let cut = boolean(&b, &tool, BoolOp::Cut).unwrap().unwrap();
assert!(cut.validity().is_empty(), "{:?}", cut.validity());
let (h, r, d, w) = (8.485281374238571_f64, 3.8_f64, 3.0_f64, 10.0_f64);
let corner = (1.0 - std::f64::consts::FRAC_PI_4) * r * r;
let taken = 2f64.sqrt() * w * h * d - w * d * d / 2.0 - 2.0 * 2f64.sqrt() * corner * d;
let v = measure(&b).unwrap().volume - measure(&cut).unwrap().volume;
assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}");
}
+1 -1
View File
@@ -278,7 +278,7 @@ pub fn tool_definitions() -> Vec<Value> {
json!({
"path": string("Path to a .solidcraft, .step/.stp, .3mf, .stl, .obj, .glb or .gltf file"),
"up": {"type": "string", "enum": ["z", "y"], "description": "STEP and glTF only. z keeps the file's axes; y: the file has Y up, so its bodies are turned to stand Z up. Default: z for STEP, y for glTF"},
"names": {"type": "string", "enum": ["solid", "product", "instance"], "description": "STEP only. What the bodies are named after: solid (default: the solid's own name, else the product's), product (the part's name) or instance (the assembly occurrence's name, else the product's)"},
"names": {"type": "string", "enum": ["auto", "solid", "product", "instance"], "description": "STEP, IGES, FreeCAD and Rhino. auto (default): keep meaningful solid names, replace placeholders with the nearest named product, else instance; solid: the solid's own name; product: the part's name; instance: the occurrence's name, else the product's"},
}),
&["path"],
),
+110
View File
@@ -0,0 +1,110 @@
//! Written help for the ribbon tooltips (#2758): two short sentences per command, shown under the
//! tool name when the pointer rests on its button. The names stay as they are; the help is a
//! separate message `help.<command id>` in the catalogs, so a tool without help keeps its plain
//! tooltip. Text only for now; pictures and animations are a later step.
use crate::i18n;
/// The commands that have help text. Each one needs a `help.<id>` message in the catalogs.
pub const DOCUMENTED: &[&str] = &["solid.chamfer", "solid.extrude", "solid.fillet", "solid.hole", "solid.revolve", "solid.shell"];
/// The help for `command` in the current language, when it has any.
pub fn help(command: &str) -> Option<String> {
if !DOCUMENTED.contains(&command) {
return None;
}
let key = format!("help.{command}");
let text = i18n::tr(&key);
(text != key).then(|| text.to_owned())
}
/// Show `tip` (name, shortcut or the reason the tool is disabled) as the tooltip of `response`,
/// with the help of `command` below it when there is one.
pub fn tooltip(response: egui::Response, command: &str, tip: String) -> egui::Response {
let help = help(command);
response.on_hover_ui(move |ui| {
ui.set_max_width(300.0);
ui.label(tip);
if let Some(help) = help {
ui.add_space(2.0);
ui.label(egui::RichText::new(help).weak());
}
})
}
/// The tooltip text of a panel drop-down row: the reason when the command is disabled (as before),
/// else its name and shortcut when it has help to show below, else nothing (no tooltip).
pub fn row_tip(command: &str, label: &str, shortcut: Option<&str>, disabled_reason: Option<&str>) -> Option<String> {
match disabled_reason {
Some(why) => Some(why.to_owned()),
None => help(command).map(|_| format!("{label}{}", shortcut.map(|k| format!(" ({k})")).unwrap_or_default())),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn catalog(source: &str) -> std::collections::BTreeMap<String, String> {
serde_json::from_str(source).unwrap()
}
/// The `help.<command id>` messages; other `help.*` messages (About dialog texts) are not tool help.
fn help_keys(source: &str) -> BTreeSet<String> {
catalog(source)
.into_keys()
.filter(|k| k.starts_with("help.") && (k.starts_with("help.solid.") || DOCUMENTED.contains(&&k["help.".len()..])))
.collect()
}
#[test]
fn every_documented_id_is_a_real_command() {
for id in DOCUMENTED {
assert!(solidcraft_engine::find_command(id).is_some(), "{id} is not a command");
}
assert!(DOCUMENTED.is_sorted());
}
#[test]
fn every_help_message_is_for_a_documented_command() {
let english = help_keys(include_str!("../../../assets/locales/en.json"));
let wanted: BTreeSet<String> = DOCUMENTED.iter().map(|id| format!("help.{id}")).collect();
assert_eq!(english, wanted);
for key in &english {
let id = key.trim_start_matches("help.");
assert!(solidcraft_engine::find_command(id).is_some(), "{key} names no command");
}
}
#[test]
fn the_help_texts_are_written_in_every_complete_language() {
let english = catalog(include_str!("../../../assets/locales/en.json"));
for source in [include_str!("../../../assets/locales/ja.json"), include_str!("../../../assets/locales/zh.json")] {
let other = catalog(source);
for (key, text) in english.iter().filter(|(k, _)| k.starts_with("help.")) {
let translated = other.get(key).unwrap_or_else(|| panic!("{key} missing"));
assert!(!translated.trim().is_empty());
// The messages carry no placeholders; if one is added, both sides must agree.
assert_eq!(text.matches('{').count(), translated.matches('{').count(), "{key}");
}
}
}
#[test]
fn a_documented_command_has_help_and_others_do_not() {
for id in DOCUMENTED {
assert!(help(id).is_some_and(|text| !text.is_empty()), "{id}");
}
assert!(help("solid.pattern.rectangular").is_none());
}
#[test]
fn a_drop_down_row_has_a_tip_only_for_a_reason_or_help() {
assert_eq!(row_tip("solid.shell", "Shell", Some("S"), None).as_deref(), Some("Shell (S)"));
assert_eq!(row_tip("solid.hole", "Hole", None, None).as_deref(), Some("Hole"));
assert_eq!(row_tip("solid.hole", "Hole", None, Some("Select a face")).as_deref(), Some("Select a face"));
assert_eq!(row_tip("solid.pattern.rectangular", "Pattern", None, None), None);
assert_eq!(row_tip("solid.pattern.rectangular", "Pattern", None, Some("No body")).as_deref(), Some("No body"));
}
}
+156 -2
View File
@@ -66,6 +66,10 @@ pub struct Documents {
pub choice: Answer,
/// Save As is answering a Close/Quit prompt.
saving: bool,
/// Close Other Tabs / Close All Tabs: the tabs still to close, in increasing order (the last
/// is closed first, so the ones before it keep their places). One with unsaved changes asks
/// first; Cancel stops the rest (#3055).
pub batch: Option<Vec<usize>>,
}
impl Documents {
@@ -307,6 +311,37 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
}
}
/// Close several tabs (Close Other Tabs, Close All Tabs; #3055): each with unsaved changes asks
/// "save changes?" in turn, the others close; Cancel keeps the tabs not closed yet.
pub fn close_many(app: &mut SolveApp, mut tabs: Vec<usize>) {
if app.file_picker_busy() || app.docs.quitting.is_some() || app.docs.closing.is_some() {
return;
}
tabs.sort_unstable();
tabs.dedup();
app.docs.batch = Some(tabs);
batch_step(app);
}
/// Close the next tab of [`close_many`]'s batch, or ask about it when it has unsaved changes.
fn batch_step(app: &mut SolveApp) {
if app.docs.closing.is_some() || app.docs.quitting.is_some() {
return;
}
let Some(mut left) = app.docs.batch.take() else { return };
while let Some(i) = left.pop() {
if i >= app.docs.count() {
continue;
}
if dirty(app, i) {
app.docs.closing = Some(i);
app.docs.batch = Some(left);
return;
}
close(app, i, true);
}
}
/// Quit (the title bar's ×, File ▸ Quit, the window being closed): asks "save changes?" for each
/// design with unsaved changes, then sets `app.quit_requested` for the host to close the window.
/// Cancel, or a Save that didn't save, stays open.
@@ -371,13 +406,19 @@ pub fn tabs_info(app: &SolveApp) -> Vec<(String, bool)> {
}
/// The control channel's `ui.documents`: list, `new`, `switch`, `close` (by index; `force`
/// closes without asking) or `quit`; `answer`: save | dont_save | cancel answers "save changes?".
/// closes without asking), `close_others` (all but `index`), `close_all` or `quit`; `answer`:
/// save | dont_save | cancel answers "save changes?".
pub fn control(app: &mut SolveApp, p: &Value) -> Value {
let i = p.get("index").and_then(Value::as_u64).map(|x| x as usize);
match p.get("action").and_then(Value::as_str) {
Some("new") => new_design(app),
Some("switch") => switch(app, i.unwrap_or(app.docs.active)),
Some("close") => close(app, i.unwrap_or(app.docs.active), p.get("force").and_then(Value::as_bool).unwrap_or(false)),
Some("close_others") => {
let keep = i.unwrap_or(app.docs.active);
close_many(app, (0..app.docs.count()).filter(|k| *k != keep).collect());
}
Some("close_all") => close_many(app, (0..app.docs.count()).collect()),
Some("quit") => request_quit(app),
_ => {}
}
@@ -391,7 +432,7 @@ pub fn control(app: &mut SolveApp, p: &Value) -> Value {
answer_prompt(app, a);
}
let tabs: Vec<Value> = tabs_info(app).into_iter().map(|(name, dirty)| json!({"name": name, "dirty": dirty})).collect();
json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing, "quitting": app.docs.quitting.is_some()})
json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing, "quitting": app.docs.quitting.is_some(), "batch": app.docs.batch})
}
/// The document tabs in the application bar, from `x0` to `x1`: click to switch, × to close,
@@ -416,6 +457,9 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
})
.collect();
let fit_all = shown == n && natural.iter().sum::<f32>() <= x1 - x0 - 34.0;
// A right-click on a tab opens its menu (#3055): the tab and where the menu opens.
let menu_id = egui::Id::new("sc_doc_tab_menu");
let mut menu: Option<(usize, egui::Pos2)> = ui.ctx().data(|d| d.get_temp(menu_id));
for (i, (name, dirty)) in info.iter().enumerate().skip(first).take(shown) {
let w = if fit_all { natural.get(i).copied().unwrap_or(w) } else { w };
let tr = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(w - 2.0, r.height() - 8.0));
@@ -460,6 +504,9 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
if xresp.hovered() || xresp.has_focus() { 1.0 } else { 0.55 },
);
}
if resp.secondary_clicked() {
menu = Some((i, ui.ctx().pointer_interact_pos().unwrap_or(tr.left_bottom())));
}
if xresp.on_hover_text("Close").clicked() {
shut = Some(i);
} else if resp.on_hover_text(name.as_str()).clicked() {
@@ -505,6 +552,44 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
}
x += 26.0;
}
// The tab's menu: close it, the others, or all of them.
let mut many: Option<Vec<usize>> = None;
if let Some((i, at)) = menu.filter(|(i, _)| *i < n) {
let items = [("ui.closeTab", "Close"), ("ui.closeOtherTabs", "Close Other Tabs"), ("ui.closeAllTabs", "Close All Tabs")];
let resp = crate::frame::area(menu_id.with("area")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
crate::context_menu::menu_frame(ui, |ui| {
let mut chosen = None;
for (id, label) in items {
let mut item = crate::context_menu::Item::action(id, label, "");
item.enabled = id != "ui.closeOtherTabs" || n > 1;
let top = ui.cursor().top();
let row = crate::context_menu::list_row(ui, &item);
let rect = Rect::from_min_max(pos2(ui.min_rect().left(), top), pos2(ui.min_rect().right(), ui.cursor().top()));
crate::scenario::publish_handle(&format!("tab_menu:{id}"), rect.center());
if row.is_some() {
chosen = Some(id);
}
}
chosen
})
});
match resp.inner {
Some("ui.closeTab") => shut = Some(i),
Some("ui.closeOtherTabs") => many = Some((0..n).filter(|k| *k != i).collect()),
Some("ui.closeAllTabs") => many = Some((0..n).collect()),
_ => {}
}
let pressed_outside = ui.ctx().input(|x| x.pointer.any_pressed()) && !resp.response.contains_pointer();
menu = if resp.inner.is_some() || pressed_outside || ui.ctx().input(|x| x.key_pressed(egui::Key::Escape)) { None } else { Some((i, at)) };
}
ui.ctx().data_mut(|d| match menu {
Some(m) => {
d.insert_temp(menu_id, m);
}
None => {
d.remove::<(usize, egui::Pos2)>(menu_id);
}
});
// New design: a new tab showing the start page.
let pr = Rect::from_center_size(pos2(x + 14.0, r.center().y), vec2(22.0, 22.0));
let presp = ui.interact(pr, ui.id().with("doc_new"), Sense::click());
@@ -524,6 +609,7 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
}
match (shut, pick) {
_ if !idle => {}
_ if many.is_some() => close_many(app, many.unwrap_or_default()),
(Some(i), _) => close(app, i, false),
(None, Some(i)) => switch(app, i),
(None, None) => {}
@@ -653,9 +739,11 @@ pub fn answer_prompt(app: &mut SolveApp, answer: Answer) {
Answer::Cancel => {
app.docs.closing = None;
app.docs.quitting = None;
app.docs.batch = None;
}
}
quit_step(app);
batch_step(app);
}
/// Resume Close/Quit only after Save As has answered and the write succeeded.
@@ -671,8 +759,12 @@ pub(crate) fn saved_for_close(app: &mut SolveApp) {
}
} else if saved {
close(app, app.docs.active, true);
} else {
// Save As cancelled: the tabs not closed yet stay.
app.docs.batch = None;
}
quit_step(app);
batch_step(app);
}
#[cfg(test)]
@@ -732,6 +824,68 @@ mod tests {
}
}
/// Four tabs: the first and third with a part (unsaved), the second and fourth blank.
fn four_tabs() -> SolveApp {
let mut a = app();
a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
open_tab(&mut a, Session::default()).unwrap();
open_tab(&mut a, Session::default()).unwrap();
a.run("solid.box", json!({"length": 5, "width": 5, "height": 5})).unwrap();
open_tab(&mut a, Session::default()).unwrap();
assert_eq!(a.docs.count(), 4);
a
}
/// #3055: Close Other Tabs on the first tab closes the blank fourth, asks about the third
/// (unsaved), and after Don't Save closes it and the blank second: the first is left, its
/// part as it was.
#[test]
fn close_other_tabs_asks_about_unsaved_ones_and_keeps_the_tab() {
let mut a = four_tabs();
let v = control(&mut a, &json!({"action": "close_others", "index": 0}));
assert_eq!(v["closing"], json!(2), "{v}");
assert_eq!(a.docs.count(), 3, "the blank fourth closed without asking");
let v = control(&mut a, &json!({"answer": "dont_save"}));
assert_eq!(a.docs.count(), 1, "{v}");
assert!(a.docs.closing.is_none() && a.docs.batch.is_none());
let volume = a.run("inspect.measure", json!({})).unwrap()["total"]["volume_mm3"].as_f64().unwrap();
assert!((volume - 1000.0).abs() < 1e-6, "the first tab's part: {volume}");
}
/// #3055: Close All Tabs, and Cancel at the first question: the tabs not closed yet stay.
/// Close All Tabs on blank designs leaves one blank design.
#[test]
fn close_all_tabs_stops_at_cancel_and_ends_on_a_blank_design() {
let mut a = four_tabs();
control(&mut a, &json!({"action": "close_all"}));
assert_eq!((a.docs.count(), a.docs.closing), (3, Some(2)));
control(&mut a, &json!({"answer": "cancel"}));
assert_eq!((a.docs.count(), a.docs.closing, a.docs.batch.clone()), (3, None, None), "Cancel keeps the rest");
control(&mut a, &json!({"action": "close_all"}));
control(&mut a, &json!({"answer": "dont_save"}));
control(&mut a, &json!({"answer": "dont_save"}));
assert_eq!(a.docs.count(), 1);
assert!(active_is_blank(&a), "all closed: a blank design");
}
/// #3055: a right-click on a tab opens its menu; Close Other Tabs from it keeps that tab.
#[test]
fn a_tab_right_clicked_offers_close_others() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.custom_titlebar = true;
h.call("ui.resize", json!({"width": 1400, "height": 720}));
open_tab(&mut h.app, Session::default()).unwrap();
open_tab(&mut h.app, Session::default()).unwrap();
assert_eq!(h.app.docs.count(), 3);
h.frames(3);
h.step(&json!({"click": {"handle": "tab:design:1"}, "button": "right"})).unwrap();
h.frames(2);
h.step(&json!({"click": {"handle": "tab_menu:ui.closeOtherTabs"}})).unwrap();
h.frames(2);
assert_eq!(h.app.docs.count(), 1);
}
#[test]
fn tabs_keep_their_own_design() {
let mut a = app();
+16
View File
@@ -35,6 +35,9 @@ static ENGLISH: Catalog = Catalog::new("en", "English", include_str!(concat!(env
static CZECH: Catalog = Catalog::new("cs", "Čeština", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/cs.json")));
static DANISH: Catalog = Catalog::new("da", "Dansk", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/da.json")));
static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/de.json")));
/// English in UK spelling: only the messages whose spelling differs; the rest are English's.
static ENGLISH_UK: Catalog =
Catalog::new("en-GB", "English (UK)", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/en-GB.json")));
static SPANISH: Catalog = Catalog::new("es", "Español (España)", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/es.json")));
static FRENCH: Catalog = Catalog::new("fr", "Français", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/fr.json")));
static GALICIAN: Catalog = Catalog::new("gl", "Galego", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/gl.json")));
@@ -66,6 +69,7 @@ pub const LOCALES: &[&Catalog] = &[
&CZECH,
&DANISH,
&GERMAN,
&ENGLISH_UK,
&SPANISH,
&FRENCH,
&GALICIAN,
@@ -252,6 +256,18 @@ mod tests {
assert!(ids[1..].is_sorted(), "{ids:?}");
}
/// English (UK) holds only the messages it spells differently; the rest are English's.
#[test]
fn english_uk_shows_uk_spelling_and_english_for_the_rest() {
let (uk, english) = (Locale::for_language("en-GB"), Locale::for_language("en"));
assert_eq!(uk.name(), "English (UK)");
assert_eq!(uk.label("Center X"), "Centre X");
assert_eq!(uk.label("Customize Toolbar"), "Customise Toolbar");
assert_eq!(uk.text("ui.design_properties.organization"), "Organisation");
assert_eq!(uk.text("ui.preferences"), english.text("ui.preferences"));
assert_eq!(english.label("Center X"), "Center X");
}
/// A new design's name, `Untitled`, is a default name like `Sketch1`: it shows translated
/// wherever the design's name is shown (the tab, the browser's top row, the save prompt).
#[test]
+4
View File
@@ -20,6 +20,7 @@ pub mod canvas;
mod colour_tokens_tests;
#[cfg(test)]
mod colour_vision_tests;
pub mod command_help;
pub mod context_menu;
pub mod control;
pub mod crash_ui;
@@ -427,6 +428,8 @@ pub struct SolveApp {
pub last_command: Option<(String, String)>,
/// Esc was already acted on this frame (by the shortcuts), so later handlers skip it.
pub esc_handled: bool,
/// When Esc was last pressed (input time): a second press soon after finishes the sketch.
pub last_escape: Option<f64>,
pub status: Option<(String, f64, bool)>,
pub quit_requested: bool,
/// Autosave into the recovery folder (the desktop app turns it on).
@@ -555,6 +558,7 @@ impl SolveApp {
apply_gate: None,
last_command: None,
esc_handled: false,
last_escape: None,
status: None,
quit_requested: false,
autosave: None,
+1 -1
View File
@@ -346,7 +346,7 @@ mod tests {
"claims",
"moisture_unit",
];
for catalog in crate::i18n::LOCALES {
for catalog in crate::i18n::LOCALES.iter().filter(|c| c.id != "en-GB") {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/locales").join(format!("{}.json", catalog.id));
let messages: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
for key in keys.into_iter().chain(KINDS) {
+7 -3
View File
@@ -268,9 +268,9 @@ fn motion_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option
Some(h.finish())
}
/// Tool previews already stream in `poll`. New-body extrusions and face offsets instead
/// build their full result; allow successful intermediate distances only within the same
/// setup. Preserve selections, feature identity, extent mode, revision and colours. The
/// Tool previews already stream in `poll`. New-body extrusions, face offsets, fillets and
/// chamfers instead build their full result; allow successful intermediate distances (a
/// fillet's radii, a chamfer's distances and angle) only within the same setup. Preserve selections, feature identity, extent mode, revision and colours. The
/// exact job key still ensures the latest distance is computed and errors never go stale.
fn stream_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option<u64> {
if let Some(key) = motion_key(cmds, revision, colors) {
@@ -290,6 +290,10 @@ fn stream_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option
let fields: &[&str] = match command.as_str() {
"solid.extrude" => &["distance", "distance2", "to_offset"],
"solid.offset_face" => &["distance"],
// A radius dragged or typed: each result shows while the next computes, rather than
// the preview holding still until the input stops changing.
"solid.fillet" => &["radius", "radius2", "mid"],
"solid.chamfer" => &["distance", "distance2", "angle"],
_ => return None,
};
let params = params.as_object_mut()?;
+29 -1
View File
@@ -116,7 +116,11 @@ fn distance_stream_keeps_feature_selection_and_extent_context() {
assert!(stream_key(&offset(1), 7, colors()).is_some());
assert_eq!(stream_key(&offset(1), 7, colors()), stream_key(&offset(5), 7, colors()));
assert!(stream_key(&[], 7, colors()).is_none());
assert!(stream_key(&[("solid.fillet".into(), json!({"radius":5}))], 7, colors()).is_none());
let fillet = |radius| vec![("solid.fillet".into(), json!({"edges":[[5,0,10]],"radius":radius}))];
assert_eq!(stream_key(&fillet(1), 7, colors()), stream_key(&fillet(2), 7, colors()));
let other_edge = vec![("solid.fillet".into(), json!({"edges":[[0,5,10]],"radius":1}))];
assert_ne!(stream_key(&fillet(1), 7, colors()), stream_key(&other_edge, 7, colors()), "other edges are another setup");
assert!(stream_key(&[("solid.shell".into(), json!({"thickness":1}))], 7, colors()).is_none());
let mut mixed = old;
mixed.push(("select.clear".into(), json!({})));
assert!(stream_key(&mixed, 7, colors()).is_none());
@@ -147,3 +151,27 @@ fn distance_results_reject_other_setups_and_obsolete_errors() {
assert!(as_new_body(&join).is_some());
assert!(stream_key(&join, s.revision, colors()).is_none(), "tool previews retain their upstream streaming path");
}
/// A fillet's radius dragged (or typed): each finished preview shows while the next radius
/// computes, as a distance does; before, every one was dropped until the radius held still.
#[test]
fn fillet_preview_keeps_moving_while_its_radius_changes() {
let mut s = Session::new(solidcraft_engine::doc::Document::new("Fillet drag"));
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
let fillet = |radius: i32| vec![("solid.fillet".into(), json!({"edges":[[5,0,10]],"radius":f64::from(radius) / 4.0}))];
let mut pv = PreviewState::default();
for (finished, wanted) in [(4, 6), (6, 8), (8, 8)] {
let (old, new) = (fillet(finished), fillet(wanted));
let old_key = job_key(&old, s.revision, colors());
let key = job_key(&new, s.revision, colors());
let (tx, rx) = std::sync::mpsc::channel();
pv.running = Some((old_key, rx));
pv.running_tool = false;
pv.running_stream_key = stream_key(&old, s.revision, colors());
tx.send(compute(&s, &old, colors())).unwrap();
pv.poll(key, stream_key(&new, s.revision, colors()));
assert!(pv.active && pv.error.is_none(), "the finished radius shows during the drag: {:?}", pv.error);
assert_eq!(pv.shown_key, Some(old_key));
assert!(pv.running.is_none());
}
}
+92
View File
@@ -43,6 +43,18 @@ pub fn has_geometry(sketch: &solidcraft_engine::sketch::Sketch) -> bool {
!sketch.curves.is_empty() || !sketch.wires.is_empty()
}
/// Nothing the user added: no curve, 3D curve, constraint or dimension, pattern, projection,
/// text or offset, and no point but the fixed origin.
pub fn is_blank(sketch: &solidcraft_engine::sketch::Sketch) -> bool {
!has_geometry(sketch)
&& sketch.constraints.is_empty()
&& sketch.patterns.is_empty()
&& sketch.links.is_empty()
&& sketch.offsets.is_empty()
&& sketch.freeform_offsets.is_empty()
&& sketch.points.iter().all(|p| p.id == "origin")
}
/// The degrees-of-freedom line: "No geometry yet" (an empty sketch only has its fixed origin),
/// "Fully constrained" or "N degrees of freedom".
pub fn dof_text(dof: usize, ok: bool, has_geometry: bool) -> String {
@@ -951,4 +963,84 @@ mod tests {
h.settle();
assert_ne!(line(&h), "No geometry yet", "a drawn rectangle is geometry");
}
/// One Esc cancels the tool and keeps the sketch open; two quick presses finish it (the
/// sketch is kept and listed in the timeline), also when the first press cancelled a tool.
#[test]
fn escape_cancels_then_a_quick_second_escape_finishes_the_sketch() {
let apart = |h: &mut crate::scenario::Harness| h.frames(60);
let mut h = crate::scenario::Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]})).unwrap();
h.settle();
h.step(&json!({"start":"sketch.line"})).unwrap();
assert!(h.app.tool.is_some());
h.step(&json!({"key":"Escape"})).unwrap();
assert!(h.app.tool.is_none(), "one Esc puts the tool down");
apart(&mut h);
h.step(&json!({"key":"Escape"})).unwrap();
apart(&mut h);
assert!(h.app.session.active_sketch.is_some(), "single presses keep the sketch open");
// A quick double press finishes and keeps the sketch.
h.step(&json!({"key":"Escape"})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
h.settle();
assert!(h.app.session.active_sketch.is_none(), "a double Esc finishes the sketch");
assert_eq!(h.app.ui.tab, "MODEL");
assert!(crate::scenario::handle_at("timeline:Sketch1").is_some(), "the sketch is kept");
let sketch = h.app.session.execute("sketch.inspect", &json!({"sketch": 1})).unwrap();
assert_eq!(sketch["curves"].as_array().map(Vec::len), Some(4), "{sketch}");
// With a tool active: the first press cancels it, the second finishes.
h.step(&json!({"click": {"handle": "timeline:Sketch1"}, "double": true})).unwrap();
h.settle();
assert!(h.app.session.active_sketch.is_some());
apart(&mut h);
h.step(&json!({"start":"sketch.circle.center"})).unwrap();
assert!(h.app.tool.is_some());
h.step(&json!({"key":"Escape"})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
h.settle();
assert!(h.app.tool.is_none() && h.app.session.active_sketch.is_none());
assert!(crate::scenario::handle_at("timeline:Sketch1").is_some());
// Outside a sketch a double Esc only clears, as before.
h.step(&json!({"key":"Escape"})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
assert!(h.app.session.active_sketch.is_none());
}
/// A double Esc on a sketch left blank takes it away (no empty sketch in the browser or the
/// timeline, one undo step) and puts the view back; one line is enough to keep it.
#[test]
fn a_quick_double_escape_discards_a_blank_sketch_and_keeps_one_with_a_line() {
let mut h = crate::scenario::Harness::new();
h.call("ui.view", json!({"view": "iso"}));
h.frames(3);
let undo = h.app.session.undo.len();
h.step(&json!({"start":"sketch.create"})).unwrap();
h.step(&json!({"click": {"plane": "XY"}})).unwrap();
h.settle();
assert!(h.app.session.active_sketch.is_some());
h.step(&json!({"key":"Escape"})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
h.settle();
crate::scenario::check(&mut h, &json!({"sketching": false, "camera": {"yaw_deg": -45, "pitch_deg": 35}})).unwrap();
assert_eq!(h.app.ui.tab, "MODEL");
assert!(h.app.session.doc.features.is_empty(), "the blank sketch is gone");
assert!(crate::scenario::handles("timeline:").iter().all(|(n, _)| !n.contains("Sketch")));
assert!(crate::scenario::handle_at("row:Sketch1").is_none());
assert!(h.app.session.undo.len() <= undo + 2, "creating and discarding are at most two steps");
h.step(&json!({"start":"sketch.create"})).unwrap();
h.step(&json!({"click": {"plane": "XY"}})).unwrap();
h.app.run("sketch.line", json!({"points": [[0, 0], [30, 10]]})).unwrap();
h.settle();
h.step(&json!({"key":"Escape"})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
h.settle();
assert!(h.app.session.active_sketch.is_none());
assert_eq!(h.app.session.doc.features.len(), 1, "a sketch with a line is kept");
assert!(crate::scenario::handles("timeline:").iter().any(|(n, _)| n.contains("Sketch")));
}
}
+10 -1
View File
@@ -67,9 +67,12 @@ fn a_sketch_key_with_nothing_selected_asks_for_the_plane_then_starts_the_tool()
assert!(h.app.dialog.is_none());
}
/// #1804: Escape puts down the sketch tool. Another Escape does not leave the sketch.
/// #1804: Escape puts down the sketch tool. Another Escape, on its own, does not leave the
/// sketch; only a quick second press does (and finishes it, keeping it).
#[test]
fn a_second_escape_stays_in_the_sketch() {
// Presses further apart than the double-press window.
let pause = |h: &mut Harness| h.frames((crate::toolbar::ESCAPE_TWICE_S * 60.0) as usize + 6);
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
let sketch = h.app.session.active_sketch;
@@ -79,15 +82,21 @@ fn a_second_escape_stays_in_the_sketch() {
press(&mut h, "Escape");
assert!(tool(&h).is_none(), "the first Escape puts the tool down");
assert_eq!(h.app.session.active_sketch, sketch);
pause(&mut h);
press(&mut h, "Escape");
assert_eq!(h.app.session.active_sketch, sketch, "a second Escape stays in the sketch");
h.app.run("sketch.line", json!({"points":[[0,0],[10,0]]})).unwrap();
h.app.session.selection = vec![Sel::SketchCurve { id: "l1".into() }];
pause(&mut h);
press(&mut h, "Escape");
assert!(h.app.session.selection.is_empty(), "Escape still clears a selection");
assert_eq!(h.app.session.active_sketch, sketch);
pause(&mut h);
press(&mut h, "Escape");
assert_eq!(h.app.session.active_sketch, sketch, "Escape after clearing the selection stays in the sketch");
press(&mut h, "Escape");
assert!(h.app.session.active_sketch.is_none(), "a quick second Escape finishes the sketch");
assert_eq!(h.app.session.doc.features.len(), 1, "and keeps it");
}
#[test]
+53 -10
View File
@@ -1005,11 +1005,13 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
})
.collect();
let right = r.right();
// Keep the Sketch exit group at the far right and reachable at every width.
// The Sketch exit group follows the last sketch panel, and is always kept room for so it
// stays reachable at every width; when panels go into the » menu it moves to the far right.
let finish_plan = sizes.last().filter(|_| tab == "SKETCH").map(Plan::full);
let finish_width = finish_plan.as_ref().map_or(0.0, |p| p.width().max(80.0));
let normal_count = sizes.len().saturating_sub(usize::from(finish_plan.is_some()));
let mut plans = fit(sizes.get(..normal_count).unwrap_or_default(), right - 4.0 - px - finish_width);
let finish_right = plans.iter().any(|p| p.fit == Fit::More);
plans.extend(finish_plan);
crate::scenario::publish_count("toolbar_collapsed", plans.iter().filter(|p| p.fit == Fit::Collapsed).count() as f64);
crate::scenario::publish_count("toolbar_more", plans.iter().filter(|p| p.fit == Fit::More).count() as f64);
@@ -1027,7 +1029,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
let trimmed = plan.shown < plan.widths.len();
let finish = tab == "SKETCH" && *panel == "FINISH SKETCH";
let width = if finish { finish_width } else { plan.width() };
if finish {
if finish && finish_right {
px = right - finish_width;
painter.line_segment([pos2(px, top), pos2(px, r.bottom() - 1.0)], Stroke::new(1.0, t.border));
}
@@ -1529,6 +1531,10 @@ fn ribbon_tooltip(app: &SolveApp, response: egui::Response, command: &solidcraft
if let Some(description) = description {
ui.add(egui::Label::new(description).wrap());
}
// The tool's own help, when it has one (#2758).
if let Some(help) = crate::command_help::help(command.id) {
ui.add(egui::Label::new(egui::RichText::new(help).color(t.text_dim)).wrap());
}
if let Some(reason) = disabled {
ui.add(egui::Label::new(egui::RichText::new(reason).color(t.text_dim)).wrap());
}
@@ -1630,9 +1636,11 @@ fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solidcraft_engine
if pin.on_hover_text(tip).clicked() {
return RowAction::TogglePin;
}
let resp = match &info.disabled_reason {
Some(why) if !info.enabled => resp.on_hover_text(why),
_ => resp,
// A disabled row says why; a documented command also shows its help (#2758).
let reason = info.disabled_reason.as_deref().filter(|_| !info.enabled);
let resp = match crate::command_help::row_tip(c.id, &label, c.shortcut, reason) {
Some(tip) => crate::command_help::tooltip(resp, c.id, tip),
None => resp,
};
if resp.secondary_clicked() {
return RowAction::Menu(resp.interact_pointer_pos().unwrap_or(rect.left_bottom()));
@@ -1777,6 +1785,9 @@ pub(crate) fn selection_filter(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
/// Two Esc presses within this many seconds finish the open sketch.
pub const ESCAPE_TWICE_S: f64 = 0.4;
/// Global keyboard shortcuts (when no text field has focus).
pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
if app.file_load.busy() {
@@ -1805,6 +1816,10 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
}
for k in keys {
use egui::Key;
// Only Esc pressed twice in a row finishes a sketch: any other key in between resets it.
if k != Key::Escape {
app.last_escape = None;
}
if shown && crate::navigation::view_key(app, ctx, k, mods) {
continue;
}
@@ -1835,6 +1850,29 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
match k {
Key::Escape => {
app.esc_handled = true;
// Two quick presses in a sketch finish it (keeping it, like Finish Sketch);
// the first one has already cancelled whatever was active.
let now = ctx.input(|i| i.time);
let again = app.last_escape.is_some_and(|t| now - t <= ESCAPE_TWICE_S);
app.last_escape = Some(now);
// Not while a tool is still out (its first Esc only ended a chain): this press
// puts it down, and the next quick one finishes.
if again
&& app.tool.is_none()
&& app.dialog.is_none()
&& let Some(sketch) = app.session.active_sketch
{
app.last_escape = None;
// A sketch left blank is taken away instead, in one undo step, so no empty
// sketch stays behind.
let blank = app.session.doc.sketch(sketch).is_ok_and(crate::sketch_palette::is_blank);
let depth = app.session.undo.len();
app.finish_sketch();
if blank && app.session.active_sketch.is_none() && app.run("timeline.delete", json!({ "feature": sketch.to_string() })).is_ok() {
app.session.undo.truncate(depth + 1);
}
continue;
}
if crate::sketch_text_move::cancel_drag(app) {
continue;
}
@@ -1848,8 +1886,8 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
app.tree.picked_joints.clear();
let _ = app.run("select.clear", json!({}));
}
// An open sketch with nothing left to cancel stays open (#1804). Finish Sketch
// is what leaves it, so a second Escape after putting a tool down is harmless.
// A single Esc with nothing left to cancel keeps the sketch open (#1804); a quick
// second press finishes it (above).
}
Key::S if !mods.shift && !mods.alt => {
app.start("ui.palette");
@@ -2375,7 +2413,7 @@ mod home_tests {
fn needs_design_tooltip_is_translated() {
let english = crate::i18n::Locale::for_language("en").text("ui.needs_design");
assert!(english.contains("{command}"));
for catalog in crate::i18n::LOCALES.iter().filter(|c| c.id != "en") {
for catalog in crate::i18n::LOCALES.iter().filter(|c| !matches!(c.id, "en" | "en-GB")) {
let text = crate::i18n::Locale::for_language(catalog.id).text("ui.needs_design");
assert!(text != english && text.contains("{command}"), "{} must translate ui.needs_design", catalog.id);
}
@@ -2738,7 +2776,8 @@ mod fill_width_tests {
let at = format!("{tab} at {w}");
let bar = crate::scenario::rect_of("toolbar").unwrap();
let finish = crate::scenario::rect_of("panel:SKETCH:FINISH SKETCH");
let right = finish.map_or(bar.right() - 4.0, |f| f.left() - 4.0);
// The room for the other panels: the bar less the Finish group kept for it.
let right = bar.right() - 4.0 - finish.map_or(0.0, |f| f.width() + 4.0);
let count = |name: &str| crate::scenario::count_of(name).unwrap_or(-1.0);
let (collapsed, trimmed, more) = (count("toolbar_collapsed"), count("toolbar_trimmed"), count("toolbar_more"));
let lists = crate::toolbar_custom::panel_lists(tab);
@@ -2785,7 +2824,11 @@ mod fill_width_tests {
}
if sketch {
let finish = finish.expect("Finish group stays visible");
assert_eq!(finish.right(), bar.right(), "{at}: Finish stays anchored right");
if more > 0.0 {
assert_eq!(finish.right(), bar.right(), "{at}: Finish moves right of the » menu");
} else {
assert!((finish.left() - end).abs() < 0.5, "{at}: Finish follows the last panel ({} vs {end})", finish.left());
}
assert!(crate::scenario::handle_at("button:sketch.finish").is_some(), "{at}: Finish stays reachable");
}
// The assistant lives in the title bar; visible ribbon tabs fit before search.
+19
View File
@@ -91,6 +91,8 @@ pub const SECTIONS: &[Section] = &[
},
Section { tab: "MODEL", panel: "CONFIGURE", promoted: 2, ids: CONFIGURE },
Section { tab: "MODEL", panel: "CONSTRUCT", promoted: 1, ids: &["construct.plane.offset"] },
// Sketches drive surfaces too: New Sketch leads the tab, as on MODEL and SHEET METAL.
Section { tab: "SURFACE", panel: "SKETCH", promoted: 1, ids: &["sketch.create"] },
Section { tab: "SURFACE", panel: "CREATE", promoted: 4, ids: &["surface.extrude", "surface.revolve", "surface.sweep", "surface.loft"] },
Section { tab: "SURFACE", panel: "EDIT", promoted: 4, ids: &["surface.trim", "surface.untrim", "surface.extend", "surface.stitch"] },
Section { tab: "SURFACE", panel: "CONVERT", promoted: 2, ids: &["surface.thicken", "mesh.convert"] },
@@ -634,6 +636,7 @@ mod tests {
("ASSEMBLE", "COMPONENTS", "component.insert_design"),
("SHEET METAL", "CREATE", "sheet.convert"),
("SHEET METAL", "SKETCH", "sketch.create"),
("SURFACE", "SKETCH", "sketch.create"),
("SKETCH", "REFERENCE", "canvas.insert"),
] {
assert!(layout(tab, panel, &specs).unwrap().0.iter().any(|c| c.id == id));
@@ -948,4 +951,20 @@ mod tests {
assert!(h.app.session.active_sketch.is_some());
assert_eq!(h.app.ui.tab, "SKETCH");
}
/// New Sketch is on the SURFACE tab too, and a sketch started there stays on that tab until
/// it opens.
#[test]
fn new_sketch_starts_from_the_surface_tab() {
let mut h = crate::scenario::Harness::new();
h.step(&json!({"click": {"handle": "tab:SURFACE"}})).unwrap();
h.frames(2);
h.step(&json!({"click": {"handle": "button:sketch.create"}})).unwrap();
h.frames(2);
assert_eq!(h.app.ui.tab, "SURFACE", "New Sketch is on this tab, so it stays");
h.step(&json!({"click": {"plane": "XY"}})).unwrap();
h.frames(2);
assert!(h.app.session.active_sketch.is_some());
assert_eq!(h.app.ui.tab, "SKETCH");
}
}
+11 -2
View File
@@ -161,7 +161,7 @@ fn docked_ribbon_geometry_and_search() {
#[test]
fn finish_sketch_stays_at_the_right_when_groups_collapse() {
use solidcraft_ui_egui::scenario::handle_at;
use solidcraft_ui_egui::scenario::{count_of, handle_at};
for layout in ["docked", "floating"] {
let mut h = Harness::new();
h.call("ui.set", json!({"layout":layout}));
@@ -172,7 +172,16 @@ fn finish_sketch_stays_at_the_right_when_groups_collapse() {
let ribbon = rect_of("toolbar").unwrap();
let exit = rect_of("ribbon:section:SKETCH:FINISH SKETCH").unwrap();
let finish = rect_of("button:sketch.finish").unwrap();
assert_eq!(exit.right(), ribbon.right() - 4.0);
// Finish follows the last sketch panel; once panels spill into the » menu it sits at
// the right end, after it.
if count_of("toolbar_more").unwrap_or(0.0) > 0.0 {
assert_eq!(exit.right(), ribbon.right() - 4.0, "{layout} at {width}");
} else {
assert!(exit.right() <= ribbon.right() - 4.0, "{layout} at {width}");
let last = rect_of("panel:SKETCH:CONFIGURE").or_else(|| rect_of("panel:SKETCH:REFERENCE")).unwrap();
let group = rect_of("panel:SKETCH:FINISH SKETCH").unwrap();
assert!(group.left() - last.right() < 16.0, "{layout} at {width}: Finish follows the last panel");
}
assert!(ribbon.contains_rect(finish));
assert!(finish.bottom() < exit.top());
assert!(handle_at("tab:SKETCH").is_some(), "the selected contextual tab scrolls into view");
+7 -1
View File
@@ -1,6 +1,6 @@
# UI localization
SolidCraft embeds its translations and works offline. English, Czech, Danish, German, Spanish, French, Galician, Italian,
SolidCraft embeds its translations and works offline. English, Czech, Danish, German, English (UK), Spanish, French, Galician, Italian,
Japanese, Korean, Malay, Polish, Brazilian Portuguese, Russian, Turkish, Ukrainian and Chinese are currently registered in `crates/ui-egui/src/i18n.rs`. Adding a language requires an
embedded catalog and one registration there; the preferences selector and validation use the same
registry.
@@ -31,6 +31,12 @@ entry has a note for translators: what the term must not be confused with. Its "
than one meaning" table lists the English words that need a separate catalogue entry for each
meaning (Construction, Flat, Round, Normal, Gap, Clearance).
## English (UK)
`en-GB.json` holds only the English messages whose spelling differs in UK English (Centre,
Customise, Organisation, Licence, colours, …); every other message falls back to English. When an
English message with a US spelling is added, add its UK spelling there too.
## Coverage
A message a locale never received falls back to English silently, so a gap shows up only as a row
+1
View File
@@ -14,6 +14,7 @@ Always shown, in order: MODEL, SURFACE, ASSEMBLE, INSERT, INSPECT, MANAGE, VIEW,
| MODEL | ARRANGE | **`solid.move`**, **`solid.mirror`**, **`solid.pattern.rectangular`**, `solid.align`, `solid.pattern.circular`, `solid.pattern.path` |
| MODEL | CONFIGURE | **`parameters.change`**, **`config.table`**, `config.configure` |
| MODEL | CONSTRUCT | **`construct.plane.offset`**, `construct.plane.at_angle`, `construct.axis.through_cylinder`, `construct.axis.normal_to_face`, `construct.axis.two_planes`, `construct.axis.two_points`, `construct.axis.edge`, `construct.point.vertex`, `construct.point.two_edges`, `construct.point.three_planes`, `construct.point.center`, `construct.point.edge_plane`, `construct.point.along_path`, `construct.plane.midplane`, `construct.plane.two_edges`, `construct.plane.three_points`, `construct.plane.tangent`, `construct.plane.along_path`, `construct.plane.perpendicular` |
| SURFACE | SKETCH | **`sketch.create`** |
| SURFACE | CREATE | **`surface.extrude`**, **`surface.revolve`**, **`surface.sweep`**, **`surface.loft`**, `surface.boundary`, `surface.patch`, `surface.offset`, `surface.ruled`, `surface.blend` |
| SURFACE | EDIT | **`surface.trim`**, **`surface.untrim`**, **`surface.extend`**, **`surface.stitch`**, `surface.unstitch`, `surface.reverse_normal`, `surface.fair`, `surface.fillet`, `surface.match` |
| SURFACE | CONVERT | **`surface.thicken`**, **`mesh.convert`** |
+4 -4
View File
@@ -10,7 +10,7 @@ SolidCraft reads STEP (ISO 10303-21; AP203, AP214 and AP242 geometry) with its o
Every import (STEP, IGES, 3MF, STL, OBJ; inserted, dropped, opened as a new design, or run as `file.insert_step`, `file.insert_mesh` or `doc.open` from a script, the CLI or MCP) becomes a **new component named after the file** (`bracket.step` → component `bracket`), placed in the active component at the file's origin. The Import feature belongs to it, so the file's bodies never land loose next to your own. Opening a file as a design makes the component too, so the browser shows the file's name. The command's reply reports it: `component` (id), `component_name` and `occurrence`.
A STEP **assembly** keeps its structure: each product under the file's root becomes a component nested under the import's, named after the product, at the place the file gives it. A part (or sub-assembly) the file places more than once becomes **one component with an occurrence per placement**: the Import feature builds the part once and skips the file's later copies (`skip` in the feature, format 22), and the occurrences show it everywhere else. The reply adds `subcomponents` (how many components were made below the import's) and `rigid_joints`.
A STEP **assembly** keeps its structure: each product under the file's root becomes a component nested under the import's, named after the product, at the place the file gives it. IGES groups and subfigures become nested components the same way. A part (or sub-assembly) the file places more than once becomes **one component with an occurrence per placement**: the Import feature builds the part once and skips the file's later copies (`skip` in the feature, format 22), and the occurrences show it everywhere else. Placements that are scaled, mirrored or otherwise non-rigid keep their geometry in place in the parent component. The reply adds `subcomponents` (how many components were made below the import's) and `rigid_joints`.
The Browser reuses body, sketch and construction-plane membership, child lists and resolved placements while the
camera moves. Large assemblies keep their tree open without resolving every part and
@@ -34,7 +34,7 @@ The contents of an imported assembly are **rigid by default**: rigid joints (a r
Designs saved before imports made components keep their Import where it is (no component is made for it) and rebuild as they did.
Read: manifold solid breps (with voids), faceted breps, shell-based surface models; planes, cylinders, cones, spheres, tori, B-spline and rational B-spline surfaces (with knots, Bézier, uniform, quasi-uniform), surfaces of revolution and linear extrusion, trimmed surfaces; lines, circles, ellipses, hyperbolas and parabolas (as exact rational curves, which export writes back as exact rational B-splines), B-spline curves, polylines, trimmed/surface/seam curves; length and angle units (SI prefixes, conversion-based units such as inches); product names (body names come from the solid's name, else the product's; `names` below chooses); surface colours; assemblies (next-assembly-usage occurrences placed by item-defined transformations, and mapped items) — the product tree is kept in the feature and becomes the import's components (above). Files are untrusted: sizes, entity counts and nesting are capped and every kernel call is guarded. What cannot be read becomes a warning on the feature (a face left out gives an open body); not read yet: offset surfaces, pcurve-only edges.
Read: manifold solid breps (with voids), faceted breps, shell-based surface models; planes, cylinders, cones, spheres, tori, B-spline and rational B-spline surfaces (with knots, Bézier, uniform, quasi-uniform), surfaces of revolution and linear extrusion, trimmed surfaces; lines, circles, ellipses, hyperbolas and parabolas (as exact rational curves, which export writes back as exact rational B-splines), B-spline curves, polylines, trimmed/surface/seam curves; length and angle units (SI prefixes, conversion-based units such as inches); product names (meaningful solid names are kept, placeholders use part names; `names` below chooses); surface colours; assemblies (next-assembly-usage occurrences placed by item-defined transformations, and mapped items) — the product tree is kept in the feature and becomes the import's components (above). Files are untrusted: sizes, entity counts and nesting are capped and every kernel call is guarded. What cannot be read becomes a warning on the feature (a face left out gives an open body); not read yet: offset surfaces, pcurve-only edges.
Extruded circular and elliptical faces keep their profile's parameter seam outside
the face trim, or on an explicit seam edge. Narrow faces such as rounded gear tooth
@@ -46,7 +46,7 @@ the supporting ellipse in the middle.
Files from different systems differ in which way is up and in what they call their solids. `doc.open`, `file.insert_step` and the MCP `open` tool take two options for STEP and IGES files; without them a file comes in as written.
- **`up`**: `"z"` (the default) keeps the file's axes. Many systems write Y up, so such a part lies on its back in SolidCraft's Z-up space. `"up": "y"` turns the import's component a quarter turn about X (+90°: Y becomes Z), as the window's "Stand it up" offer does, so an assembly's parts and instances turn together. The import and the turn are one undo step, the reply names the turned occurrence as `stand_up`, and only the file's component turns, not what is already in the design. The window offers to stand a part up after a STEP file opens unless the call said which way is up (`up: "z"` as well as `"y"`).
- **`names`**: what the bodies are called. `"solid"` (the default) is the name of the solid in the file, and the product's name where the solid has none. Some systems fill a solid's name with the last feature that made it (`Cut-Extrude3`), so every body of an assembly has a name that says nothing about its part. `"product"` names each body after the product (the part) that holds it, and `"instance"` after the assembly occurrence (`Post-2`), or the product where the body is not in an occurrence. A name the file leaves out, or fills with a placeholder, falls back to the next one (instance, product, solid). Repeated names are numbered as any body names are (`Post`, `Post (2)`). The names are stored with the Import feature, so the design reopens with them. The default stays the solid's name because SolidCraft's own STEP export writes a body's name there.
- **`names`**: `"auto"` (the default) keeps meaningful solid names and replaces placeholders such as `Body1` or `Body 2 (3)` with the nearest named product, then the occurrence name if no product is named. `"solid"` keeps the solid's name; `"product"` chooses the part's name; `"instance"` chooses the assembly occurrence (`Post-2`), falling back to the product. If the chosen name is missing, the solid's name remains. Repeated names are numbered (`Post`, `Post (2)`). Names are stored with the Import feature and survive reopening the design. Files without names or assembly metadata retain generated body names and a flat structure.
```json
{"command": "file.insert_step", "params": {"path": "frame.step", "up": "y", "names": "instance"}}
@@ -61,7 +61,7 @@ display on the resulting body.
## IGES
IGES 5.3 files (`.igs`/`.iges`) open, insert and drop the same way: `crates/kernel/src/iges_in.rs` (written from the published specification) restates the file as STEP and the STEP reader reads it, so the feature holds STEP text. Read: manifold solid B-reps (186) with their shells, faces, loops, edge and vertex lists; trimmed (144) and bounded (143) surfaces, and bare B-spline patches, sewn into a solid by their shared edges (an open body when they do not close); lines, circular arcs, ellipses, composite curves, rational B-spline curves; planes (108, 190), cylinders, cones, spheres, tori, rational B-spline surfaces, surfaces of revolution and tabulated cylinders; transformation matrices; units; colours (314 and the predefined numbers); body names (name properties, else labels). Not read yet: ruled and offset surfaces, curves on surfaces given only in parameter space. Export (`file.export` to `.igs`) writes each body as a manifold solid B-rep on the same surfaces a STEP export uses, with its name and colour.
IGES 5.3 files (`.igs`/`.iges`) open, insert and drop the same way: `crates/kernel/src/iges_in.rs` (written from the published specification) restates the file as STEP and the STEP reader reads it, so the feature holds STEP text. Read: manifold solid B-reps (186) with their shells, faces, loops, edge and vertex lists; trimmed (144) and bounded (143) surfaces, and bare B-spline patches, sewn within their owning group into a solid by their shared edges (an open body when they do not close); groups (402 forms 1, 7, 14 and 15), subfigure definitions (308) and instances (408), retaining names, nesting and placements; lines, circular arcs, ellipses, composite curves, rational B-spline curves; planes (108, 190), cylinders, cones, spheres, tori, rational B-spline surfaces, surfaces of revolution and tabulated cylinders; transformation matrices; units; colours (314 and the predefined numbers); body names (name properties, else labels). Not read yet: ruled and offset surfaces, curves on surfaces given only in parameter space. Export (`file.export` to `.igs`) writes each body as a manifold solid B-rep on the same surfaces a STEP export uses, with its name and colour.
## FreeCAD and Rhino
@@ -0,0 +1,10 @@
{
"description": "Issue #2040: a block with one side at 45 degrees, rounded by 3.8 mm fillets along the ends of its top and bottom. The slanted face's outline has four quarter-ellipses, which its sketch fits as splines; extruding the face as a cut or a join failed with 'boolean ... no result'. The extrude is added by the test.",
"commands": [
{"command": "sketch.create", "params": {"plane": "XZ", "flip": true}},
{"command": "sketch.line", "params": {"points": [[0, 0], [11.514718625761429, 0], [20, 8.485281374238571], [0, 8.485281374238571]], "closed": true}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 10}},
{"command": "solid.fillet", "params": {"edges": [[10, -10, 8.485281374238571], [10, 0, 8.485281374238571], [5.757359312880714, 0, 0], [5.757359312880714, -10, 0]], "radius": 3.8}}
]
}
+2 -2
View File
@@ -30,10 +30,10 @@ In `$SKILL/scripts/`:
## Traps
1. **Zero dimension.** A sketch dimension driven to 0 loses its side. On the next parameter change it either fails with `its constraints would conflict` (a point placed by dimensions from the origin) or, with no error, puts the point on either side (a slot that other constraints also held came back mirrored). Check every distance expression against the whole allowed range of each parameter in it, counts included; the walk in step 3 catches the failure, and only the volume or the position catches the mirror. For a point on an axis, pass `x=None` or `y=None` to `place`: it aligns the point instead of dimensioning it. For a position that does reach 0, such as the first of a centred row of one, dimension it from a sketch point placed outside the part: a point at x = -100 mm, then the distance `100 mm - (n - 1) / 2 * pitch` from it.
1. **Zero dimension.** A sketch dimension driven to 0 loses its side: the point it measures has no direction left, so the solver may leave it anywhere on the axis. On the next parameter change it either fails with `its constraints would conflict` (a point placed by dimensions from the origin) or, with no error, moves it: a slot that other constraints also held slides to the origin when the distance reaches 0 and, the solver taking the shortest step that fits, keeps the side it was drawn on when the value comes back. Check every distance expression against the whole allowed range of each parameter in it, counts included; the walk in step 3 catches the failure, and only the volume or the position catches a point that ended up on the other side. For a point on an axis, pass `x=None` or `y=None` to `place`: it aligns the point instead of dimensioning it. For a position that does reach 0, such as the first of a centred row of one, dimension it from a sketch point placed outside the part: a point at x = -100 mm, then the distance `100 mm - (n - 1) / 2 * pitch` from it.
2. **Counts.** Solid patterns take counts 1..1000; a count that reaches 0 fails the rebuild. A fractional count rounds to the nearest whole number without an error (6.5 gives 7 copies), while every other expression uses the exact value, so a centred row shifts. Nothing in SolidCraft enforces a parameter range during a parameter change (configuration rules apply only when a configuration row is activated): choose a range that keeps every count at 1 or more, write "whole number" and the range in the comment, and use `round(n)` wherever the count also positions something.
3. **Circumscribed polygons.** `sketch.polygon.circumscribed` with a size dimension and one vertical side still has 1 dof: its corners can alternate in and out. `sketch.constraint.polygon` makes it regular; `scb.hexagon` adds it.
4. **Boolean retries.** A `Boolean retry: ...` warning means the result may be wrong (see "Boolean warning" in the `solidcraft-modeling` reference), so check the volume of that state. `temporary coincident-face separation` comes from faces that coincide. In a plain block, a tapered tool whose start or end lies on a face logs it and a straight one does not; on the floor of a filleted, shelled box, a straight cylinder joined onto the floor logs it too, and sunk 1 mm into the floor it does not. Starting cutters about 1 mm past the faces they pierce (sizes extrapolated along any taper) and sinking joins about 1 mm into the body remove the warning on a single feature. They did not fix standoffs built as a joined cylinder and a cut pilot hole, patterned together: those still kept material in the holes, and one ring profile fixed it ("Boolean warning" in the `solidcraft-modeling` reference). Check the volume of every state either way.
4. **Boolean retries.** A `Boolean retry: ...` warning means the result may be wrong (see "Boolean warning" in the `solidcraft-modeling` reference), so check the volume of that state. `temporary coincident-face separation` comes from faces that coincide. A tapered tool whose start or end lies on a face of a plain block, or a straight cylinder joined onto the floor of a filleted, shelled box, does not log it; a `tool displacement` warning shows up when a join and a cut share one location, as in a standoff built as a joined cylinder with a cut pilot hole, patterned together. That case reports the warning but its volume agrees with the analytic growth, and a single ring profile removes the warning. Starting cutters about 1 mm past the faces they pierce and sinking joins about 1 mm into the body remain good practice on a single feature. Check the volume of every state either way.
5. **Measurement accuracy.** The `solidcraft-modeling` reference gives how far measured volumes stray from computed ones. Centre of mass and bounding box come out up to about 5e-5 of the part size off. Set test tolerances as the template says.
6. **Expressions.** They have `+ - * / ^ %`, `sin cos tan asin acos atan atan2 sqrt abs min max floor ceil round mod trunc exp ln log log10 pow sign hypot` (`log` is base 10), `pi` and units; no `if` and no comparisons. Build steps from `floor`, `mod`, `min`, `max` and `sign`, which gives 1 at 0. `asin acos atan atan2` return angles, so a unitless parameter cannot hold them.
7. **Snapping.** A point created exactly on an existing point or the origin becomes coincident with it (`connect` defaults to true).
@@ -75,7 +75,8 @@ def test_a_dimension_driven_to_zero_can_break_the_next_change():
])
def test_a_slot_held_by_other_constraints_can_cross_to_the_other_side_at_zero():
def slot_centre(*changes: list) -> float:
"""The slot's centre on x after the given parameter changes."""
body = execute([
cmd("parameters.add", name="n", expression="3", unit=""),
cmd("sketch.create", plane="XY", name="Slot sketch"),
@@ -86,11 +87,19 @@ def test_a_slot_held_by_other_constraints_can_cross_to_the_other_side_at_zero():
cmd("sketch.dimension", entities=["origin", "a1.center"], type="horizontal", value="(n - 1) * 3 mm"),
cmd("sketch.finish"),
cmd("solid.extrude", sketch="Slot sketch", distance="1 mm", body_name="Slot"),
cmd("parameters.change", name="n", expression="1"),
cmd("parameters.change", name="n", expression="3"),
*changes,
])["bodies"][0]
# Started at x = -6; after n 3 -> 1 -> 3 it sits at +6, with no error
assert (body["bbox"]["min"][0] + body["bbox"]["max"][0]) / 2 == pytest.approx(6, abs=1e-3)
return (body["bbox"]["min"][0] + body["bbox"]["max"][0]) / 2
def test_a_slot_held_by_other_constraints_keeps_its_side_at_zero():
# Drawn at x = -6. At n = 1 the distance is 0, so the slot slides to the origin; when the
# value comes back the solver takes the shortest step that fits and the slot keeps the side
# it was drawn on, so it no longer comes back mirrored.
assert slot_centre() == pytest.approx(-6, abs=1e-3)
assert slot_centre(cmd("parameters.change", name="n", expression="1")) == pytest.approx(0, abs=1e-3)
assert slot_centre(cmd("parameters.change", name="n", expression="1"),
cmd("parameters.change", name="n", expression="3")) == pytest.approx(-6, abs=1e-3)
def centred_row(position: list) -> list:
@@ -162,12 +171,6 @@ def test_a_circumscribed_polygon_needs_the_polygon_constraint():
assert sketch_dof([*HEXAGON, cmd("sketch.constraint.polygon", lines=[f"l{i}" for i in range(1, 7)])]) == 0
def test_in_a_plain_block_only_a_tapered_tool_on_a_face_logs_a_boolean_retry():
assert any("coincident-face" in w for w in warnings([*BLOCK, *tapered_cut("0 mm", "12 mm")]))
assert warnings([*BLOCK, *tapered_cut("-1 mm", "14 mm")]) == []
assert warnings([*BLOCK, *tapered_cut("0 mm", "12 mm", taper="0 deg")]) == []
def test_round_features_measure_about_1e_4_off():
body = execute([cmd("solid.cylinder", diameter="20 mm", height="10 mm")])["bodies"][0]
error = abs(body["volume_mm3"] / (pi * 100 * 10) - 1)
@@ -392,14 +395,14 @@ def standoffs_at(standoffs: list, height: float) -> tuple[float, dict]:
return gap, {f["name"]: f["warning"] for f in high["timeline"] if f.get("warning")}
def test_a_boolean_retry_can_leave_material_in_a_cut():
def test_a_boolean_retry_reports_tool_displacement_without_excess_material():
gap, warned = standoffs_at(TWO_CYLINDERS, 7)
assert gap > 10
assert "coincident-face" in warned["Standoff"]
assert abs(gap) < 0.5
assert "Standoff" not in warned
assert "tool displacement" in warned["Pilot"]
def test_sinking_the_join_and_overcutting_the_hole_does_not_fix_it():
def test_sinking_the_join_removes_the_warning():
gap, warned = standoffs_at([
cmd("solid.cylinder", diameter="6 mm", height="h + 1 mm", base=["-39 mm", "-24.5 mm", "1 mm"], operation="join",
targets=["Case"], name="Standoff"),
@@ -407,8 +410,8 @@ def test_sinking_the_join_and_overcutting_the_hole_does_not_fix_it():
targets=["Case"], name="Pilot"),
cmd("solid.pattern.rectangular", features=["Standoff", "Pilot"], **STANDOFF_GRID),
], 7)
assert gap > 10
assert "Standoff" not in warned
assert abs(gap) < 0.5
assert warned == {}
@pytest.mark.parametrize("height", [3, 7, 12, 20])
@@ -418,11 +421,12 @@ def test_a_ring_profile_builds_the_same_standoffs_exactly(height):
assert warned == {}
@pytest.mark.parametrize("base, height, retries", [("2 mm", "h", True), ("1 mm", "h + 1 mm", False)])
def test_a_straight_join_on_a_shelled_floor_retries_unless_sunk(base, height, retries):
def test_a_sunk_join_on_a_shelled_floor_does_not_warn():
warned = warnings([
*SHELLED_CASE,
cmd("solid.cylinder", diameter="6 mm", height=height, base=["-39 mm", "-24.5 mm", base], operation="join",
cmd("solid.cylinder", diameter="6 mm", height="h + 1 mm", base=["-39 mm", "-24.5 mm", "1 mm"], operation="join",
targets=["Case"], name="Standoff"),
])
assert any("coincident-face" in w for w in warned) == retries
assert warned == []
@@ -38,19 +38,16 @@ def test_flags_underconstrained_sketch(tmp_path):
def test_reports_step_warnings(tmp_path):
result = report(tmp_path, [
{"command": "sketch.create", "params": {"plane": "XY", "offset": "12 mm", "name": "Plate"}},
{"command": "sketch.rectangle.center", "params": {"center": [0, 0], "corner": [40, 40]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"sketch": "Plate", "distance": "12 mm", "direction": "negative",
"body_name": "P"}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "Hole"}},
{"command": "sketch.circle.center", "params": {"center": [0, 0], "diameter": 20}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"sketch": "Hole", "distance": "12 mm", "taper": "-20 deg",
"operation": "cut", "targets": ["P"], "name": "Flush cut"}},
{"command": "parameters.add", "params": {"name": "h", "expression": "5 mm"}},
{"command": "solid.box", "params": {"length": 91, "width": 62, "height": 30, "center": [0, 0, 15], "body_name": "Case"}},
{"command": "solid.fillet", "params": {"edges": [[45.5, 31, 15], [-45.5, 31, 15], [45.5, -31, 15], [-45.5, -31, 15]], "radius": 3}},
{"command": "solid.shell", "params": {"faces": [[0, 0, 30]], "thickness": 2}},
{"command": "solid.fillet", "params": {"edges": [[0, -31, 30]], "radius": 1}},
{"command": "solid.cylinder", "params": {"diameter": "6 mm", "height": "h", "base": [-39, -24.5, 2], "operation": "join", "targets": ["Case"], "name": "Standoff"}},
{"command": "solid.cylinder", "params": {"diameter": "2.5 mm", "height": "h", "base": [-39, -24.5, 2], "operation": "cut", "targets": ["Case"], "name": "Pilot"}},
])
assert result.returncode == 0, result.stderr
assert 'WARN "Flush cut": Boolean retry' in result.stdout
assert 'WARN "Pilot": Boolean retry' in result.stdout
def test_fails_on_a_bad_step(tmp_path):
@@ -86,40 +83,38 @@ def test_execute_raises_on_failure():
@pytest.fixture
def flush_design(tmp_path):
"""A cut 10 mm deep into a 12 mm plate; at depth 13 mm its tapered end lies on the plate top."""
design = tmp_path / "plate.json"
"""A shelled case with a joined standoff and a cut pilot hole; Pilot warns tool displacement."""
design = tmp_path / "standoff.json"
built = report(tmp_path, [
{"command": "parameters.add", "params": {"name": "depth", "expression": "10 mm"}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "Plate sketch"}},
{"command": "sketch.rectangle.center", "params": {"center": [0, 0], "corner": [40, 40]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"sketch": "Plate sketch", "distance": "12 mm", "body_name": "P"}},
{"command": "sketch.create", "params": {"plane": "XY", "offset": "-1 mm", "name": "Cut sketch"}},
{"command": "sketch.circle.center", "params": {"center": [5, 5], "diameter": 20}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"sketch": "Cut sketch", "distance": "depth", "taper": "10 deg",
"operation": "cut", "targets": ["P"], "name": "Cut"}},
{"command": "parameters.add", "params": {"name": "h", "expression": "5 mm"}},
{"command": "solid.box", "params": {"length": 91, "width": 62, "height": 30, "center": [0, 0, 15], "body_name": "Case"}},
{"command": "solid.fillet", "params": {"edges": [[45.5, 31, 15], [-45.5, 31, 15], [45.5, -31, 15], [-45.5, -31, 15]], "radius": 3}},
{"command": "solid.shell", "params": {"faces": [[0, 0, 30]], "thickness": 2}},
{"command": "solid.fillet", "params": {"edges": [[0, -31, 30]], "radius": 1}},
{"command": "solid.cylinder", "params": {"diameter": "6 mm", "height": "h", "base": [-39, -24.5, 2], "operation": "join", "targets": ["Case"], "name": "Standoff"}},
{"command": "solid.cylinder", "params": {"diameter": "2.5 mm", "height": "h", "base": [-39, -24.5, 2], "operation": "cut", "targets": ["Case"], "name": "Pilot"}},
], "--save", design)
assert built.returncode == 0 and "WARN" not in built.stdout, built.stdout + built.stderr
assert built.returncode == 0, built.stdout + built.stderr
return design
def depth(value: str) -> dict:
return {"command": "parameters.change", "params": {"name": "depth", "expression": value}}
return {"command": "parameters.change", "params": {"name": "h", "expression": value}}
def test_reports_warnings_after_a_parameter_change(tmp_path, flush_design):
flush = report(tmp_path, [depth("13 mm")], "--in", flush_design)
assert 'WARN "Cut": Boolean retry' in flush.stdout
flush = report(tmp_path, [depth("7 mm")], "--in", flush_design)
assert flush.returncode == 0, flush.stderr
assert 'WARN "Pilot": Boolean retry' in flush.stdout
def test_reports_every_inspection_in_the_script(tmp_path, flush_design):
walk = report(tmp_path, [depth("13 mm"), {"command": "document.inspect", "params": {"measure": True}},
depth("10 mm")], "--in", flush_design)
walk = report(tmp_path, [depth("7 mm"), {"command": "document.inspect", "params": {"measure": True}},
depth("5 mm")], "--in", flush_design)
lines = walk.stdout.splitlines()
assert any(line.startswith('step 2 WARN "Cut": Boolean retry') for line in lines), walk.stdout
assert any(line.startswith("step 2 BODY P: volume") for line in lines)
assert not any(line.startswith("WARN") for line in lines)
assert any(line.startswith('step 2 WARN "Pilot": Boolean retry') for line in lines)
assert any(line.startswith("step 2 BODY Case: volume") for line in lines)
assert any(line.startswith('WARN "Pilot": Boolean retry') for line in lines)
def test_reports_bodies_inspected_without_measurements(tmp_path):
+1 -1
View File
@@ -129,7 +129,7 @@ After `set_parameter L=120` the volume is 35335.2 mm³, and the holes stay in th
- **Tolerance:** analytic and measured volumes agree to about 0.001-0.01%, so treat a gap that small as a match. Long shallow arcs measure worse, about 0.1% (R 1000 mm over 20 degrees).
- **Face counts:** compare the logical `faces`, not the larger `kernel.faces`.
- **Reply types:** `set_parameter` returns `recomputed` as a number. A batch reply echoes every step.
- **Boolean warning:** a feature `warning` such as `Boolean retry: temporary coincident-face separation up to 2.050000e0 mm per face` means the kernel nudged faces that coincide. Examples are a join flush on a face, such as a cylinder joined onto the floor of a shelled box, or a tapered cut that starts on a face. When the volume still matches your number the warning is harmless. When it does not, the retry may have left the geometry wrong, with no error. Seen: on a box with filleted corners, a shell and a rounded rim, a standoff joined and its pilot hole cut as two cylinders, patterned 2x2, left material in the holes at some standoff heights. A boss with its hole as one ring profile (two concentric circles, extrude the ring) built correctly at every height tried (3 to 20 mm). Another kind, `tool displacement`, moves a tool by about 2e-4 mm.
- **Boolean warning:** a feature `warning` such as `Boolean retry: temporary coincident-face separation up to 2.050000e0 mm per face` means the kernel nudged faces that coincide. A join flush on a face, such as a cylinder joined onto the floor of a shelled box, or a tapered cut that starts on a face, does not log it. When the volume still matches your number the warning is harmless. When it does not, the retry may have left the geometry wrong, with no error. A standoff built as a joined cylinder with a cut pilot hole, patterned together, reports `tool displacement` (moving a tool by about 2e-4 mm) but its volume agrees with the analytic growth; a boss with its hole as one ring profile (two concentric circles, extrude the ring) builds without a warning at every height tried (3 to 20 mm).
## Reply cost (0.8, plate example)
+3 -2
View File
@@ -103,7 +103,8 @@ pub fn run(root: &Path, args: &[&str]) -> Result<(), String> {
}
/// Locale ids other than English: every catalog file that sits beside `en.json`, so a newly
/// added locale is picked up without editing this command.
/// added locale is picked up without editing this command. A regional English (`en-GB.json`)
/// holds only the messages it spells differently, so it has no gaps to report.
fn locales(root: &Path) -> Result<Vec<String>, String> {
let dir = root.join(DIR);
let entries = std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
@@ -112,7 +113,7 @@ fn locales(root: &Path) -> Result<Vec<String>, String> {
.map(|e| e.path())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json"))
.filter_map(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned()))
.filter(|id| id != "en")
.filter(|id| id != "en" && !id.starts_with("en-"))
.collect();
ids.sort();
Ok(ids)