Compare commits

...
105 Commits
Author SHA1 Message Date
bherbruck 2bb9c81069 Release 0.14.0 2026-10-16 09:19:14 +00:00
bherbruckandClaude Opus 5.5 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
bherbruckandClaude Opus 5.5 9d82b94469 Robustness goldens: record seeds 31 and 83 after #1581 and #1495 (#1657)
Seed 31: #1581 refuses a fillet selection with an earlier round's smooth seam instead of re-rounding another nearby edge. Seed 83: #1316 was recorded before its branch took in #1495, whose shell keeps the wall beside a concave edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:55:20 -04:00
bherbruckandClaude Opus 5.5 a9beb437b3 Boolean property tests: nightly only, and the cases they found as ignored tests (#1650)
Pushes to main skip boolean_props (random inputs); the nightly property-tests job runs it. The three shrunk cases are ignored tests with their issues (#1647, #1648, #1649), and proptest's failure file is named outright.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:35:53 -04:00
github-actions[bot] db1fd39139 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 07:38:05 +00:00
midasdf 07da7f8065 Class-A surfacing 2/3: surface match, boundary patch and CV fairing (#1487)
Thanks @midasdf.
2026-10-16 03:26:20 -04:00
DuBearte 7f79443b46 Control channel: a timed-out request says it is still running, and its result can be asked for (#1618) (#1627)
Thanks @DuBearte.
2026-10-16 03:26:16 -04:00
phanoob 9bb65b4d68 Booleans: a prism cut beside the target's other faces takes the exact 2D route in a column (#1629)
Thanks @phanoob.
2026-10-16 03:26:13 -04:00
Lasse Lian 18ec44d773 AppImage: leave the menu entry to launchers that set DESKTOPINTEGRATION (#1635)
Thanks @lasselian.
2026-10-16 03:26:09 -04:00
Karl Von Richter 9422853cf9 Feature mirrors and patterns: preserve replay and add Flip/Center placement (#1619)
Thanks @Karl-VonRichter.
2026-10-16 03:01:00 -04:00
Irakli Kourtis f57a62deb7 Intersection edge nodes advance along the curve at fine display qualities (#1615)
Thanks @jyviko.
2026-10-16 03:00:56 -04:00
DuBearte 61678654c2 Extrude: dragging the taper stops where the profile would close (#1602) (#1614)
Thanks @DuBearte.
2026-10-16 03:00:52 -04:00
DuBearte 25c89a706e Sketch: a tangent arc starts from a trimmed end (#1485) (#1604)
Thanks @DuBearte.
2026-10-16 03:00:49 -04:00
bherbruck 26bf1af93b Sketch on an origin plane faces the side you clicked (#1587)
Fixes #1586.
2026-10-16 03:00:24 -04:00
github-actions[bot] 3707a67cee Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 06:01:11 +00:00
Mateusz Sych 571122c4ba Motion Trace: a point's path as a joint is driven or a study plays, kept as a 3D spline (#1529)
Thanks @SychPL.
2026-10-16 01:54:34 -04:00
H. Conrad dcffab09b1 Tessellation: point-in-trim tests skip runs of segments the ray canno… (#1309)
Thanks @Ian-cmd-ops.
2026-10-16 01:54:29 -04:00
DuBearte 6f270f6259 Split Body: a sketch line as the splitting tool (#1552) (#1592)
Thanks @DuBearte.
2026-10-16 01:35:15 -04:00
H. Conrad dc6eca6830 Fix/shell saddle corners (#1316)
Thanks @Ian-cmd-ops.
2026-10-16 01:19:42 -04:00
H. Conrad d29ca29578 Perf/mesh collision (#1307)
Thanks @Ian-cmd-ops.
2026-10-16 01:12:39 -04:00
github-actions[bot] 5e9285ca75 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 04:37:13 +00:00
Karl Von Richter 371578a06f Feat/Sketch: associative pattern tools with independent directions and Flip/Center controls (#1601)
Thanks @Karl-VonRichter.
2026-10-16 00:32:32 -04:00
JIJINBEI 44968dbfe6 Feature fields: a type for each role (Expr, BodyName, FacePick…) (#1273)
Thanks @jijinbei.
2026-10-16 00:28:12 -04:00
JIJINBEI 3333f8a62b Divide intersection edges from their neighbours' parameters (#1038) (#1412)
Thanks @jijinbei.
2026-10-16 00:27:55 -04:00
H. Conrad 42d4ebeb16 STEP export: a merged partial cylinder keeps its cylinder (silent volume loss) (#1497)
Thanks @Ian-cmd-ops.
2026-10-16 00:27:48 -04:00
DuBearte d624e0a171 Expressions: inch and foot marks (#1502) (#1578)
Thanks @DuBearte.
2026-10-16 00:27:40 -04:00
DuBearte 3fe929e91c Renderer: skip a frame the GPU won't stage instead of crashing (#1469) (#1579)
Thanks @DuBearte.
2026-10-16 00:27:33 -04:00
DuBearte c419dec76c Joint drive test: wait as long as the other preview tests (#1549) (#1582)
Thanks @DuBearte.
2026-10-16 00:27:26 -04:00
DuBearte f91103c51d Viewport: the grid faces side views (#1470) (#1583)
Thanks @DuBearte.
2026-10-16 00:27:19 -04:00
DuBearte ac3a25fb5b View keys: arrows turn the view, Ctrl+arrows pan, Ctrl+8 looks at the selection (#1541) (#1585)
Thanks @DuBearte.
2026-10-16 00:27:12 -04:00
github-actions[bot] 8f5073ff14 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 04:18:59 +00:00
H. Conrad c5b7009ce3 ci: nightly property tests with more cases (#1498)
Thanks @Ian-cmd-ops.
2026-10-16 00:13:48 -04:00
bherbruck f98c5e350d Shell: put shell_by_opening's doc comment back on it (#1588)
Thanks @bherbruck.
2026-10-16 00:11:30 -04:00
github-actions[bot] 2e5b7b11e4 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 04:08:05 +00:00
bherbruck 6c1a898e3d Check for updates at every start; show "Update to X" as a title-bar pill (#1538)
Thanks @bherbruck.
2026-10-16 00:07:41 -04:00
Mateusz Sych 4d862cd76c Joints: joint.reach (inverse kinematics), and a jointed part follows a drag (#1540)
Thanks @SychPL.
2026-10-15 23:58:34 -04:00
bherbruck baf44657c1 Agent chat: queue messages while the agent works, Send now, Stop, New chat, and a model picker (#1566, #1571) (#1566)
Thanks @bherbruck.
2026-10-15 23:58:28 -04:00
bherbruck fdb059cfed Claim bot: let me /unclaim my own issues (#1595)
An explicit /unclaim from the owner releases an owner-assigned issue; the sweep still never touches them.
2026-10-15 23:57:15 -04:00
H. Conrad 716107f0e4 Sketch cut and paste: refuse parameters that are not an object (no panic) (#1577)
Thanks @Ian-cmd-ops.
2026-10-15 23:34:18 -04:00
CamWight 6220d8f8a6 Desktop 3D mouse on Windows without a 3DxWare profile: object-mode axes, no emulated wheel (#1380) (#1528)
Thanks @CamWight.
2026-10-15 23:28:02 -04:00
bherbruck c01a7b9b37 Fix main: drop a test's use of the removed viewport.no_pixels (#1594)
Thanks @bherbruck.
2026-10-15 23:24:57 -04:00
bherbruck 556e424bb2 Fix main: drop the removed viewport.no_pixels from the edge-pick test (#1596)
#1555 removed the field; #1568's new test still set it.
2026-10-15 23:22:43 -04:00
bherbruck ce96e625b4 Contributing: kernel speed work goes to solidcore (#1597) (#1598)
Thanks @bherbruck.
2026-10-15 23:12:50 -04:00
bherbruck 5cfcf7fd03 Fix the main build: the edge-pick test used a removed field (#1590) 2026-10-15 23:09:41 -04:00
Irakli Kourtis fac3f96bdd Patch: G1/G2 to the faces beside the loop, general and N-sided loops, points (#759) (#1521)
Thanks @jyviko.
2026-10-15 23:07:24 -04:00
github-actions[bot] 77bdcc47e9 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 03:01:46 +00:00
DuBearte 987f0be21e Sketch: infinite construction lines (#1359) (#1547)
Thanks @DuBearte.
2026-10-15 22:54:16 -04:00
bherbruck bd17eeef0c Claim bot: /claim and /unclaim on issues (#1584)
Comment /claim on an issue to be assigned and get the in progress label; /unclaim releases it. A daily sweep frees claims with no PR after 72 h or with a PR idle for 14 days.
2026-10-15 22:54:06 -04:00
sthifen e21c5f06ac Viewport frame-rate readout (Preferences ▸ Graphics) (#1580)
Thanks @sthifen.
2026-10-15 22:50:41 -04:00
Colin Faulkingham 904bf19e83 Fix agent runtime lookup when launched from the desktop (#1551)
Thanks @cfaulkingham.
2026-10-15 22:44:13 -04:00
Colin Faulkingham 7ef78f960a Fix perpendicular fillets after connected rounds (#1581)
Thanks @cfaulkingham.
2026-10-15 22:40:09 -04:00
Erikas a422fb1a4b solid.mirror: a plane's {origin} takes length expressions and follows them (#1492) (#1501)
Thanks @erkexzcx.
2026-10-15 22:37:08 -04:00
rlartot daf618c344 Edge division: evaluate each point once (#1560)
Thanks @rlartot.
2026-10-15 22:19:51 -04:00
phanoob 5165e428cd Boolean: refuse a nudged result that meshes inconsistently (#1360) (#1455)
The last-resort nudge's result is also measured finely; a face left a hair wide that meshes as its whole plane no longer passes as a plausible cut.
2026-10-15 22:19:28 -04:00
Irakli Kourtis 1a0b33fcf3 Headless pictures draw on the GPU, with the viewport's pipelines (CPU rasterizer as last tier) (#1555)
Thanks @jyviko.
2026-10-15 22:19:26 -04:00
Giovanni 48322f6464 Join a flange flush with a bent, tapered loft's end where the retry fails (#1391) (#1556)
Thanks @gvocale.
2026-10-15 22:19:22 -04:00
Erikas 5c0351b1f4 Fillet: refuse only crossed faces the round makes, not ones the body had (#1488) (#1517)
Thanks @erkexzcx.
2026-10-15 22:19:19 -04:00
Erikas b20fd3d9d6 Booleans: when the last-resort nudge fails both ways, try two more screw axes (#1490) (#1527)
Thanks @erkexzcx.
2026-10-15 22:19:15 -04:00
H. Conrad 63accec16e STEP export: shelled revolved bodies export instead of panicking (#1496)
A scale across a revolution's axis keeps the face a surface of revolution and is written as one; any other refusal from the writer is an export error, not a panic.
2026-10-15 22:19:10 -04:00
Mythic Keeper d188eea1f4 Fix: edges between two faces hard to pick (#1568)
Thanks @MythicForge.
2026-10-15 22:19:08 -04:00
H. Conrad 2141a3802a Shell: an open face next to a concave edge no longer cuts the wall past it (#1495)
The pushed-out cavity is trimmed so that past an open face's plane it only reaches through the open face itself.
2026-10-15 22:19:04 -04:00
H. Conrad 723c7b61c4 Boolean: cut around a part's sealed voids when the whole cut fails (#1570)
A cut clear of every void cuts the outer shell alone and puts the voids back (robustness seed 15 now builds).
2026-10-15 22:18:31 -04:00
bherbruckandClaude Opus 5.5 1a55241882 Contributing: claim an issue with a draft PR (#1565)
Check gh pr list --search "#N", then open a draft titled "<summary> (#N)" with an empty claim commit; the lowest-numbered open PR for an issue wins, and a draft with no pushes for 72 hours may be taken over after a comment. Agents on a shared machine check the load before a big build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:17:39 -04:00
DuBearteandClaude Opus 5.5 b562ab51e1 Agent discovery test: the fixture uses a name Windows runs (#1554) (#1575)
The search-order test wrote an extensionless codex-acp, which Windows
discovery rightly skips; it now writes the first name this platform
looks for (codex-acp.exe on Windows, codex-acp elsewhere).

Fixes #1554

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:05:55 -04:00
DuBearteandClaude Opus 5.5 105a3146c0 Toolbar: a second click on a drop-down's button closes it (#1522) (#1576)
The workspace switcher's menu closed on the press (a press outside the
menu) and the click reopened it; the menu now keeps the button that
opened it, a press there is not outside, and its click toggles. A
panel's label toggles its command list instead of always opening it.

Fixes #1522

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:04:42 -04:00
bherbruckandClaude Opus 5.5 f08ed74617 Keep the files many PRs add to conflict-free (#1564)
Message catalogs sorted one message per line (cargo xtask lists [--fix], checked in cargo xtask ci and the Lists workflow); the language list one per line, English first then by id; THIRD-PARTY-LICENSES.txt and docs/features.md rewritten on main by the Generated files workflow instead of in PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:59:52 -04:00
Colin FaulkinghamandClaude Sonnet 5.5 342305705d Add XAI Grok to the AI panel (#1558)
Grok Build is added as one more entry in the agent discovery list, over ACP, like the others. Thanks @cfaulkingham.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 21:27:50 -04:00
Mateusz SychandClaude Sonnet 5.5 a6742180a9 Join from a sketch on a body's face joins that body (#1349) (#1526)
Thanks @SychPL for the fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 21:27:35 -04:00
cronosandClaude Sonnet 5.5 abb15e4b0e Preserve circle and arc centre heights when measuring straight edges (#1546)
Thanks @crono2250 for the fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 21:27:29 -04:00
Karl Von RichterandClaude Sonnet 5.5 584bbe923d sketch(fix): preserve rectangle constraints through fillets (#1543)
Thanks @Karl-VonRichter for the fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 21:27:24 -04:00
bherbruckandClaude Opus 5.5 9a07932f20 Design format: a change is one new file, never an edit to FORMAT (#1563)
A PR that saves something new adds crates/doc/src/format/pending/<name>.rs (a note, and an upgrade step if older designs must be rewritten). The build writes the next format number while changes are pending and lists them in format_pending; scripts/fold-format-pending.sh fixes them as v<N>/ at release time. Formats 23 and 24 move into v23/ and v24/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:13:38 -04:00
bherbruck 067f2642c2 Agent chat on Windows: no terminal window flashes when agents are detected 2026-10-15 21:11:49 -04:00
bherbruckandClaude Sonnet 5.5 af771b9ceb CI: PRs build and test only the packages they touch (#1561)
Clippy, the test archive and the test shards cover the packages a PR changes plus their dependents. Main, nightly and manual runs stay full.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 20:58:26 -04:00
4cbf9cf607 Sheet metal: an Edge Flange can choose its own corner relief (#1481)
An Edge Flange feature can set the shape and size of the relief at the corners its flanges make, in place of the sheet metal rule's. Design format 24.

Co-authored-by: NOWN-DEV <NOWN-DEV@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 20:22:31 -04:00
c361630954 Export the current 3D view as 2D DXF with optional hidden lines (#1449)
Adds File > Export > Current View as DXF: the visible bodies projected from the current camera onto 2D LINE/LWPOLYLINE entities, with hidden edges omitted or dashed.

Co-authored-by: cronos <19868002+crono2250@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 20:04:25 -04:00
845f4913cf render: add opaque CPU sampling and bounded mip chains (#1459)
Co-Authored-By: selune <0xlillith@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 19:39:30 -04:00
Jackson OurslandandClaude Opus 5.5 78242d67d1 Import FreeCAD and Rhino files through cadmpeg (#1435)
FreeCAD .FCStd and Rhino .3dm files open, insert and drop like STEP and IGES:
cadmpeg (Apache-2.0, pinned =0.6.1, native builds only) detects the format,
decodes it under its service resource caps, validates the model and writes it
as STEP AP214, which becomes an Import feature in a new component named after
the file, with the up and names options.

Only the fcstd, rhino and step codecs are compiled in (no default features).
ACIS is left out: cadmpeg's sat codec is a ShapeManager stream decoder.
native_cad.rs is the single importer seam. Self-made FreeCAD and Rhino box
fixtures, hostile-input, ZIP-bomb and cap tests, engine tests for the
component path; the browser build reports these formats as not supported.
A note under design format 23 says an Import's file can name these formats.

Co-authored-by: Jacob Oursland <jacob.oursland@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:30:03 -04:00
bherbruck 2b45635d73 Version 0.13.1 2026-10-15 19:18:52 -04:00
bherbruck 4097ac0131 sketch.cut: refuse non-object params instead of panicking 2026-10-15 19:12:55 -04:00
96d1ad0ab2 Show optional X/Y/Z distances in measurement overlays (#1422)
Measure can show the world-axis components alongside the total distance, with a Graphics preference to turn them off.

Co-authored-by: cronos <19868002+crono2250@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 19:09:26 -04:00
3d914c9f15 Agent chat gate: curve and occurrence ids named path are not files; broken tool arguments are not run (#1499)
Co-Authored-By: Rec0iL <Rec0iL@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 19:01:09 -04:00
1b5c2b5847 New tab: no stale fillet preview, and + always opens a tab (#1533)
Co-Authored-By: Pablo López <pablopeza@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 19:01:04 -04:00
7160ff7beb A sketch on an extrude's cap keeps its face when the extrude is made thin (#1532)
Thin extrude caps are certified by the curves the wall follows, so a sketch attached to a cap survives switching between solid and thin (#1519).

Co-Authored-By: Philipp Bischoff <PhilippBischoff0@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:55:17 -04:00
b400958c3e render: add isotropic texture footprint LOD reference (#1460)
Co-Authored-By: selune <0xlillith@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:55:14 -04:00
1c9d6617ad Add Turkish translation (#1520)
Registers a Turkish (tr) catalog with its attribution, docs and a test.

Co-authored-by: macrulz54 <macrulz54@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:47:46 -04:00
f7967b1ff6 Joints: gears, rack and pinion, and screws (#1524)
Motion links can carry a coupling (gear pair, rack and pinion, screw) whose ratio follows radii, picked round faces or pitch. Design format 23.

Co-authored-by: SychPL <SychPL@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:47:38 -04:00
a519f3cf74 Web app: 3D mouse navigation through the Gamepad API (#1473)
The web app reads a 3D mouse through the Gamepad API and drives the camera through navigation::device, so it follows each page's navigation capabilities (#1336).

Co-authored-by: CamWight <CamWight@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:42:44 -04:00
b44ada89e5 Preferences: choose the interface font (#1408)
Adds an Interface font setting that leads the proportional family, with the default face and CJK/Hangul fallbacks kept behind it (#1290).

Co-authored-by: DuBearte <DuBearte@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:40:07 -04:00
bherbruck 6128a20c5f Agent chat on Windows: start npm agents through their .cmd shim (#1530) 2026-10-15 18:36:41 -04:00
cronosandClaude Sonnet 5.5 73493b94c3 Measure circle and arc centres as fixed 3D points (#1514)
Picked circles and arcs keep their own centres when measured; only a cylindrical face uses its axis.

Co-authored-by: cronos <crono2250@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:04:17 -04:00
061c0f3333 render: add perspective UV interpolation and derivatives (#1461)
Co-Authored-By: selune <0xlillith@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:04:09 -04:00
3dd8e2d176 timeline.edit: refuse fields the feature's definition doesn't have (#1507)
A field the definition doesn't have is refused with the fields it does have, instead of reporting success and changing nothing (#1491).

Co-Authored-By: Erikas <erkexzcx@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:03:32 -04:00
bherbruck 66d13059dd Release 0.13.1 2026-10-15 21:56:43 +00:00
bherbruckandClaude Opus 5.5 a13124acdd Import glTF 2.0 files (.glb, .gltf) as mesh bodies (#1512)
glTF files open, insert and drop like 3MF/STL/OBJ: a component named after the file, the node tree as nested rigid components, repeated meshes as occurrences, metres to mm, Y up turned to Z up by default (up: z keeps axes), materials as PBR appearances. Bounded, hostile-input tested reader; no new persisted data. Closes #1505.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:27:29 -04: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
bherbruckandClaude Opus 5.5 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
bherbruckandClaude Opus 5.5 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
bherbruckandClaude Opus 5.5 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
bherbruckandClaude Opus 5.5 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
412 changed files with 54148 additions and 9543 deletions

No files matched your search

+4 -1
View File
@@ -3,7 +3,8 @@
[profile.ci]
# Heavy tests (one to four minutes each on a CI runner) that pull requests skip. They run on
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`.
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`. The
# boolean_props property tests run only nightly (ci.yml excludes them from pushes to main).
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
default-filter = """
not (
@@ -16,6 +17,8 @@ not (
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
))
| (package(solidcraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
| (package(solidcraft-kernel) & binary(boolean_props))
| (package(solidcraft-engine) & binary(atomic_commands))
| (package(solidcraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
)
"""
+91 -7
View File
@@ -1,4 +1,10 @@
# Fast checks always run; native builds run for code/CI changes.
# PRs test what they touch; main and nightly test everything. On a pull request the "Select
# checks" job maps the changed files to workspace packages (scripts/ci/affected.py), adds the
# packages that depend on them, and Clippy, the test archive and the test shards cover only
# those. Changes to the lockfile, root manifest, toolchain/cargo/nextest config, .github/,
# vendor/, tests/, scripts/ or any file no package owns select the whole workspace, as do
# pushes to main, the nightly run and Run workflow.
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
# skipped on pull requests and run on pushes to main and nightly.
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
@@ -49,6 +55,8 @@ jobs:
outputs:
native: ${{ steps.paths.outputs.native }}
kernel: ${{ steps.paths.outputs.kernel }}
full: ${{ steps.paths.outputs.full }}
packages: ${{ steps.paths.outputs.packages }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -62,6 +70,8 @@ jobs:
run: |
native=false
kernel=false
full=true
packages=
if [ "$EVENT_NAME" != pull_request ]; then
native=true
else
@@ -76,9 +86,20 @@ jobs:
*) native=true ;;
esac
done < "$RUNNER_TEMP/ci-paths"
# Pull requests build and test the touched packages and their dependents.
python3 -I scripts/ci/affected.py --paths "$RUNNER_TEMP/ci-paths" > "$RUNNER_TEMP/ci-affected"
cat "$RUNNER_TEMP/ci-affected"
full=$(sed -n 's/^full=//p' "$RUNNER_TEMP/ci-affected")
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
# Code changed but nothing was selected: be safe and test everything.
if [ "$native" = true ] && [ "$full" != true ] && [ -z "$packages" ]; then full=true; fi
fi
echo "native=$native" >> "$GITHUB_OUTPUT"
echo "kernel=$kernel" >> "$GITHUB_OUTPUT"
{
echo "native=$native"
echo "kernel=$kernel"
echo "full=$full"
echo "packages=$packages"
} >> "$GITHUB_OUTPUT"
checks:
name: Format and repository checks
@@ -102,6 +123,10 @@ jobs:
run: cargo xtask layers
- name: Web app offline use and updates (headless Chrome)
run: cargo xtask web-offline-test
- name: Affected-package selection self-test
run: python3 -I scripts/ci/test_affected.py
- name: Claim bot tests
run: node --test scripts/claim/*.test.js
clippy:
name: Native Linux Clippy
@@ -117,7 +142,15 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-clippy
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
- name: Clippy
env:
FULL: ${{ needs.changes.outputs.full }}
PACKAGES: ${{ needs.changes.outputs.packages }}
run: |
# Pull requests lint the affected packages (scripts/ci/affected.py); everything else, all.
scope=(--workspace)
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
cargo clippy "${scope[@]}" --all-targets --profile ci --locked -- -D warnings
build-tests:
name: Build tests
@@ -144,7 +177,14 @@ jobs:
with:
tool: cargo-nextest
- name: Compile tests into a nextest archive
run: cargo nextest archive --workspace --cargo-profile ci --locked --archive-file tests.tar.zst
env:
FULL: ${{ needs.changes.outputs.full }}
PACKAGES: ${{ needs.changes.outputs.packages }}
run: |
# Pull requests archive only the affected packages (scripts/ci/affected.py).
scope=(--workspace)
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
cargo nextest archive "${scope[@]}" --cargo-profile ci --locked --archive-file tests.tar.zst
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nextest-archive
@@ -164,8 +204,18 @@ jobs:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
env:
# Pictures (snapshots, screenshots, ui.render, the viewport's offscreen tests) are drawn on
# the GPU. The runner has none: Mesa's lavapipe (software Vulkan) draws them, and a test that
# would skip for want of an adapter fails instead.
SOLIDCRAFT_GPU: software
SOLIDCRAFT_REQUIRE_GPU: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install a software Vulkan driver (lavapipe)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
@@ -179,11 +229,15 @@ jobs:
run: |
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
all=()
if [ "$EVENT_NAME" != pull_request ]; then all=(--ignore-default-filter); fi
# The boolean property tests draw new random inputs every run; they run only in the
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
if [ "$EVENT_NAME" != pull_request ]; then
all=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
fi
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
# the build job's path, which is the same checkout path here.
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --partition "slice:$SHARD/4" "${all[@]}"
--profile ci --partition "slice:$SHARD/4" --no-tests=pass "${all[@]}"
- name: Robustness goldens sample (pull requests that touch the kernel)
# Every 8th seed of tests/robustness/goldens.json, a quarter per shard; pushes to main and
# the nightly run every seed in the step above.
@@ -267,6 +321,33 @@ jobs:
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --success-output final --run-ignored only -E "binary(action_matrix) & test(=action_matrix_determinism)"
property-tests:
# The boolean property tests and the atomic command tests with many cases. Pull requests skip
# them (.config/nextest.toml); pushes to main run atomic_commands with small defaults (seeds
# 0..4) and skip boolean_props, whose random inputs would fail main at random.
name: Property tests (many cases)
needs: build-tests
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Boolean properties and atomic commands
# The defaults (8 cases, seeds 0..4) take 159 s and 802 s on a 2-core VM; four times the
# cases and twice the seeds are estimated at about 40 minutes there (not yet timed on CI).
env:
PROPTEST_CASES: "32"
ATOMIC_SEEDS: "0..8"
run: |
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --ignore-default-filter --success-output final -E "binary(boolean_props) | binary(atomic_commands)"
doc-tests:
name: Doc tests
needs: changes
@@ -326,7 +407,8 @@ jobs:
# D3D12's older shader compiler (FXC, used where dxcompiler.dll is absent) is stricter than
# DXC, Vulkan and Metal: a shader it cannot compile once crashed the app at startup. Build the
# GPU tessellation pipeline with FXC on the runner's WARP software adapter and compare its
# results with the CPU.
# results with the CPU, and draw headless pictures with the viewport's pipelines there
# (crates/gpu/tests/capture.rs).
windows-fxc:
name: Windows D3D12 FXC shaders
needs: changes
@@ -336,6 +418,8 @@ jobs:
env:
WGPU_BACKEND: dx12
WGPU_DX12_COMPILER: fxc
# WARP is a software (Cpu) adapter.
SOLIDCRAFT_GPU: software
SOLIDCRAFT_REQUIRE_GPU: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+44
View File
@@ -0,0 +1,44 @@
# `/claim` and `/unclaim` on issues (CONTRIBUTING.md, "Sending a change"), and a daily sweep that
# frees claims nobody is working on. The logic is in scripts/claim/claim.js (tested in ci.yml).
name: Claim
on:
issue_comment:
types: [created]
schedule:
- cron: '17 5 * * *'
workflow_dispatch:
permissions:
contents: read # to check out scripts/claim
issues: write
pull-requests: read
# One run at a time per issue, so two simultaneous /claim comments can't both win.
concurrency:
group: claim-${{ github.event.issue.number || 'sweep' }}
cancel-in-progress: false
jobs:
claim:
name: Claim bot
runs-on: ubuntu-24.04
timeout-minutes: 10
if: >-
github.event_name != 'issue_comment' ||
(!github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
(startsWith(github.event.comment.body, '/claim') || startsWith(github.event.comment.body, '/unclaim')))
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/claim
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const claim = require('./scripts/claim/claim.js');
if (context.eventName === 'issue_comment') {
await claim.handleComment({ github, context });
} else {
const released = await claim.sweep({ github, context });
core.info(`Released: ${released.join(', ') || 'none'}`);
}
+48
View File
@@ -0,0 +1,48 @@
# Files generated from the code are rewritten here, on main, after each merge, so pull requests
# never edit them (two pull requests that each regenerated one would always conflict):
# THIRD-PARTY-LICENSES.txt (`cargo xtask licences`) and docs/features.md (`cargo xtask features`).
name: Generated files
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
# One run at a time; a burst of merges leaves one run waiting, which sees all of them.
concurrency:
group: generated-files
cancel-in-progress: false
jobs:
regenerate:
name: Regenerate and commit
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
# Always main itself, never pull request code, since this job can push.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: generated-files
- name: THIRD-PARTY-LICENSES.txt
run: cargo xtask licences
- name: docs/features.md
run: cargo xtask features
- name: Commit if changed
run: |
if git diff --quiet -- THIRD-PARTY-LICENSES.txt docs/features.md; then echo "unchanged"; exit 0; fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add THIRD-PARTY-LICENSES.txt docs/features.md
git commit -m "Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]"
for _ in 1 2 3; do
git pull --rebase origin main && git push origin HEAD:main && exit 0
sleep 10
done
exit 1
+32
View File
@@ -0,0 +1,32 @@
# Files many pull requests add to are kept so that additions don't conflict: one file per change
# or one sorted line per item (CONTRIBUTING.md, "Lists many changes add to"). Fast, no build.
name: Lists
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
format-changes:
name: Design format changes merge cleanly
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: bash scripts/test-format-pending.sh
sorted:
name: Lists are sorted one item per line
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: lists
- run: cargo xtask lists
+7
View File
@@ -45,7 +45,14 @@ jobs:
curl -sSL "https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
curl -sSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install a software Vulkan driver (lavapipe)
# The book's pictures are `solidcraft-cli snapshot`s, drawn on the GPU; the runner has none.
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
- name: Build the book
env:
SOLIDCRAFT_GPU: software
run: cargo xtask book
- name: Build the web app
working-directory: apps/solidcraft-web
+25 -2
View File
@@ -123,6 +123,23 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
keep monitoring it while the task is active and make its pending status clear at handoff;
do not claim it merged without checking.
## Claim = a draft PR
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
issue wins**; if yours lost, close it. Don't claim by commenting or by editing files.
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
comment on it first, then open your own claim (you may start from its branch).
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
and wait while it is over 24; build with `nice`.
## Design file format
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
works out the number (the next one while anything is pending), and the release folds pending
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
@@ -153,5 +170,11 @@ before the draft is verified.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
commands by area, from the registry) when commands are added or renamed.
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
workflow; never regenerate them in a PR.
## Lists many PRs add to
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
then by id, generated files left to `main`, and a design-format change as one new file.
+29 -1
View File
@@ -16,7 +16,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|---|---|---|---|
| examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 |
| examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 |
| crates/ui-egui/src/pbr_gpu.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
| crates/gpu/src/pbr.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.svg | SolidCraft contributors | original macOS adaptation of the code-drawn SolidCraft mark, edited and exported with VectorCraft; Apple HIG and production templates consulted as layout references only | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 |
@@ -36,6 +36,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| docs/screenshots/class-a-boundary.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch through two sketch lines built by `surface.boundary` with `crown: 0.4` (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 |
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
@@ -96,6 +97,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/tr.json | macrulz54 | Turkish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
@@ -109,6 +111,8 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/web/loading.css | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| crates/io/src/native_cad/fixtures/block.FCStd | SolidCraft contributors | original 10 × 20 × 30 mm test box saved by FreeCAD 1.1.4 run as an external program (`make_block_fcstd.py` beside it); no third-party geometry | MIT OR Apache-2.0 |
| crates/io/src/native_cad/fixtures/block.3dm | SolidCraft contributors | original 10 × 20 × 30 mm test box written with McNeel's MIT-licensed rhino3dm Python package (`make_block_3dm.py` beside it); no third-party geometry | MIT OR Apache-2.0 |
| docs/book/src/og.png | SolidCraft contributors | copy of assets/branding/web/source/og.png staged by cargo xtask book (gitignored) | MIT OR Apache-2.0 |
@@ -139,6 +143,27 @@ work cooperative and adds checked run lengths, zero-run rejection, width validat
header/input/radiance bounds and cancellation. Original licence texts are retained
in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
## File-format crates
| Crate | Used for | Licence |
|---|---|---|
| gltf 1.4.1, gltf-json 1.4.1, gltf-derive 1.4.1 (https://github.com/gltf-rs/gltf) | glTF 2.0 import (crates/io/src/gltf_in.rs): the JSON document model and its reference checks; the GLB container, buffers and accessors are read by SolidCraft's own code | MIT OR Apache-2.0 |
| inflections 1.1.1 (build-time, through gltf-derive) | case conversion in gltf's derive macros | MIT |
| lazy_static 1.5.1 (through gltf-json) | | MIT OR Apache-2.0 |
The glTF test files are built by the tests themselves (crates/io/src/gltf_in_tests.rs,
crates/engine/src/tests/gltf_import.rs); no third-party models are committed.
## FreeCAD and Rhino import library
FreeCAD `.FCStd` and Rhino `.3dm` files are read with [cadmpeg](https://github.com/cadmpeg/cadmpeg)
0.6.1 (Apache-2.0), linked as the crates `cadmpeg-core`, `cadmpeg-ir`, `cadmpeg-registry`,
`cadmpeg-container`, `cadmpeg-codec-freecad`, `cadmpeg-codec-rhino` and `cadmpeg-codec-step`
in native builds (`crates/io/src/native_cad.rs`). Only those open-format codecs are built: no
default features, so cadmpeg's codecs for commercial CAD formats and its ShapeManager/ACIS
codec are not compiled in. Its licence text is in THIRD-PARTY-LICENSES.txt; no code from it is
copied into SolidCraft.
## Recovered contributions
| SolidCraft file | Original contribution | Licence |
@@ -152,6 +177,7 @@ in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) |
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
## Vendored renderer compatibility fix
@@ -162,3 +188,5 @@ its original `egui.wgsl` shader are retained; no images or other artwork are cop
SolidCraft's narrow patch selects a safe 1x MSAA path on the chosen OpenGL adapter
and shares that effective count with the surface attachments and CAD callbacks.
Vulkan, DirectX 12, Metal and WebGPU retain their requested multisampling.
A frame whose meshes the GPU refuses to stage (device lost or out of memory) is
skipped with a warning instead of panicking (#1469).
+25 -2
View File
@@ -96,6 +96,23 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
## Claim = a draft PR
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
issue wins**; if yours lost, close it. Don't claim by commenting or by editing files.
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
comment on it first, then open your own claim (you may start from its branch).
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
and wait while it is over 24; build with `nice`.
## Design file format
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
works out the number (the next one while anything is pending), and the release folds pending
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
@@ -126,5 +143,11 @@ before the draft is verified.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
commands by area, from the registry) when commands are added or renamed.
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
workflow; never regenerate them in a PR.
## Lists many PRs add to
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
then by id, generated files left to `main`, and a design-format change as one new file.
+74 -7
View File
@@ -98,8 +98,8 @@ The [README](README.md#architecture) has the layer table. The short version:
MIT OR Apache-2.0, and its solver and blends are written from the maths. (Running an
open-source application to get reference values for a test is fine; reading its source is
not.) Permissive
dependencies are fine. Note which one you're adding and why in the PR, and run
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
dependencies are fine. Note which one you're adding and why in the PR. THIRD-PARTY-LICENSES.txt
lists it once the change lands: the Generated files workflow rewrites it on `main`.
Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see
[the local command and coverage limits](docs/book/src/dev/testing.md#dependency-advisories).
3. **Every asset is original or openly licensed and has a row in
@@ -163,6 +163,13 @@ in the PR. `cargo xtask test` builds the tests with it and then runs every test
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
depend on wall-clock timing.
**PRs test what they touch; main and nightly test everything.** On a pull request, CI maps the
changed files to workspace packages and runs Clippy, the test build and the tests for those and
the packages that depend on them (`scripts/ci/affected.py`, self-tested in the `checks` job). A
change to `Cargo.lock`, the root `Cargo.toml`, `.cargo/`, `.config/`, `.github/`, `vendor/`,
`tests/`, `scripts/` or any file outside a package runs the whole workspace, as does every push
to `main`, the nightly run and Run workflow.
**CI shards the tests.** One job builds them into a cargo-nextest archive and four jobs run a
quarter each, so every test runs in its own process: don't rely on state left by another test.
Pull requests skip a few heavy tests (one to four minutes each), which run on every push to
@@ -173,8 +180,9 @@ Pull requests skip a few heavy tests (one to four minutes each), which run on ev
`tests::robustness_goldens` (pull requests that touch `crates/kernel` run its `sample_*` part),
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, and the `boolean_props` property tests
(these run only in the nightly "Property tests" job: their inputs are random, so main skips them too)
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
**The action matrix** tries every ordinary action on every face, edge, vertex and body of a
@@ -192,18 +200,75 @@ the current results, and CI runs `cargo xtask oracle` on every pull request. A c
because of a known bug names its issue in `expected.json` (`"known_failure": "#123"`); never
change an expected value to match SolidCraft's output.
## Changing the design file format
A design file says which format it is in (`"format": "solidcraft/N"`). If your change saves
something new in a design (a field, a feature type, a new kind of name), or makes older designs
mean something else, it needs a format change. **Add one file, and edit nothing shared:**
1. Create `crates/doc/src/format/pending/<what-it-is>.rs` (lowercase, dashes). Start it with a
`//!` note: what is saved, what older designs get without it, and that the last release asks
for an update instead of meeting it.
2. If older designs must be rewritten as they open, put `pub fn step(v: &mut Value) ->
crate::Result<()>` in the same file. Pending steps run in file-name order, after the older
formats' steps.
3. Don't touch `FORMAT`, `JSON_STEPS`, the history in `crates/doc/src/format.rs`, another
change's file, or any `v<N>/` folder: those are fixed once released.
That's all. The build counts the files: while any are pending it writes the next format number,
which no release reads, so a release refuses a design that has your change instead of losing it.
A file also lists the pending changes it has (`format_pending`), so a later development build
runs only the steps that file is missing. Two pull requests that each add a change merge without
a conflict. `crates/doc/src/format/pending/README.md` has an example.
**At release time** the maintainer runs `scripts/fold-format-pending.sh`, which moves the pending
files into `crates/doc/src/format/v<N>/` and fixes that number for good (see
[docs/releasing.md](docs/releasing.md)). Never add a file to a `v<N>/` folder afterwards.
## Lists many changes add to
Some files gain an item in many pull requests at once. They are kept so that two changes that
each add an item only meet when their items are neighbours, which spares everyone a conflict
and a fresh CI run:
- **Message catalogs** (`assets/locales/*.json`): one message per line, sorted by id. Add your
message anywhere and run `cargo xtask lists --fix`; `cargo xtask lists` (in `cargo xtask ci`
and the Lists workflow) fails on an unsorted catalog or an id that appears twice.
- **`mod` lines**: one per line, in the sorted group they belong to (`cargo fmt` keeps each group
sorted). Add a new module into the existing group, not as a new group at the end.
- **Languages** (`crates/ui-egui/src/i18n.rs`): one per line, English first, then by id (a test
checks it).
- **Generated files**: `THIRD-PARTY-LICENSES.txt` and `docs/features.md` are rewritten on `main`
after each merge by the Generated files workflow (`cargo xtask licences`, `cargo xtask
features`). Don't regenerate or edit them in a pull request.
- **Design format**: one file per change, never an edit to a shared number (above).
## Sending a change
1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solidcraft/issues), labelled
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). Before starting,
comment on the issue so it gets the `in progress` label and two people don't build the same
thing. For anything bigger than a bug fix that has no issue yet, open one first.
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). For anything
bigger than a bug fix that has no issue yet, open one first. **Claim the issue with a draft
PR** before you start, so two people don't build the same thing:
- Check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
- Branch from the latest `main`, commit `git commit --allow-empty -m "claim: #N"`, push, and
open a **draft** PR titled `<summary> (#N)`, e.g. `Fillet: variable radius along a chain (#123)`.
- **The lowest-numbered open PR for an issue wins.** If yours lost, close it (or offer to help
on the other one).
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
comment on it first, then open your own claim.
- Mark the PR ready for review when it is done.
2. Branch from `main` and keep the PR to one topic.
3. Run `cargo xtask ci` before pushing. It runs fmt, the asset, licence and layer checks,
clippy (`-D warnings`), then the tests with the wasm build check alongside. A PR has to pass it.
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
the expected ones).
Comment `/claim` on an issue to take it: you are assigned, it gets the `in progress` label, and
the bot replies with the deadline. Open a draft PR referencing the issue within 72 hours, or the
claim lapses and the issue is freed again; a claim with a PR is freed after 14 days without
pushes. `/unclaim` releases it early (only you or a maintainer can). Issues a maintainer has
taken are never freed automatically.
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
include actual descriptive screenshots or a short recording in the issue or PR. This evidence
is required for changes or bugs that can be demonstrated in the app, including kernel,
@@ -232,6 +297,8 @@ long as the change meets the rules above.
## Where help is wanted most
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; it is being replaced by [solidcore](https://github.com/bherbruck/solidcore) ([progress](https://bherbruck.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/bherbruck/solidcraft/issues/1597).
See the [`help wanted`](https://github.com/bherbruck/solidcraft/labels/help%20wanted) and
[`good first issue`](https://github.com/bherbruck/solidcraft/labels/good%20first%20issue) labels.
In order of how much they'd help users:
Generated
+471 -42
View File
@@ -131,7 +131,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpufeatures 0.2.17",
]
[[package]]
@@ -489,6 +489,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
@@ -582,6 +591,117 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadmpeg-codec-freecad"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a5257265c2549cf59a8457b3c7aadcc96c95abe20205232553766993a808812"
dependencies = [
"cadmpeg-container",
"cadmpeg-core",
"cadmpeg-ir",
"roxmltree",
"serde",
"serde_json",
"tempfile",
"zip 8.6.0",
]
[[package]]
name = "cadmpeg-codec-rhino"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d88b42fb91c7e5af4c75c0433029771f7e3233a0b72ec3d0b2e5ff2c72bed07"
dependencies = [
"cadmpeg-container",
"cadmpeg-core",
"cadmpeg-ir",
"crc32fast",
"flate2",
"roxmltree",
"serde",
"serde_json",
"sha1 0.11.0",
"sha2 0.11.1",
"tempfile",
]
[[package]]
name = "cadmpeg-codec-step"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83a52258c129a683f77fb360ca30af54ae34339625ad890be12621128a5811c"
dependencies = [
"base64",
"cadmpeg-container",
"cadmpeg-core",
"cadmpeg-ir",
"encoding_rs",
"serde_json",
"thiserror 2.0.21",
"zip 8.6.0",
]
[[package]]
name = "cadmpeg-container"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c110508cd9155e905f52906de505dfd42dc70a0321f5e90b5f68c28ba207686e"
dependencies = [
"cadmpeg-core",
"crc32fast",
"flate2",
"zip 8.6.0",
"zstd 0.14.2",
]
[[package]]
name = "cadmpeg-core"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b1147e51a74e7c52da5b393a0344597238cdc622fac6443eb2fd526759f6fa"
dependencies = [
"flate2",
"memchr",
"roxmltree",
"serde",
"serde_json",
"thiserror 2.0.21",
"zstd 0.14.2",
]
[[package]]
name = "cadmpeg-ir"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71ff19f019f013d97e353bb802c5d2e253c2859aaa5422009aa933dc2f2e7dbc"
dependencies = [
"base64",
"cadmpeg-core",
"serde",
"serde-value",
"serde_json",
"sha2 0.11.1",
"thiserror 2.0.21",
]
[[package]]
name = "cadmpeg-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66046f1a694926707bea8944f861e699964b04c68972708588f97e14ccee3c5d"
dependencies = [
"cadmpeg-codec-freecad",
"cadmpeg-codec-rhino",
"cadmpeg-codec-step",
"cadmpeg-container",
"cadmpeg-core",
"cadmpeg-ir",
"serde",
"thiserror 2.0.21",
"toml",
]
[[package]]
name = "calloop"
version = "0.13.0"
@@ -685,6 +805,17 @@ dependencies = [
"serde",
]
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -704,7 +835,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"inout",
]
@@ -756,6 +887,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -812,6 +949,12 @@ dependencies = [
"libc",
]
[[package]]
name = "core_detect"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -821,6 +964,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -871,6 +1023,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -902,7 +1063,7 @@ dependencies = [
"hkdf",
"num",
"once_cell",
"sha2",
"sha2 0.10.9",
"zeroize",
]
@@ -936,11 +1097,22 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -1155,6 +1327,20 @@ dependencies = [
"winreg",
]
[[package]]
name = "encoding_rs"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
dependencies = [
"cfg-if",
"core_detect",
"multiversion_no_op",
"rustversion",
"scopeguard",
"simdutf8",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1490,6 +1676,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
]
[[package]]
@@ -1531,6 +1718,42 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gltf"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7"
dependencies = [
"byteorder",
"gltf-json",
"lazy_static",
"serde_json",
]
[[package]]
name = "gltf-derive"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51"
dependencies = [
"inflections",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "gltf-json"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14"
dependencies = [
"gltf-derive",
"serde",
"serde_derive",
"serde_json",
]
[[package]]
name = "glutin"
version = "0.32.3"
@@ -1704,7 +1927,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -1723,6 +1946,15 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -1877,6 +2109,12 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inflections"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a"
[[package]]
name = "inout"
version = "0.1.4"
@@ -2047,6 +2285,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "libc"
version = "0.2.190"
@@ -2204,6 +2448,12 @@ dependencies = [
"pxfm",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "naga"
version = "30.0.1"
@@ -2719,6 +2969,15 @@ dependencies = [
"libredox",
]
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]]
name = "ordered-float"
version = "5.5.0"
@@ -3013,6 +3272,22 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "proptest"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
dependencies = [
"bitflags 2.13.2",
"chacha20",
"core_detect",
"num-traits",
"rand 0.10.3",
"rand_xorshift",
"regex-syntax",
"unarray",
]
[[package]]
name = "pxfm"
version = "0.1.30"
@@ -3063,7 +3338,17 @@ checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
@@ -3073,7 +3358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@@ -3085,6 +3370,21 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_xorshift"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -3176,6 +3476,12 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "renderdoc-sys"
version = "1.1.0"
@@ -3243,6 +3549,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -3413,9 +3728,9 @@ dependencies = [
"hkdf",
"num",
"once_cell",
"rand",
"rand 0.8.8",
"serde",
"sha2",
"sha2 0.10.9",
"zbus 4.4.0",
]
@@ -3477,6 +3792,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float 2.10.1",
"serde",
]
[[package]]
name = "serde_core"
version = "1.0.229"
@@ -3537,8 +3862,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
@@ -3548,8 +3884,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
@@ -3704,7 +4051,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -3733,16 +4080,18 @@ dependencies = [
[[package]]
name = "solidcraft-cli"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"image",
"serde_json",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-mcp",
]
[[package]]
name = "solidcraft-derive"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"proc-macro2",
"quote",
@@ -3751,12 +4100,12 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"log",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"solidcraft-derive",
"solidcraft-geom",
"solidcraft-kernel",
@@ -3768,7 +4117,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"log",
"serde",
@@ -3784,7 +4133,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"serde",
"serde_json",
@@ -3793,37 +4142,46 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"log",
"naga",
"pollster 0.4.0",
"serde",
"serde_json",
"solidcraft-engine",
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-render",
"thiserror 2.0.21",
"wgpu",
]
[[package]]
name = "solidcraft-io"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
"cadmpeg-registry",
"gltf",
"quick-xml",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-kernel",
"thiserror 2.0.21",
"zip",
"zip 3.0.0",
]
[[package]]
name = "solidcraft-kernel"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"log",
"proptest",
"rayon",
"serde",
"serde_json",
@@ -3839,28 +4197,29 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"serde_json",
"solidcraft-engine",
"solidcraft-gpu",
]
[[package]]
name = "solidcraft-render"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"image",
"moxcms",
"png",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"solidcraft-geom",
]
[[package]]
name = "solidcraft-sketch"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"serde",
"serde_json",
@@ -3871,7 +4230,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -3882,17 +4241,17 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"solidcraft-engine",
"solidcraft-gpu",
"ttf-parser",
"web-time",
"zip",
"zip 3.0.0",
]
[[package]]
name = "solidcraft-web"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"eframe",
"egui",
@@ -4193,8 +4552,6 @@ dependencies = [
[[package]]
name = "truck-geometry"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d9d85f203fdbc2d206a38b8e7c4417a05a2eb589be8b81ecfe9f98faafc425d"
dependencies = [
"serde",
"thiserror 1.0.69",
@@ -4209,7 +4566,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a31fe98d5a0589ad20576260e214af23dea21a8d125da81ad3bbcb666f411f"
dependencies = [
"getrandom 0.2.17",
"rand",
"rand 0.8.8",
"thiserror 1.0.69",
"truck-base",
"truck-derivers",
@@ -4321,6 +4678,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typeid"
version = "1.0.3"
@@ -4344,6 +4707,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-general-category"
version = "1.1.0"
@@ -4892,7 +5261,7 @@ dependencies = [
"objc2-metal 0.3.2",
"objc2-quartz-core 0.3.2",
"once_cell",
"ordered-float",
"ordered-float 5.5.0",
"parking_lot",
"portable-atomic",
"portable-atomic-util",
@@ -5381,10 +5750,10 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"serde_json",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -5434,10 +5803,10 @@ dependencies = [
"hex",
"nix",
"ordered-stream",
"rand",
"rand 0.8.8",
"serde",
"serde_repr",
"sha1",
"sha1 0.10.7",
"static_assertions",
"tracing",
"uds_windows",
@@ -5686,6 +6055,20 @@ dependencies = [
"zopfli",
]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"typed-path",
"zstd 0.13.3",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
@@ -5710,6 +6093,52 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe 7.3.0",
]
[[package]]
name = "zstd"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "057cfd910cfac363a0ada849592624b4c9ff2e10bef504c3433810d78ed96f93"
dependencies = [
"zstd-safe 8.1.0",
]
[[package]]
name = "zstd-safe"
version = "7.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-safe"
version = "8.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd44c6a7284e91f3717755b24315a302edd9153a01f753c3cba3d765e8eafac"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.1.1+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.5.3"
+13 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.13.1"
version = "0.14.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -53,6 +53,17 @@ rayon = "1.12"
# packages (3MF)
zip = { version = "3", default-features = false, features = ["deflate"] }
quick-xml = "0.41"
# glTF 2.0 import (MIT OR Apache-2.0): the document model and GLB container only; buffers are
# read by crates/io itself, so no image decoding or file access comes with it.
gltf = { version = "1.4", default-features = false, features = ["names"] }
# FreeCAD and Rhino import (Apache-2.0, pure Rust apart from zstd; native builds only).
# Pinned: its IR and codec APIs change between minor versions. Open formats only: no default
# features, so cadmpeg's codecs for commercial formats (f3d, sldprt, inventor, catia, creo, nx)
# and its ShapeManager/ACIS `sat` codec are not compiled in. `step` is the writer we restate through.
cadmpeg-core = "=0.6.1"
cadmpeg-ir = "=0.6.1"
cadmpeg-registry = { version = "=0.6.1", default-features = false, features = ["fcstd", "rhino", "step"] }
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
@@ -115,6 +126,7 @@ debug = "line-tables-only"
# truck-shapeops with SolidCraft fixes (Apache-2.0, see vendor/truck-shapeops and ATTRIBUTION.md).
[patch.crates-io]
truck-geometry = { path = "vendor/truck-geometry" }
truck-shapeops = { path = "vendor/truck-shapeops" }
truck-stepio = { path = "vendor/truck-stepio" }
truck-meshalgo = { path = "vendor/truck-meshalgo" }
+17 -5
View File
@@ -82,6 +82,9 @@ rebuild with no errors, replayed here at 2× speed.
## What works
- **Current-view DXF export** from File → Export Current View as DXF, with hidden edges
omitted or dashed, shown body placements and the current camera. See [view DXF](docs/view-dxf.md).
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
arcs, polygons, slots, ellipses, fit/control splines, conics and text; construction geometry,
projection, trimming, fillets and chamfers, including corners at the origin.
@@ -129,13 +132,16 @@ rebuild with no errors, replayed here at 2× speed.
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ, and
assemblies with joints as URDF for robot simulators (docs/urdf.md);
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
3MF, STL, OBJ and glTF 2.0 (`.glb`/`.gltf`, its node tree as components and its materials as
appearances) import as mesh bodies (they render, measure, move and export; solid features
need B-rep bodies). The `mesh.convert` command reconstructs supported closed constant-section parts
as parametric solids with planar and circular cylindrical faces;
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)); FreeCAD
`.FCStd` and Rhino `.3dm` solids import the same way through
[cadmpeg](https://github.com/cadmpeg/cadmpeg) in the desktop app and CLI.
- **Render:** opaque surfaces can use metal fraction, roughness and dielectric IOR in the
integrated product viewport and CPU preview PNGs. Existing simple colors remain supported.
Fixed direct lighting is a preview; HDRI, refraction and texture maps are not implemented.
@@ -178,7 +184,13 @@ Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p s
On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
page in Preferences has an on/off switch and a speed control for the 3D mouse.
page in Preferences has an on/off switch and a speed control for the 3D mouse. The web app reads
it too, through the browser; Firefox needs a 3DxWare setting first (see
[Getting started › 3D mouse in the browser](docs/book/src/guide/getting-started.md#3d-mouse-in-the-browser)).
With 3DxWare on Windows, SolidCraft follows 3DxWare's profile for it when there is one, and
3DxWare's object mode (tilt to roll, twist to spin) until there is. The mouse-wheel turns that
3DxWare makes from the cap's motion are ignored, so the view doesn't zoom in steps as the cap tilts.
On Linux SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the
device. A udev rule that adds the `uaccess` tag does that, for example
@@ -228,9 +240,9 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
| Layer | Crates | Role |
|---|---|---|
| L0 | `geom` | vectors, planes, profiles, meshes and their measures |
| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and CPU rasterizer |
| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and the headless scene description |
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL import features |
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL/OBJ/glTF import features |
| L4 | `engine` | the session and the command registry (everything is a command) |
| L5 | `gpu`, `ui-egui`, `mcp` | compute-shader evaluation of display tessellation plans; the swappable desktop front end; the MCP server (headless or bridged to the app) |
| apps | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
+17 -6
View File
@@ -7,7 +7,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
| Question | Answer |
|---|---|
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; FreeCAD/Rhino import; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
| Reference tests | **82 / 82 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (61) or FreeCAD-computed (21) expected values, run by CI on every PR. |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
@@ -49,12 +49,15 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops of any shape and number of sides as one face, G0/G1/G2 to the faces beside each edge, through points, #759), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), gears, rack and pinion and screws (motion links whose ratio follows picked radii, parameters or a pitch, also inside closed loops), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Motion Trace (a picked point's path as a joint is driven or a study plays, closed loops following; kept as a 3D sketch spline), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Gear, Rack and Pinion, Screw and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), gears, rack and pinion and screws (motion links whose ratio follows picked radii, parameters or a pitch, also inside closed loops), inverse kinematics (`joint.reach`: a part's point to a target through the joints on its chain, within limits and with loops closed, nearest pose and its stopper when out of reach; dragging a jointed part in the viewport moves it as far as its joints allow), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Gear, Rack and Pinion, Screw and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
@@ -66,6 +69,11 @@ pointer movement orbits without a mouse button, and vertical scrolling zooms. Ot
retain their saved two-finger scroll preference; camera motion is available through
`view.navigate` and modifier-aware `ui.move` automation.
M11 precursor: File → Export Current View as DXF exports shown placed bodies from the current
camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits
partly hidden edges and includes curved silhouettes. Curves are approximated; drawing sheets,
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
## Interactive modelling
- New Body extrusion and face-offset previews show completed intermediate geometry while a
@@ -223,12 +231,13 @@ retain their saved two-finger scroll preference; camera motion is available thro
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
anywhere. See [crash reports](docs/crash-reports.md).
- Agent chat (off by default, desktop): a side panel that drives a coding agent the user
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, …) over the
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, Grok Build, …) over the
Agent Client Protocol, under the user's own login; SolidCraft stores no keys. The agent works
through SolidCraft's MCP tools on a gated endpoint of its own: file actions wait for Allow or
Deny, crash reports are off limits, 24 changes per message, one undo step per message, and
the agent's whole process group ends with the panel or the app. Docks left or right,
minimises to a strip. See [agent chat](docs/agent-chat.md).
minimises to a strip. Grok Build discovery includes `~/.grok/bin` and reuses the user's
Grok login. See [agent chat](docs/agent-chat.md).
- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`)
writes a design's history as a script that `eval` and `run` replay into the same design —
user parameters, units, sheet metal and plastic rules and materials as their own commands,
@@ -301,7 +310,9 @@ retain their saved two-finger scroll preference; camera motion is available thro
is an equivalent circular setback. Curved faces/edges, sloped ends and corner junctions remain unsupported.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
meet (a sphere for equal radii); perpendicular corner fillets after a closed ring of earlier
rounds preserve the ring's radii and the enclosed planar face (#1557);
straight edges (convex or concave) between planar faces with perpendicular
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
+931 -305
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -21,5 +21,10 @@ path = "src/alias.rs"
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-gpu = { workspace = true }
solidcraft-mcp = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
# Snapshot tests decode the pictures to compare them with a tolerance.
image = { workspace = true }
+63 -5
View File
@@ -34,7 +34,6 @@ use serde_json::{Value, json};
mod app_icon;
use solidcraft_engine::Session;
use solidcraft_engine::render::render_png;
use solidcraft_engine::view::Guides;
const USAGE: &str = "usage:
@@ -63,6 +62,10 @@ const USAGE: &str = "usage:
solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
solidcraft-cli licences
solidcraft-cli gpu [--policy auto|hardware|software|cpu]
(the GPU adapters a headless render can open, best first, the one the policy picks and the tier it
lands on: gpu, gpu-software, or the CPU rasterizer (auto with no adapter, or cpu); the policy defaults
to SOLIDCRAFT_GPU, else auto; exits with an error when hardware or software finds no such adapter)
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
";
@@ -75,6 +78,7 @@ fn main() -> ExitCode {
Some("snapshot") => cmd_snapshot(&args[1..]),
Some("render") => render::run(&args[1..]),
Some("exec") => cmd_exec(&args[1..]),
Some("gpu") => cmd_gpu(&args[1..]),
Some("commands") => {
use std::io::Write as _;
let mut s = Session::default();
@@ -213,6 +217,7 @@ fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
}
fn cmd_run(args: &[String]) -> Result<(), String> {
install_renderer();
let json_out = json_out(args)?;
let base = flag(args, "--in");
let path = positional(args);
@@ -334,13 +339,59 @@ fn cmd_script(args: &[String]) -> Result<(), String> {
Ok(())
}
/// `gpu`: the headless adapter survey and the policy's pick.
fn cmd_gpu(args: &[String]) -> Result<(), String> {
use solidcraft_gpu::select::{Policy, survey};
let policy = match args {
[] => Policy::from_env()?,
[flag, v] if flag == "--policy" => Policy::parse(v).ok_or_else(|| format!("--policy must be auto, hardware, software or cpu, not `{v}`"))?,
_ => return Err("usage: solidcraft-cli gpu [--policy auto|hardware|software|cpu]".into()),
};
let s = survey(policy);
let chosen = s.chosen.and_then(|i| s.adapters.get(i)).cloned();
// The tier pictures land on: a hardware GPU, a software adapter, or the CPU rasterizer.
let renderer = s.lands_on.map(|l| l.renderer());
println!(
"{}",
pretty(&json!({
"policy": policy.name(),
"backends": s.backends,
"adapters": s.adapters,
"chosen": chosen,
"lands_on": s.lands_on,
"renderer": renderer,
}))
);
match s.lands_on {
Some(solidcraft_gpu::select::Landing::Cpu) => {
eprintln!("warning: {}", solidcraft_engine::capture::CPU_WARNING);
Ok(())
}
Some(_) => Ok(()),
None => Err(format!("no adapter qualifies under the {} policy", policy.name())),
}
}
/// Draw this process's pictures (snapshots, MCP screenshots, saved thumbnails) on the GPU with the
/// viewport's pipelines: the headless device the `SOLIDCRAFT_GPU` policy picks, opened on the first
/// picture; under `auto` with no qualifying adapter (or `cpu`), the CPU rasterizer, and the
/// picture says so. Under `hardware` or `software` with no such adapter a picture fails with an
/// error that names the policy.
fn install_renderer() {
solidcraft_engine::capture::install(std::sync::Arc::new(|scene, cam, w, h| {
let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, cam, w, h)?;
Ok(solidcraft_engine::capture::Picture { rgba, renderer })
}));
}
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
install_renderer();
known_flags("snapshot", args, &["--in", "--out", "--width", "--height", "--view", "--camera"], &["--no-grid", "--no-axes"])?;
if positional(args).is_none() && flag(args, "--in").is_none() {
return Err(USAGE.to_string());
}
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
let size = |name: &str, default: usize| match flag(args, name) {
let size = |name: &str, default: u32| match flag(args, name) {
None => Ok(default),
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
};
@@ -364,9 +415,14 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
let cam = s.camera.unwrap_or_else(|| solidcraft_engine::view::home_camera(&s));
let guides = Guides { grid: !args.iter().any(|a| a == "--no-grid"), axes: !args.iter().any(|a| a == "--no-axes") };
let scene = solidcraft_engine::view::scene_with_guides(&s, &cam, s.section, guides);
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?;
println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()})));
let png = solidcraft_engine::capture::png(&scene, &cam, w, h)?;
std::fs::write(out, &png.bytes).map_err(|e| format!("{out}: {e}"))?;
let mut reply = json!({"path": out, "width": w, "height": h, "bytes": png.bytes.len(), "renderer": png.renderer});
if let Some(w) = solidcraft_engine::capture::warning(png.renderer) {
eprintln!("warning: {w}");
reply["warning"] = json!(w);
}
println!("{}", pretty(&reply));
Ok(())
}
@@ -412,6 +468,7 @@ fn ico(images: &[(usize, Vec<u8>)]) -> Result<Vec<u8>, String> {
}
fn cmd_exec(args: &[String]) -> Result<(), String> {
install_renderer();
let id = args.first().ok_or_else(|| USAGE.to_string())?;
let p: Value = match args.get(1) {
Some(t) => serde_json::from_str(t).map_err(|e| format!("params: {e}"))?,
@@ -427,6 +484,7 @@ fn cmd_exec(args: &[String]) -> Result<(), String> {
/// a running app's control port. stdout carries only protocol messages. Files the agent's tool
/// calls reach are confined to `solidcraft_mcp::agent_scope` unless `--allow-any-path` is given.
fn cmd_mcp(args: &[String]) -> Result<(), String> {
install_renderer();
let scope = (!args.iter().any(|a| a == "--allow-any-path"))
.then(|| solidcraft_mcp::agent_scope(flags(args, "--allow-path").into_iter().map(std::path::PathBuf::from)));
if let Some(s) = &scope {
+14 -5
View File
@@ -151,11 +151,20 @@ fn snapshot_runs_its_script_on_the_in_design() {
json_output(cli().arg("snapshot").args(args).arg("--out").arg(&png).args(["--width", "64", "--height", "48"]).output().unwrap());
std::fs::read(&png).unwrap()
};
let alone = shot("alone.png", &[shifted.as_os_str()]);
let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]);
let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]);
assert_ne!(alone, on_base, "the script ran on the --in design");
assert_ne!(base_only, on_base);
// Pictures are drawn on the GPU: without an adapter only the option checks below run.
match solidcraft_gpu::capture::shared() {
Ok(_) => {
let alone = shot("alone.png", &[shifted.as_os_str()]);
let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]);
let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]);
assert_ne!(alone, on_base, "the script ran on the --in design");
assert_ne!(base_only, on_base);
}
Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
eprintln!("pictures left out: {e}")
}
Err(e) => panic!("{e}"),
}
for (bad, says) in [(["--frobnicate", "1"], "doesn't take --frobnicate"), (["--width", "wide"], "--width must be 16 to 8192")] {
let output = cli().arg("snapshot").arg(&base).arg("--out").arg(dir.0.join("x.png")).args(bad).output().unwrap();
assert_eq!(output.status.code(), Some(1));
+217 -22
View File
@@ -1,6 +1,8 @@
//! `snapshot` frames its picture like the MCP `screenshot`: from a camera, a view saved in the
//! design or one the script applies itself, and without the ground grid and the origin's axes
//! when asked (the grid lies on Z 0 and cuts through a part that straddles it).
//! when asked (the grid lies on Z 0 and cuts through a part that straddles it). Pictures are drawn
//! on the GPU and compared with a tolerance; without a GPU adapter the tests that draw are skipped
//! with the reason printed (`SOLIDCRAFT_REQUIRE_GPU=1` makes that a failure).
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
@@ -12,7 +14,52 @@ use solidcraft_mcp::{Headless, Server};
static NEXT: AtomicUsize = AtomicUsize::new(0);
const SIZE: (usize, usize) = (160, 100);
/// Whether pictures can be drawn here; installs the renderer the CLI installs, so the MCP
/// `screenshot` in this process draws as `solidcraft-cli mcp` does.
fn gpu() -> bool {
match solidcraft_gpu::capture::shared() {
Ok(c) => {
solidcraft_engine::capture::install(std::sync::Arc::new(move |scene, cam, w, h| {
c.rgba(scene, cam, w, h).map(|rgba| solidcraft_engine::capture::Picture { rgba, renderer: c.renderer() }).map_err(|e| e.to_string())
}));
true
}
Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
eprintln!("skipping: {e}");
false
}
Err(e) => panic!("{e}"),
}
}
/// A PNG's size and RGBA pixels.
fn decode(png: &[u8]) -> (u32, u32, Vec<u8>) {
let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).unwrap().to_rgba8();
(img.width(), img.height(), img.into_raw())
}
/// Pixels of two same-sized pictures that differ by more than a little in some channel.
fn differing(a: &[u8], b: &[u8]) -> usize {
let ((wa, ha, a), (wb, hb, b)) = (decode(a), decode(b));
assert_eq!((wa, ha), (wb, hb), "pictures of different sizes");
a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 16)).count()
}
/// The same picture: GPU output may differ by a few levels or a stray edge pixel between drivers.
#[track_caller]
fn assert_same(a: &[u8], b: &[u8], what: &str) {
let (n, total) = (differing(a, b), (SIZE.0 * SIZE.1) as usize);
assert!(n * 500 <= total, "{what}: {n} of {total} pixels differ");
}
/// Different pictures: more than stray pixels differ.
#[track_caller]
fn assert_differs(a: &[u8], b: &[u8], what: &str) {
let n = differing(a, b);
assert!(n >= 20, "{what}: only {n} pixels differ");
}
const SIZE: (u32, u32) = (160, 100);
fn camera() -> Value {
json!({"target": [20, 0, 0], "yaw": 0.6, "pitch": 0.25, "distance": 100})
@@ -88,32 +135,38 @@ impl Drop for Scratch {
/// not the home view the option used to be ignored for.
#[test]
fn a_camera_frames_the_picture_as_the_mcp_screenshot_does() {
if !gpu() {
return;
}
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let home = dir.snapshot(&script, "home.png", &[]);
let json = camera().to_string();
let shot = dir.snapshot(&script, "camera.png", &["--camera", &json]);
assert_ne!(shot, home, "--camera was ignored");
assert_eq!(shot, dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()})));
assert_differs(&shot, &home, "--camera was ignored");
assert_same(&shot, &dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()})), "the MCP screenshot");
// A perspective camera too, and a different eye gives a different picture.
let mut near = camera();
near["fov"] = json!(0.6);
let perspective = dir.snapshot(&script, "perspective.png", &["--camera", &near.to_string()]);
assert_ne!(perspective, shot);
assert_eq!(perspective, dir.mcp(&script, "mcp-perspective.png", json!({"camera": near})));
assert_differs(&perspective, &shot, "perspective");
assert_same(&perspective, &dir.mcp(&script, "mcp-perspective.png", json!({"camera": near})), "the MCP perspective screenshot");
}
/// `--view` also takes a view saved in the design, which is the camera it was saved with; a
/// standard view still looks as it did.
#[test]
fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
if !gpu() {
return;
}
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let json = camera().to_string();
let by_camera = dir.snapshot(&script, "camera.png", &["--camera", &json]);
let saved = dir.snapshot(&script, "saved.png", &["--view", "Detail"]);
assert_eq!(saved, by_camera);
assert_eq!(saved, dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"})));
assert_same(&saved, &by_camera, "the saved view");
assert_same(&saved, &dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"})), "the MCP saved view");
// A standard view frames the model from its direction, as before: the home camera turned.
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
let mut s = Session::default();
@@ -121,32 +174,38 @@ fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
let mut cam = solidcraft_engine::view::home_camera(&s);
cam.set_view(solidcraft_engine::render::StandardView::Top);
let scene = solidcraft_engine::view::scene(&s, &cam);
assert_eq!(top, solidcraft_engine::render::render_png(&scene, &cam, SIZE.0, SIZE.1).unwrap());
assert_eq!(top, dir.mcp(&script, "mcp-top.png", json!({"view": "top"})));
assert_ne!(top, saved);
assert_same(&top, &solidcraft_engine::capture::png(&scene, &cam, SIZE.0, SIZE.1).unwrap().bytes, "the home camera turned to the top");
assert_same(&top, &dir.mcp(&script, "mcp-top.png", json!({"view": "top"})), "the MCP top view");
assert_differs(&top, &saved, "top and the saved view");
let iso = dir.snapshot(&script, "iso.png", &["--view", "iso"]);
assert_eq!(iso, dir.snapshot(&script, "home.png", &[]));
assert_ne!(iso, top);
assert_same(&iso, &dir.snapshot(&script, "home.png", &[]), "iso is home");
assert_differs(&iso, &top, "iso and top");
}
/// A script that applies a view itself (as an MCP `execute` of `view.apply` does) sets the camera
/// of its snapshot; `--view` and `--camera` still decide when given.
#[test]
fn a_script_can_carry_its_camera() {
if !gpu() {
return;
}
let dir = Scratch::new();
let script = dir.script("carried.json", &[json!({"command": "view.apply", "params": {"camera": camera()}})]);
let plain = dir.script("plain.json", &[]);
let carried = dir.snapshot(&script, "carried.png", &[]);
assert_eq!(carried, dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()]));
assert_ne!(carried, dir.snapshot(&plain, "home.png", &[]));
assert_same(&carried, &dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()]), "the carried camera");
assert_differs(&carried, &dir.snapshot(&plain, "home.png", &[]), "the carried camera and home");
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
assert_eq!(top, dir.snapshot(&plain, "plain-top.png", &["--view", "top"]));
assert_same(&top, &dir.snapshot(&plain, "plain-top.png", &["--view", "top"]), "--view decides");
}
/// The grid and the axes each go when asked, and a render without both from any side shows the
/// model alone: an empty design is then the same blank picture from every camera.
#[test]
fn the_grid_and_the_axes_can_be_left_out() {
if !gpu() {
return;
}
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let json = camera().to_string();
@@ -161,7 +220,7 @@ fn the_grid_and_the_axes_can_be_left_out() {
let different = [&all, &no_grid, &no_axes, &bare];
for (i, a) in different.iter().enumerate() {
for b in &different[i + 1..] {
assert_ne!(a, b, "{view:?}: two pictures are the same");
assert_differs(a, b, &format!("{view:?}: two of the pictures"));
}
}
// The same as the MCP's `grid` and `axes`.
@@ -173,15 +232,151 @@ fn the_grid_and_the_axes_can_be_left_out() {
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
dir.mcp(&script, out, a)
};
assert_eq!(no_grid, mcp(json!({"grid": false}), "mcp-no-grid.png"));
assert_eq!(no_axes, mcp(json!({"axes": false}), "mcp-no-axes.png"));
assert_eq!(bare, mcp(json!({"grid": false, "axes": false}), "mcp-bare.png"));
assert_same(&no_grid, &mcp(json!({"grid": false}), "mcp-no-grid.png"), "the MCP's grid: false");
assert_same(&no_axes, &mcp(json!({"axes": false}), "mcp-no-axes.png"), "the MCP's axes: false");
assert_same(&bare, &mcp(json!({"grid": false, "axes": false}), "mcp-bare.png"), "the MCP's bare picture");
}
let empty = dir.0.join("empty.json");
std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap();
let blank = |view: &str, out: &str| dir.snapshot(&empty, out, &["--view", view, "--no-grid", "--no-axes"]);
assert_eq!(blank("top", "blank-top.png"), blank("front", "blank-front.png"));
assert_ne!(blank("top", "blank-top.png"), dir.snapshot(&empty, "top.png", &["--view", "top"]));
assert_same(&blank("top", "blank-top.png"), &blank("front", "blank-front.png"), "blank from every side");
assert_differs(&blank("top", "blank-top.png"), &dir.snapshot(&empty, "top.png", &["--view", "top"]), "blank and the grid");
}
/// A snapshot of a box shows its silhouette where the camera puts it: the pixels that differ
/// from an empty design's picture (same camera, no grid or axes) span the box's corners projected
/// to pixels, within the edge and silhouette lines' width.
#[test]
fn a_snapshot_shows_the_part_where_the_camera_puts_it() {
if !gpu() {
return;
}
let dir = Scratch::new();
let part = dir.0.join("box.json");
let empty = dir.0.join("empty.json");
let corner = [10.0, -20.0, -15.0];
let size = [60.0, 40.0, 30.0];
std::fs::write(
&part,
json!({"commands": [{"command": "solid.box", "params": {"length": size[0], "width": size[1], "height": size[2], "corner": corner}}]})
.to_string(),
)
.unwrap();
std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap();
for cam in [
camera(),
json!({"target": [40, 0, 0], "yaw": 0.0, "pitch": 0.0, "distance": 160}),
json!({"target": [40, 0, 0], "yaw": -0.4, "pitch": 0.5, "distance": 150, "fov": 0.6}),
] {
let args = ["--camera", &cam.to_string(), "--no-grid", "--no-axes"];
let (_, _, with) = decode(&dir.snapshot(&part, "part.png", &args));
let (_, _, without) = decode(&dir.snapshot(&empty, "empty.png", &args));
// The part's pixels: where the picture differs from the empty one.
let (mut lo, mut hi) = ([u32::MAX; 2], [0u32; 2]);
for (i, (p, q)) in with.as_chunks::<4>().0.iter().zip(without.as_chunks::<4>().0).enumerate() {
if p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 24) {
let (x, y) = (i as u32 % SIZE.0, i as u32 / SIZE.0);
lo = [lo[0].min(x), lo[1].min(y)];
hi = [hi[0].max(x), hi[1].max(y)];
}
}
assert!(lo[0] <= hi[0], "{cam}: the part is not in the picture");
// Where the camera puts the corners.
let mut s = solidcraft_engine::Session::default();
s.execute("view.apply", &json!({"camera": cam})).unwrap();
let c = s.camera.unwrap();
let vp = c.view_proj(f64::from(SIZE.0) / f64::from(SIZE.1), 1000.0);
let (mut elo, mut ehi) = ([f64::MAX; 2], [f64::MIN; 2]);
for k in 0..8 {
let p = solidcraft_engine::geom::Vec3::new(
corner[0] + size[0] * f64::from(k & 1),
corner[1] + size[1] * f64::from((k >> 1) & 1),
corner[2] + size[2] * f64::from((k >> 2) & 1),
);
let q = vp.apply(p);
let (x, y) = ((q[0] / q[3] + 1.0) * 0.5 * f64::from(SIZE.0), (1.0 - q[1] / q[3]) * 0.5 * f64::from(SIZE.1));
elo = [elo[0].min(x), elo[1].min(y)];
ehi = [ehi[0].max(x), ehi[1].max(y)];
}
let near = |got: u32, want: f64| (f64::from(got) - want).abs() <= 2.5;
assert!(
near(lo[0], elo[0]) && near(lo[1], elo[1]) && near(hi[0] + 1, ehi[0]) && near(hi[1] + 1, ehi[1]),
"{cam}: the part spans pixels {lo:?}..={hi:?}, its corners project to {elo:?}..{ehi:?}"
);
}
}
/// `snapshot` under a `SOLIDCRAFT_GPU` policy: its reply and stderr.
fn snapshot_under(dir: &Scratch, script: &Path, out: &str, policy: &str) -> Output {
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.env("SOLIDCRAFT_GPU", policy)
.arg("snapshot")
.arg(script)
.arg("--out")
.arg(dir.0.join(out))
.args(["--width", &SIZE.0.to_string(), "--height", &SIZE.1.to_string(), "--view", "top"])
.output()
.unwrap()
}
/// The last tier: `SOLIDCRAFT_GPU=cpu` draws with the CPU rasterizer whatever adapters there are,
/// says so (`renderer: cpu` and a warning that overlays are not drawn), and the picture is the
/// rasterizer's own.
#[test]
fn a_forced_cpu_snapshot_is_the_cpu_rasterizers_and_says_so() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let output = snapshot_under(&dir, &script, "cpu.png", "cpu");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "{stderr}");
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(reply["renderer"], "cpu", "{reply}");
assert!(reply["warning"].as_str().is_some_and(|w| w.contains("not drawn")), "{reply}");
assert!(stderr.contains("CPU rasterizer"), "{stderr}");
let mut s = Session::default();
s.run_script(&serde_json::from_slice(&std::fs::read(&script).unwrap()).unwrap()).unwrap();
let mut cam = solidcraft_engine::view::home_camera(&s);
cam.set_view(solidcraft_engine::render::StandardView::Top);
let scene = solidcraft_engine::view::scene(&s, &cam);
let cpu = solidcraft_engine::render::render_png(&scene, &cam, SIZE.0 as usize, SIZE.1 as usize).unwrap();
assert_eq!(std::fs::read(dir.0.join("cpu.png")).unwrap(), cpu);
}
/// `hardware` (and `software`) never fall to the CPU rasterizer: without such an adapter the
/// snapshot fails, naming the policy, and writes nothing; with one it says it was drawn on the GPU.
/// `auto` lands where `solidcraft-cli gpu` says it does.
#[test]
fn a_strict_policy_never_falls_to_the_cpu() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let survey = |policy: &str| {
let o = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["gpu", "--policy", policy]).output().unwrap();
(o.status.success(), serde_json::from_slice::<Value>(&o.stdout).unwrap_or(Value::Null))
};
for policy in ["hardware", "software"] {
let (found, report) = survey(policy);
let output = snapshot_under(&dir, &script, &format!("{policy}.png"), policy);
let stderr = String::from_utf8_lossy(&output.stderr);
if found {
assert!(output.status.success(), "{policy}: {stderr}");
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(reply["renderer"], report["renderer"], "{policy}: {reply}");
assert_ne!(reply["renderer"], "cpu");
} else {
assert_eq!(report["lands_on"], Value::Null, "{policy}: {report}");
assert_eq!(output.status.code(), Some(1), "{policy}: {stderr}");
assert!(stderr.contains(&format!("`{policy}` policy")) && stderr.contains("solidcraft-cli gpu"), "{policy}: {stderr}");
assert!(!dir.0.join(format!("{policy}.png")).exists());
}
}
let (found, report) = survey("auto");
assert!(found, "auto always lands somewhere: {report}");
let output = snapshot_under(&dir, &script, "auto.png", "auto");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(reply["renderer"], report["renderer"], "{reply} vs {report}");
let (_, cpu) = survey("cpu");
assert_eq!((cpu["lands_on"].clone(), cpu["renderer"].clone()), (json!("cpu"), json!("cpu")), "{cpu}");
}
#[test]
+1 -1
View File
@@ -29,5 +29,5 @@ web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList",
"BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", "Gamepad",
] }
+1 -1
View File
@@ -458,7 +458,7 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_accept(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl");
input.set_accept(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl,.obj,.glb,.gltf");
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
let _ = input.set_attribute("style", "display:none");
if let Some(body) = doc.body() {
+21 -2
View File
@@ -10,6 +10,8 @@
mod files;
#[cfg(target_arch = "wasm32")]
mod install;
#[cfg(any(target_arch = "wasm32", test))]
mod space_mouse;
#[cfg(target_arch = "wasm32")]
mod web {
@@ -21,6 +23,7 @@ mod web {
use wasm_bindgen::JsCast as _;
use crate::files::{self, Files, Intent};
use crate::space_mouse::SpaceMouse;
const CANVAS_ID: &str = "solidcraft_canvas";
const LOADING_ID: &str = "solidcraft_loading";
@@ -67,6 +70,7 @@ mod web {
/// On-demand CJK font: fetched only when a CJK language is selected.
font: solidcraft_ui_egui::fonts::CjkFontLoader,
font_result: FontLoadResult,
space_mouse: SpaceMouse,
}
impl Shell {
@@ -85,7 +89,16 @@ mod web {
for (text, error) in self.files.messages.borrow_mut().drain(..) {
self.app.set_status(text, error);
}
let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
let mut incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
// A glTF file's buffers picked with it are kept next to it, not opened.
incoming.retain_mut(|(name, bytes, _)| {
let companion =
std::path::Path::new(name.as_str()).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("bin"));
if companion {
vfs::insert(&format!("{}{name}", files::UPLOAD), std::mem::take(bytes));
}
!companion
});
for (name, bytes, intent) in incoming {
let design = solidcraft_engine::io::is_design_path(&name);
let path = if design && intent == Intent::Open {
@@ -373,11 +386,14 @@ mod web {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.tick_font(ctx);
self.app.logic(ctx);
self.space_mouse.logic(&mut self.app, ctx);
self.files_tick(ctx);
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
files::take_drops(&self.files, raw);
// Before the app's own hook, which adds the control channel's events.
self.space_mouse.raw_input_hook(ctx, self.app.preferences.space_mouse_enabled, raw);
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
@@ -475,6 +491,8 @@ mod web {
install_app: Some(crate::install::service(&cc.egui_ctx)),
..Default::default()
};
// Thumbnails of saves and start-page cards.
solidcraft_ui_egui::gpu::install_cpu_picture_renderer();
let mut app = SolveApp::new(Session::default(), services);
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
app.load_prefs(&p);
@@ -507,6 +525,7 @@ mod web {
recents_synced: false,
font: Default::default(),
font_result: Rc::new(std::cell::RefCell::new(None)),
space_mouse: SpaceMouse::default(),
}))
}),
)
+285
View File
@@ -0,0 +1,285 @@
//! The 3D mouse in the browser (#1336), through the Gamepad API.
//!
//! Chromium browsers list a 3Dconnexion device itself, with its six axes scaled to -1..1 from the
//! device's ±350. Firefox lists only joysticks and gamepads (a 3D mouse is a HID multi-axis
//! controller), but 3DxWare's virtual joystick carries the same six axes once 3DxWare's profile
//! for Firefox sends them there. Both come in the device's own order and signs, so the shared
//! navigation takes them with 3DxWare's object-mode mapping, as the Linux app does without a
//! profile.
use solidcraft_ui_egui::space_mouse::{Axes, FULL_SCALE};
/// Logitech's vendor id, which older 3Dconnexion devices carry, and those devices' product ids
/// (usb.ids). Logitech's own gamepads and wheels carry the same vendor id.
const LOGITECH: u16 = 0x046d;
const LOGITECH_3D_MICE: [u16; 11] = [0xc603, 0xc605, 0xc606, 0xc621, 0xc623, 0xc625, 0xc626, 0xc627, 0xc628, 0xc629, 0xc62b];
/// 3Dconnexion's own vendor id. The browser lists only its 3D mice as gamepads: its mice, and the
/// mouse side of its receivers, are not gamepads.
const THREEDCONNEXION: u16 = 0x256f;
/// 3DxWare's virtual joystick.
const VIRTUAL_JOYSTICK: (u16, u16) = (0xbeef, 0x046d);
/// What a gamepad the browser lists is, when it is a 3D mouse. A device is read before the
/// joystick: in a Chromium browser both are listed, and the joystick stays at rest.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Source {
Device,
VirtualJoystick,
}
/// The vendor and product ids in a gamepad's id: `Name (Vendor: 046d Product: c62b)` in Chromium
/// browsers, `046d-c62b-Name` in Firefox.
fn vendor_product(id: &str) -> Option<(u16, u16)> {
fn hex(digits: &str) -> Option<u16> {
if (1..=4).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_hexdigit()) { u16::from_str_radix(digits, 16).ok() } else { None }
}
if let Some((_, ids)) = id.rsplit_once("Vendor: ") {
let (vendor, rest) = ids.split_once(" Product: ")?;
let product = rest.split(|c: char| !c.is_ascii_hexdigit()).next()?;
return Some((hex(vendor)?, hex(product)?));
}
let mut parts = id.splitn(3, '-');
let (vendor, product, _name) = (parts.next()?, parts.next()?, parts.next()?);
Some((hex(vendor)?, hex(product)?))
}
/// Whether a gamepad is a 3D mouse, from the id the browser gives it.
pub fn source(id: &str) -> Option<Source> {
match vendor_product(id)? {
VIRTUAL_JOYSTICK => Some(Source::VirtualJoystick),
(THREEDCONNEXION, _) => Some(Source::Device),
(LOGITECH, product) if LOGITECH_3D_MICE.contains(&product) => Some(Source::Device),
_ => None,
}
}
/// The six axes in the device's raw units, from a gamepad's axes (-1..1). Fewer than six: not a
/// 3D mouse. A value that isn't a number reads as rest.
pub fn axes(values: &[f64]) -> Option<Axes> {
let raw = |i: usize| values.get(i).map(|&v| if v.is_finite() { (v.clamp(-1.0, 1.0) * f64::from(FULL_SCALE)).round() as i16 } else { 0 });
Some(Axes { translation: [raw(0)?, raw(1)?, raw(2)?], rotation: [raw(3)?, raw(4)?, raw(5)?] })
}
/// The 3D mouse to read among the ones listed: the first device, else the first joystick.
pub fn choose(found: impl IntoIterator<Item = (Source, Axes)>) -> Option<Axes> {
found.into_iter().min_by_key(|(source, _)| *source).map(|(_, axes)| axes)
}
#[cfg(target_arch = "wasm32")]
pub use browser::SpaceMouse;
#[cfg(target_arch = "wasm32")]
mod browser {
use std::cell::Cell;
use std::rc::Rc;
use solidcraft_ui_egui::SolveApp;
use solidcraft_ui_egui::space_mouse::{Axes, DriverMapping, navigate_frame};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::closure::Closure;
use solidcraft_ui_egui::space_mouse::WheelGuard;
use super::{axes, choose, source};
/// How often the page looks for the cap starting to move while nothing else redraws.
const WAKE_MS: i32 = 50;
/// The 3D mouse the browser lists, if any.
fn read() -> Option<Axes> {
let pads = web_sys::window()?.navigator().get_gamepads().ok()?;
choose(pads.iter().filter_map(|pad| {
let pad = pad.dyn_into::<web_sys::Gamepad>().ok().filter(web_sys::Gamepad::connected)?;
let source = source(&pad.id())?;
let values: Vec<f64> = pad.axes().iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect();
Some((source, axes(&values)?))
}))
}
/// Reads the 3D mouse once per frame and moves the view with it.
#[derive(Default)]
pub struct SpaceMouse {
axes: Axes,
/// The previous frame's time and the step since it (seconds).
last: Option<f64>,
seconds: f64,
wheel: WheelGuard,
/// Listening for gamepads (from the first frame with navigation on).
listening: bool,
/// The timer that wakes the app when the cap starts moving runs (from when a 3D mouse
/// is first listed).
waking: bool,
/// Navigation is on (the waker reads it).
enabled: Rc<Cell<bool>>,
}
impl SpaceMouse {
/// At the start of a frame: reads the cap and drops 3DxWare's emulated wheel from the
/// frame's input, before the app sees it.
pub fn raw_input_hook(&mut self, ctx: &egui::Context, enabled: bool, raw: &mut egui::RawInput) {
let now = raw.time.unwrap_or_else(|| js_sys::Date::now() / 1000.0);
self.seconds = self.last.map_or(0.0, |last| now - last);
self.last = Some(now);
self.enabled.set(enabled);
self.axes = Axes::default();
if !enabled {
return;
}
if !self.listening {
self.listening = true;
listen(ctx);
}
let reading = read();
if reading.is_some() && !self.waking {
self.waking = wake(ctx, &self.enabled);
}
// Inside the dead zone is rest: a cap resting just off zero doesn't keep redrawing.
self.axes = reading.filter(|axes| axes.moving()).unwrap_or_default();
self.wheel.filter(self.axes.moving(), now, &mut raw.events);
}
pub fn logic(&self, app: &mut SolveApp, ctx: &egui::Context) {
navigate_frame(app, ctx, DriverMapping::object_mode(), self.axes, self.seconds);
}
}
/// A 3D mouse is listed only once it has been moved on the page: redraw then, so the frame
/// finds it without waiting for the pointer.
fn listen(ctx: &egui::Context) {
let Some(window) = web_sys::window() else { return };
let ctx = ctx.clone();
let on_connect = Closure::<dyn FnMut()>::new(move || ctx.request_repaint());
let _ = window.add_event_listener_with_callback("gamepadconnected", on_connect.as_ref().unchecked_ref());
// The listener lives as long as the page.
on_connect.forget();
}
/// The browser sends no event when a gamepad moves, so a timer looks while the app is idle and
/// redraws when the cap starts moving; the frames that follow keep going on their own.
fn wake(ctx: &egui::Context, enabled: &Rc<Cell<bool>>) -> bool {
let Some(window) = web_sys::window() else { return false };
let (ctx, enabled) = (ctx.clone(), Rc::clone(enabled));
let tick = Closure::<dyn FnMut()>::new(move || {
if enabled.get() && read().is_some_and(Axes::moving) {
ctx.request_repaint();
}
});
let started = window.set_interval_with_callback_and_timeout_and_arguments_0(tick.as_ref().unchecked_ref(), WAKE_MS).is_ok();
// The timer lives as long as the page.
tick.forget();
started
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognises_3d_mice_in_each_browsers_ids() {
// As Edge and Firefox list them (Windows 11, SpaceMouse Pro, 3DxWare 10).
assert_eq!(source("SpaceMouse Pro (Vendor: 046d Product: c62b)"), Some(Source::Device));
assert_eq!(source("Unknown Gamepad (Vendor: beef Product: 046d)"), Some(Source::VirtualJoystick));
assert_eq!(source("beef-046d-Unknown Gamepad"), Some(Source::VirtualJoystick));
// 3Dconnexion's own vendor id, any product; the older devices under Logitech's.
assert_eq!(source("SpaceMouse Compact (Vendor: 256f Product: c635)"), Some(Source::Device));
assert_eq!(source("256f-c652-3Dconnexion Universal Receiver"), Some(Source::Device));
assert_eq!(source("46d-c626-SpaceNavigator"), Some(Source::Device));
assert_eq!(source("SpaceNavigator (STANDARD GAMEPAD Vendor: 046d Product: c626)"), Some(Source::Device));
for product in LOGITECH_3D_MICE {
assert_eq!(source(&format!("x (Vendor: 046d Product: {product:04x})")), Some(Source::Device));
assert_eq!(source(&format!("046d-{product:04X}-x")), Some(Source::Device));
}
}
#[test]
fn gamepads_and_other_logitech_devices_are_not_3d_mice() {
for id in [
"Logitech Dual Action (STANDARD GAMEPAD Vendor: 046d Product: c216)",
"046d-c21d-Logitech Gamepad F310",
"G29 Driving Force Racing Wheel (Vendor: 046d Product: c24f)",
"046d-c215-Logitech Extreme 3D",
"NuLOOQ navigator (Vendor: 046d Product: c640)",
"Xbox 360 Controller (XInput STANDARD GAMEPAD)",
"45e-28e-Xbox 360 Wired Controller",
"beef-046e-Unknown Gamepad",
"beee-046d-Unknown Gamepad",
"",
"-",
"--",
"256f-",
"256f-c635",
"Vendor: 256f",
"Vendor: 256f Product: ",
"x (Vendor: 256fff Product: c635)",
"x (Vendor: zz6f Product: c635)",
"12345-c635-x",
"256f--x",
"SpaceMouse Pro",
] {
assert_eq!(source(id), None, "{id:?}");
}
}
#[test]
fn ids_never_panic() {
// Hostile and odd ids: multi-byte text around the markers, markers repeated or cut short.
for id in [
"Vendor: é Product: ü",
"Vendor: Vendor: 256f Product: c635)",
"é-ü-ö",
"Ⅰ (Vendor: 256f Product: c6Ⅰ)",
"\u{0}-\u{0}-\u{0}",
"Vendor: 256f Product: c635 Vendor: ",
] {
let _ = source(id);
}
let mut seed = 0x2545_f491_4f6c_dd1d_u64;
let alphabet: Vec<char> = "0123456789abcdefABCDEF- ():VendorProductéⅠ\u{0}".chars().collect();
for _ in 0..20_000 {
let len = (seed % 40) as usize;
let id: String = (0..len)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
alphabet[(seed % alphabet.len() as u64) as usize]
})
.collect();
if let Some(Source::Device) = source(&id) {
let (vendor, product) = vendor_product(&id).expect("recognised ids have both");
assert!(vendor == THREEDCONNEXION || (vendor == LOGITECH && LOGITECH_3D_MICE.contains(&product)), "{id:?}");
}
}
}
#[test]
fn gamepad_axes_are_the_devices_raw_units() {
// Full deflection is the desktop's 350, and the browser's scale keeps the device's steps.
let axes = axes(&[1.0, -1.0, 0.5, -0.5, 2.0 / 700.0, 0.0]).expect("six axes");
assert_eq!(axes, Axes { translation: [350, -350, 175], rotation: [-175, 1, 0] });
for raw in -350_i16..=350 {
let value = f64::from(raw) / 350.0;
assert_eq!(super::axes(&[value; 6]).map(|a| a.translation[0]), Some(raw));
}
// Past full deflection is full deflection; anything that isn't a number is rest.
let hostile = super::axes(&[f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, -1e300, f64::MIN_POSITIVE]).expect("six axes");
assert_eq!(hostile, Axes { translation: [0, 0, 0], rotation: [350, -350, 0] });
// A gamepad with fewer than six axes is not a 3D mouse; extra axes are ignored.
assert_eq!(super::axes(&[1.0; 5]), None);
assert_eq!(super::axes(&[]), None);
assert_eq!(super::axes(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]), Some(Axes::default()));
}
#[test]
fn a_device_is_read_before_the_virtual_joystick() {
let moving = Axes { translation: [100, 0, 0], rotation: [0; 3] };
let other = Axes { translation: [0, 200, 0], rotation: [0; 3] };
assert_eq!(choose([(Source::VirtualJoystick, other), (Source::Device, moving)]), Some(moving));
assert_eq!(choose([(Source::Device, moving), (Source::VirtualJoystick, other)]), Some(moving));
assert_eq!(choose([(Source::VirtualJoystick, other)]), Some(other));
// Two devices: the first listed.
assert_eq!(choose([(Source::Device, moving), (Source::Device, other)]), Some(moving));
assert_eq!(choose([]), None);
}
}
+120 -9
View File
@@ -4,7 +4,7 @@
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use std::time::Duration;
use serde_json::{Value, json};
@@ -160,12 +160,35 @@ fn refuse(out: &mut TcpStream, reader: &mut BufReader<TcpStream>) {
let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink());
}
/// How long a request waits for the app's reply: most requests, and a batch (or every request,
/// on a patient connection).
#[derive(Clone, Copy)]
struct Waits {
short: Duration,
long: Duration,
}
const WAITS: Waits = Waits { short: Duration::from_secs(60), long: Duration::from_secs(3600) };
/// A request that timed out but is still running in the app (#1618): its id and method, and where
/// its reply will land.
struct Pending {
id: Value,
method: String,
reply: Receiver<Value>,
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
serve_with(stream, token, tx, ctx, patient, WAITS);
}
fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool, waits: Waits) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
let mut bad = 0usize;
let mut authenticated = false;
let mut pending: Option<Pending> = None;
loop {
let line = match read_line(&mut reader, MAX_LINE) {
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
@@ -193,14 +216,46 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
// A batch replaces many short requests: the whole of it may take long (#743).
let wait = Duration::from_secs(if patient || method == "engine.batch" { 3600 } else { 60 });
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(wait).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
// A request that timed out may have finished since (#1618).
let late = pending.as_ref().and_then(|p| match p.reply.try_recv() {
Ok(reply) => Some(reply),
Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app ended the request without a reply"})),
Err(TryRecvError::Empty) => None,
});
let mut r = if method == "control.pending" {
match (pending.take(), late) {
(None, _) => json!({"ok": true, "result": {"pending": null}}),
(Some(p), Some(reply)) => json!({"ok": true, "result": {"pending": p.id, "method": p.method, "done": true, "reply": reply}}),
(Some(p), None) => {
let r = json!({"ok": true, "result": {"pending": p.id, "method": p.method, "done": false}});
pending = Some(p);
r
}
}
} else if let Some(p) = pending.as_ref().filter(|_| late.is_none()) {
// Nothing more is queued behind it, to run unseen when it ends.
json!({"ok": false, "busy": true, "error": format!("busy: request {} (`{}`) is still running; ask `control.pending` for its result", p.id, p.method)})
} else {
if late.is_some() {
// Finished, and the client moved on without asking: it is dropped.
pending = None;
}
// A batch replaces many short requests: the whole of it may take long (#743).
let wait = if patient || method == "engine.batch" { waits.long } else { waits.short };
let (req, rrx) = ControlRequest::new(method.clone(), params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
match rrx.recv_timeout(wait) {
Ok(r) => r,
Err(_) => {
let r = json!({"ok": false, "running": true, "error": format!("timeout: `{method}` is still running in the app and will still apply; ask `control.pending` for its result")});
pending = Some(Pending { id: id.clone(), method, reply: rrx });
r
}
}
};
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
@@ -228,6 +283,62 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
mod tests {
use super::*;
/// #1618: a request that outlasts its wait says it is still running; the connection keeps its
/// reply for `control.pending`, and refuses other requests as `busy` until it lands, so none
/// is queued to run unseen behind it.
#[test]
fn a_timed_out_request_can_be_asked_for_later() {
let token = "0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let ran = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let seen = ran.clone();
std::thread::spawn(move || {
for req in rx {
seen.lock().unwrap().push(req.method.clone());
if req.method == "slow" {
std::thread::sleep(Duration::from_millis(400));
}
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
});
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100) };
std::thread::spawn(move || {
if let Some(stream) = listener.incoming().flatten().next() {
serve_with(stream, token, tx, egui::Context::default(), false, waits);
}
});
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let mut out = s.try_clone().unwrap();
let mut lines = BufReader::new(s);
let mut ask = |v: Value| -> Value {
writeln!(out, "{v}").unwrap();
let mut l = String::new();
lines.read_line(&mut l).unwrap();
serde_json::from_str(&l).unwrap()
};
let r = ask(json!({"id": 1, "method": "slow", "token": token}));
assert!(r["ok"] == false && r["running"] == true && r["error"].as_str().is_some_and(|e| e.starts_with("timeout")), "{r}");
let r = ask(json!({"id": 2, "method": "ui.view"}));
assert!(r["busy"] == true && r["id"] == 2, "{r}");
let r = ask(json!({"id": 3, "method": "control.pending"}));
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(1), &json!(false)), "{r}");
std::thread::sleep(Duration::from_millis(600));
let r = ask(json!({"id": 4, "method": "control.pending"}));
assert_eq!(
(&r["result"]["pending"], &r["result"]["done"], &r["result"]["reply"]["result"]),
(&json!(1), &json!(true), &json!("slow")),
"{r}"
);
let r = ask(json!({"id": 5, "method": "control.pending"}));
assert_eq!(r["result"]["pending"], Value::Null, "{r}");
let r = ask(json!({"id": 6, "method": "ui.view"}));
assert_eq!((&r["ok"], &r["result"]), (&json!(true), &json!("ui.view")), "{r}");
assert_eq!(*ran.lock().unwrap(), ["slow", "ui.view"], "the busy request never reached the app");
}
/// #205: a connection runs nothing until a request carries the launch's token; a wrong or
/// missing token, or garbage, is refused and the connection closed. Once shown, the token
/// need not be repeated on that connection.
+18 -3
View File
@@ -6,7 +6,7 @@
//! the software renderer; see `startup` and docs/book/src/guide/troubleshooting.md.
//!
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
//! `.3mf`/`.stl`/`.obj`/`.glb`/`.gltf` file as a new design holding mesh bodies.
//!
//! `--control <port>` (or `SOLIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
@@ -77,6 +77,9 @@ impl eframe::App for App {
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
// Before the app's own hook, which adds the control channel's events.
#[cfg(any(target_os = "windows", target_os = "linux"))]
self.space_mouse.raw_input_hook(self.solve.preferences.space_mouse_enabled, raw);
self.solve.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
@@ -108,7 +111,7 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
let future = open_parent
.clone()
.add_filter(
"Designs, STEP, IGES and meshes",
"Designs, CAD files and meshes",
&[
io::DESIGN_EXT,
io::LEGACY_DESIGN_EXT,
@@ -121,16 +124,27 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
"iges",
"IGS",
"IGES",
"FCStd",
"fcstd",
"3dm",
"3DM",
"3mf",
"3MF",
"stl",
"STL",
"obj",
"OBJ",
"glb",
"GLB",
"gltf",
"GLTF",
],
)
.add_filter("SolidCraft design (container or JSON)", &[io::DESIGN_EXT, io::LEGACY_DESIGN_EXT, "json"])
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
.add_filter("FreeCAD and Rhino", &["FCStd", "fcstd", "3dm", "3DM"])
.add_filter("Mesh (3MF, STL, OBJ, glTF)", &["3mf", "3MF", "stl", "STL", "obj", "OBJ", "glb", "GLB", "gltf", "GLTF"])
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
.add_filter("All files", &["*"])
.pick_file();
@@ -556,6 +570,7 @@ fn main() -> eframe::Result {
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
solidcraft_ui_egui::gpu::install_picture_renderer(rs);
// The graphics driver is loaded now; it may have put its own exception filter
// in front of ours.
#[cfg(target_os = "windows")]
+121 -223
View File
@@ -15,79 +15,18 @@ use hid_descriptor::Layout;
use hidapi::{DeviceInfo, HidApi};
use quick_xml::Reader;
use quick_xml::events::Event;
use solidcraft_engine::render::Camera;
use solidcraft_ui_egui::SolveApp;
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame};
const STALE_AFTER: Duration = Duration::from_millis(150);
const RETRY_AFTER: Duration = Duration::from_secs(2);
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Axes {
translation: [i16; 3],
rotation: [i16; 3],
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct AxisRule {
output: usize,
enabled: bool,
reversed: bool,
scale: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct DriverMapping {
axes: [AxisRule; 6],
overall_scale: f64,
}
impl Default for DriverMapping {
fn default() -> Self {
Self { axes: std::array::from_fn(|output| AxisRule { output, enabled: true, reversed: false, scale: 1.0 }), overall_scale: 1.0 }
}
}
impl DriverMapping {
/// The mapping used when no 3DxWare profile is found. On Linux there is no 3DxWare, so it is
/// what the driver applies by default in object mode (#1172): the physical Ry and Rz swapped
/// and reversed, so a tilt left/right rolls and a twist spins. Elsewhere the driver writes a
/// profile, and the raw axes are used until it does.
fn without_profile() -> Self {
let mut mapping = Self::default();
if cfg!(target_os = "linux") {
mapping.axes[4] = AxisRule { output: 5, enabled: true, reversed: true, scale: 1.0 };
mapping.axes[5] = AxisRule { output: 4, enabled: true, reversed: true, scale: 1.0 };
}
mapping
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Motion {
translation: [f64; 3],
rotation: [f64; 3],
}
impl DriverMapping {
fn apply(self, axes: Axes) -> Motion {
let raw = [
normalized(axes.translation[0]),
normalized(axes.translation[1]),
normalized(axes.translation[2]),
normalized(axes.rotation[0]),
normalized(axes.rotation[1]),
normalized(axes.rotation[2]),
];
let mut logical = [0.0; 6];
for (input, rule) in self.axes.iter().enumerate() {
if rule.enabled {
let sign = if rule.reversed { -1.0 } else { 1.0 };
logical[rule.output] = raw[input] * sign * rule.scale * self.overall_scale;
}
}
Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] }
}
/// The mapping used when no 3DxWare profile is found: what the driver applies by default in
/// object mode. On Linux there is no 3DxWare (#1172); on Windows 3DxWare has no profile for
/// SolidCraft until one is made, and the raw axes swap roll and spin (#1380).
fn without_profile() -> DriverMapping {
DriverMapping::object_mode()
}
#[derive(Clone, Copy, Debug, Default)]
@@ -381,6 +320,9 @@ pub struct SpaceMouse {
last_frame: Instant,
driver_mapping: DriverMapping,
mapping_checked_at: Instant,
/// Drops 3DxWare's emulated wheel while the cap moves (#1380).
wheel: WheelGuard,
started: Instant,
}
impl SpaceMouse {
@@ -395,84 +337,53 @@ impl SpaceMouse {
Self {
shared,
last_frame: now,
driver_mapping: load_driver_mapping().unwrap_or_else(DriverMapping::without_profile),
driver_mapping: load_driver_mapping().unwrap_or_else(without_profile),
mapping_checked_at: now,
wheel: WheelGuard::default(),
started: now,
}
}
/// Before the app's own hook (which adds the control channel's events): with navigation on,
/// the wheel events 3DxWare makes from the cap's motion are dropped.
pub fn raw_input_hook(&mut self, enabled: bool, raw: &mut egui::RawInput) {
if !enabled {
return;
}
let now = Instant::now();
let moving = self.shared.lock().map(|reading| reading.current(now).moving()).unwrap_or(false);
self.wheel.filter(moving, now.duration_since(self.started).as_secs_f64(), &mut raw.events);
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
let now = Instant::now();
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
let seconds = now.duration_since(self.last_frame).as_secs_f64();
self.last_frame = now;
if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
let updated = load_driver_mapping().unwrap_or_else(DriverMapping::without_profile);
let updated = load_driver_mapping().unwrap_or_else(without_profile);
if updated != self.driver_mapping {
log::info!("3D mouse: applying updated 3DxWare axis profile");
self.driver_mapping = updated;
}
self.mapping_checked_at = now;
}
if !app.preferences.space_mouse_enabled || app.home.open {
return;
}
let axes = match self.shared.lock() {
Ok(reading) => reading.current(now),
Err(_) => return,
};
if axes == Axes::default() {
return;
}
let speed = f64::from(app.preferences.space_mouse_speed);
if !speed.is_finite() {
return;
}
app.begin_external_navigation();
let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0)));
navigate(
&mut app.cam,
self.driver_mapping.apply(axes),
seconds * speed.clamp(0.1, 5.0),
height,
app.session.active_sketch.is_some(),
app.preferences.zoom_reverse,
);
ctx.request_repaint_after(Duration::from_millis(16));
}
}
/// A small dead zone avoids drift at rest. A full cap deflection takes roughly one viewport
/// per second to pan and about two radians per second to turn, independent of frame rate.
fn normalized(value: i16) -> f64 {
let v = f64::from(value);
if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) }
}
fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
if !(seconds > 0.0) || !seconds.is_finite() {
return;
}
let [x, y, z] = motion.translation;
let [rx, ry, rz] = motion.rotation;
// Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
// profile above can swap the physical forward/back and up/down axes before they reach here.
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None);
if !sketch {
// These are logical 3DxWare coordinates after the profile mapping above: Rx is Tilt,
// Ry is Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected
// navigation mode, so swapping them again here would make Roll behave like Spin.
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
cam.roll_by(rz * 2.0 * seconds);
navigate_frame(app, ctx, self.driver_mapping, axes, seconds);
}
}
#[cfg(test)]
mod tests {
use super::*;
use solidcraft_ui_egui::space_mouse::{DeviceMotion, Motion};
fn raw(axes: Axes) -> Motion {
DriverMapping::default().apply(axes)
/// The logical axes a reading reaches. Logical Rz rolls the view and logical Ry turns it about
/// its vertical (`navigation::device_camera` in solidcraft-ui-egui, tested there).
fn rotation(mapping: DriverMapping, rotation: [i16; 3]) -> [f64; 3] {
mapping.apply(Axes { translation: [0; 3], rotation }).rotation
}
#[test]
@@ -519,69 +430,6 @@ mod tests {
assert_eq!(reading.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
}
#[test]
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
let mut cam = Camera::default();
let before = cam;
navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, false);
assert_ne!(cam.target, before.target);
assert!(cam.distance < before.distance);
assert_ne!(cam.yaw, before.yaw);
assert_ne!(cam.pitch, before.pitch);
assert_ne!(cam.roll, before.roll);
let orientation = (cam.yaw, cam.pitch, cam.roll);
navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false);
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
assert!(cam.is_valid());
}
#[test]
fn individual_axes_have_physical_spacemouse_meaning() {
let before = Camera::default();
let mut slide = before;
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_ne!(slide.target, before.target);
assert_eq!(slide.distance, before.distance);
let mut push = before;
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_eq!(push.target, before.target);
assert!(push.distance < before.distance);
let mut lift = before;
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_ne!(lift.target, before.target);
assert_eq!(lift.distance, before.distance);
let mut tilt = before;
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false);
assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll));
assert_ne!(tilt.pitch, before.pitch);
let mut roll = before;
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll));
assert_ne!(roll.yaw, before.yaw);
let mut twist = before;
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch));
assert_ne!(twist.roll, before.roll);
}
#[test]
fn reverse_zoom_setting_applies_to_spacemouse() {
let before = Camera::default();
let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] });
let mut normal = before;
navigate(&mut normal, motion, 0.02, 600.0, false, false);
let mut reversed = before;
navigate(&mut reversed, motion, 0.02, 600.0, false, true);
assert!(normal.distance < before.distance);
assert!(reversed.distance > before.distance);
}
#[test]
fn applies_3dxware_axis_swaps_and_reversals() {
let profile = br#"
@@ -601,7 +449,7 @@ mod tests {
}
#[test]
fn without_a_profile_linux_uses_the_drivers_object_mode_axes() {
fn without_a_profile_the_drivers_object_mode_axes_are_used() {
// 3DxWare's object-mode mapping, as a profile (#1172: confirmed on a SpaceMouse Compact).
let object_mode = br#"
<AppCfg><Devices><Device><AxisBank>
@@ -614,39 +462,15 @@ mod tests {
</AxisBank></Device></Devices></AppCfg>
"#;
let profile = parse_driver_mapping(object_mode).expect("valid 3DxWare profile");
if cfg!(target_os = "linux") {
assert_eq!(DriverMapping::without_profile(), profile);
// Tilting the cap rolls the view and twisting it spins it, as with the driver.
let before = Camera::default();
let mut tilt = before;
navigate(
&mut tilt,
DriverMapping::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }),
0.02,
600.0,
false,
false,
);
assert_eq!((tilt.yaw, tilt.pitch), (before.yaw, before.pitch));
assert_ne!(tilt.roll, before.roll);
let mut twist = before;
navigate(
&mut twist,
DriverMapping::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }),
0.02,
600.0,
false,
false,
);
assert_ne!(twist.yaw, before.yaw);
assert_eq!(twist.roll, before.roll);
} else {
assert_eq!(DriverMapping::without_profile(), DriverMapping::default());
}
// On every platform (#1172 on Linux, #1380 on Windows).
assert_eq!(without_profile(), profile);
// Tilting the cap rolls the view and twisting it spins it, as with the driver.
assert_eq!(rotation(without_profile(), [0, 350, 0]), [0.0, 0.0, -1.0]);
assert_eq!(rotation(without_profile(), [0, 0, 350]), [0.0, -1.0, 0.0]);
}
#[test]
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
fn mapped_roll_and_spin_reach_the_roll_and_turn_axes() {
let profile = br#"
<AppCfg><Devices><Device><AxisBank>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
@@ -654,16 +478,90 @@ mod tests {
</AxisBank></Device></Devices></AppCfg>
"#;
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
let before = Camera::default();
// The physical roll reaches the roll axis only, and the physical spin the turn only.
assert_eq!(rotation(mapping, [0, 350, 0]), [0.0, 0.0, 1.0]);
assert_eq!(rotation(mapping, [0, 0, 350]), [0.0, -1.0, 0.0]);
}
let mut physical_roll = before;
navigate(&mut physical_roll, mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
assert_eq!((physical_roll.yaw, physical_roll.pitch), (before.yaw, before.pitch));
assert_ne!(physical_roll.roll, before.roll);
#[test]
fn each_frame_hands_the_view_its_motion_through_the_drivers_mapping() {
// The reader's latest tilt, and a profile that makes tilt roll: the frame's motion rolls.
let now = Instant::now();
let mut reading = Reading::new(now);
reading.axes.rotation = [0, 350, 0];
let mut mouse = SpaceMouse {
shared: Arc::new(Mutex::new(reading)),
last_frame: now - Duration::from_millis(20),
driver_mapping: DriverMapping::object_mode(),
mapping_checked_at: now,
wheel: WheelGuard::default(),
started: now,
};
let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default());
app.home.open = false;
mouse.tick(&mut app, &Context::default());
let DeviceMotion { motion, seconds } = app.viewport.device_motion.expect("the frame hands the page its motion");
assert_eq!(motion, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, -1.0] });
assert!(seconds > 0.0);
}
let mut physical_spin = before;
navigate(&mut physical_spin, mapping.apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
assert_ne!(physical_spin.yaw, before.yaw);
assert_eq!((physical_spin.pitch, physical_spin.roll), (before.pitch, before.roll));
fn wheel() -> egui::Event {
egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, -1.0),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
}
}
fn mouse(reading: Reading) -> SpaceMouse {
let now = Instant::now();
SpaceMouse {
shared: Arc::new(Mutex::new(reading)),
last_frame: now,
driver_mapping: DriverMapping::object_mode(),
mapping_checked_at: now,
wheel: WheelGuard::default(),
started: now,
}
}
#[test]
fn the_wheel_3dxware_makes_from_the_cap_is_dropped() {
// The cap tilted forward: 3DxWare also sends wheel turns (#1380). They are dropped.
let now = Instant::now();
let mut tilted = Reading::new(now);
tilted.axes.rotation = [-200, 0, 0];
let mut moving = mouse(tilted);
let mut raw = egui::RawInput { events: vec![wheel(), egui::Event::Zoom(1.1), egui::Event::Text("a".into())], ..Default::default() };
moving.raw_input_hook(true, &mut raw);
assert_eq!(raw.events, [egui::Event::Text("a".into())]);
// With navigation off, the wheel is the cap's only way to move the view: kept.
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
moving.raw_input_hook(false, &mut raw);
assert_eq!(raw.events.len(), 1);
// The cap at rest: the wheel is the user's.
let mut resting = mouse(Reading::new(now));
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
resting.raw_input_hook(true, &mut raw);
assert_eq!(raw.events.len(), 1);
}
#[test]
fn the_wheel_is_the_users_again_once_the_cap_has_been_still_a_moment() {
let mut tilted = Reading::new(Instant::now());
tilted.axes.rotation = [-200, 0, 0];
let mut cap = mouse(tilted);
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
cap.raw_input_hook(true, &mut raw);
assert!(raw.events.is_empty(), "dropped while the cap moves");
// Let go, then wait out the quiet time.
if let Ok(mut reading) = cap.shared.lock() {
*reading = Reading::new(Instant::now());
}
std::thread::sleep(Duration::from_secs_f64(solidcraft_ui_egui::space_mouse::WHEEL_QUIET_S) + Duration::from_millis(60));
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
cap.raw_input_hook(true, &mut raw);
assert_eq!(raw.events.len(), 1, "kept once the cap has been still");
}
}
+83 -1
View File
@@ -21,6 +21,19 @@ fn ellipse(x: f32, y: f32, rx: f32, ry: f32, a0: f32, a1: f32, n: usize) -> Vec<
(0..=n).map(|i| a0 + (a1 - a0) * i as f32 / n as f32).map(|a| (x + rx * a.cos(), y + ry * a.sin())).collect()
}
/// A gear's toothed outline about (x, y): `n` teeth from the root radius `root` out to `tip`,
/// turned by `phase` radians.
fn teeth(x: f32, y: f32, root: f32, tip: f32, n: usize, phase: f32) -> Vec<(f32, f32)> {
let step = std::f32::consts::TAU / n as f32;
let at = |a: f32, r: f32| (x + r * a.cos(), y - r * a.sin());
(0..n)
.flat_map(|k| {
let a = phase + step * k as f32;
[at(a - 0.3 * step, root), at(a - 0.15 * step, tip), at(a + 0.15 * step, tip), at(a + 0.3 * step, root)]
})
.collect()
}
struct Pen<'a> {
p: &'a Painter,
r: Rect,
@@ -477,6 +490,23 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C
}
p.circle_filled(at(0.5), dot_r, a);
}
"infinite" => {
// An infinite line (#1359): Construction's broken line, with an open arrowhead at
// each end saying it runs on. Accent only, like Construction and Centerline.
let w = line_type_stroke(r);
let at = |t: f32| pen.at(3.0 + 16.0 * t, 19.0 - 16.0 * t);
for (t0, t1) in [(0.0, 0.38), (0.62, 1.0)] {
p.add(Shape::line(vec![at(t0), at(t1)], Stroke::new(w, a)));
}
// Each head: two short strokes back from the tip, either side of the line.
let tip_len = 0.3;
for (tip, back) in [(0.0f32, tip_len), (1.0, 1.0 - tip_len)] {
let (t, b) = (at(tip), at(back));
let along = b - t;
let side = egui::vec2(-along.y, along.x) * 0.55;
p.add(Shape::line(vec![b + side, t, b - side], Stroke::new(w, a)));
}
}
"c_hv" => {
pen.line(&[(4.0, 18.0), (20.0, 18.0)], a);
pen.line(&[(6.0, 4.0), (6.0, 14.0)], a);
@@ -732,6 +762,20 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C
pen.closed(&[(21.0, 6.0), (15.0, 10.0), (15.0, 18.0), (21.0, 18.0)], a);
}
"sketch_circ_pattern" => paint(p, r, "pattern_circ", ink, Color32::TRANSPARENT, a),
"pattern_flip" => {
// Opposing arrows show reversal, with a short reference tick between them.
pen.arrow((4.0, 7.0), (20.0, 7.0), a);
pen.arrow((20.0, 17.0), (4.0, 17.0), ink);
pen.line(&[(12.0, 10.0), (12.0, 14.0)], ink);
}
"pattern_center" => {
// An original on a reference axis, with one equally spaced copy on either side.
pen.line(&[(12.0, 3.0), (12.0, 21.0)], a);
pen.line(&[(4.0, 12.0), (20.0, 12.0)], ink);
for x in [4.0, 12.0, 20.0] {
pen.circle(x, 12.0, 2.4, fill, if x == 12.0 { a } else { ink });
}
}
"sketch_rect_pattern" => paint(p, r, "pattern_rect", ink, Color32::TRANSPARENT, a),
"line_mid" => {
pen.line(&[(3.0, 19.0), (21.0, 5.0)], a);
@@ -1249,6 +1293,14 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C
pen.arc(12.0, 12.0, 8.5, 0.3, 4.6, a);
pen.arrow((11.0, 20.4), (14.5, 20.3), a);
}
// A crank on its pivot and the closed path its far end draws (accent).
"trace" => {
pen.curve([(15.0, 13.0), (22.0, 13.0), (23.0, 3.0), (15.0, 3.0)], a);
pen.curve([(15.0, 3.0), (8.0, 3.0), (9.0, 13.0), (15.0, 13.0)], a);
pen.line(&[(5.0, 20.0), (15.0, 13.0)], ink);
pen.circle(5.0, 20.0, 2.4, pen.fill, ink);
pen.dot(15.0, 13.0, a);
}
"motion_link" => {
pen.circle(7.0, 14.0, 4.5, pen.fill, ink);
pen.circle(17.5, 9.0, 3.0, pen.fill, ink);
@@ -1256,6 +1308,36 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C
pen.dot(7.0, 14.0, a);
pen.dot(17.5, 9.0, a);
}
// Couplings: the driven part in the accent.
"gear" => {
pen.circle(9.0, 14.0, 5.2, pen.fill, Color32::TRANSPARENT);
pen.closed(&teeth(9.0, 14.0, 5.2, 6.8, 9, 0.0), ink);
pen.dot(9.0, 14.0, ink);
pen.circle(17.6, 7.4, 3.2, pen.fill, Color32::TRANSPARENT);
pen.closed(&teeth(17.6, 7.4, 3.2, 4.6, 6, 0.3), a);
pen.dot(17.6, 7.4, a);
}
"rack_pinion" => {
pen.circle(12.0, 7.4, 4.2, pen.fill, Color32::TRANSPARENT);
pen.closed(&teeth(12.0, 7.4, 4.2, 5.8, 8, 0.2), ink);
pen.dot(12.0, 7.4, ink);
let mut rack = vec![(2.0, 18.0)];
for k in 0..5 {
let x = 2.5 + 4.0 * k as f32;
rack.extend([(x, 14.3), (x + 1.0, 12.8), (x + 2.2, 12.8), (x + 3.2, 14.3)]);
}
rack.extend([(22.0, 14.3), (22.0, 18.0)]);
pen.closed(&rack, a);
pen.arrow((7.0, 21.5), (17.5, 21.5), a);
}
"screw" => {
pen.poly(&[(9.5, 2.0), (14.5, 2.0), (14.5, 22.0), (9.5, 22.0)], pen.fill, ink);
for y in [5.0, 8.0, 17.0, 20.0] {
pen.line(&[(9.5, y), (14.5, y - 2.0)], ink);
}
pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 15.0), (6.0, 15.0)], pen.fill, a);
pen.arrow((21.0, 17.0), (21.0, 6.0), a);
}
"interference" => {
pen.closed(&[(3.0, 5.0), (14.0, 5.0), (14.0, 16.0), (3.0, 16.0)], ink);
pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], ic.interference, ic.interference);
@@ -1439,7 +1521,7 @@ mod tests {
/// theme's construction orange) and must not change with `ink`.
#[test]
fn construction_and_centerline_icons_follow_the_callers_accent_only() {
for name in ["construction", "centerline"] {
for name in ["construction", "centerline", "infinite"] {
let base = drawn(name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(1, 2, 3));
let other_accent = drawn(name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(4, 5, 6));
assert_ne!(base, other_accent, "{name} icon must change with the caller's accent colour");
+895 -833
View File
File diff suppressed because it is too large. Load diff
+688 -612
View File
File diff suppressed because it is too large. Load diff
+586 -539
View File
File diff suppressed because it is too large. Load diff
+688 -612
View File
File diff suppressed because it is too large. Load diff
+895 -833
View File
File diff suppressed because it is too large. Load diff
+516 -470
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+460 -413
View File
File diff suppressed because it is too large. Load diff
+694 -618
View File
File diff suppressed because it is too large. Load diff
+1 -6
View File
@@ -93,12 +93,7 @@ impl Tokens {
bend_up: Color32::from_rgb(30, 140, 50),
bend_down: Color32::from_rgb(200, 90, 20),
interference: Color32::from_rgb(235, 45, 40),
analysis: AnalysisColors {
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
curvature: [[0.15, 0.35, 0.95], [0.10, 0.80, 0.25], [0.90, 0.15, 0.10]],
hatch: false,
},
analysis: AnalysisColors::DEFAULT,
icons: IconColors {
sheet: Color32::from_rgb(170, 200, 240),
sheet_2: Color32::from_rgb(150, 185, 235),
+12 -26
View File
@@ -10,16 +10,17 @@
//!
//! What a field is, is written once, on the field, with `#[walk(...)]`:
//!
//! - no attribute: the field's own type walks itself (`impl Walk`): a plane, a nested struct, a
//! list of them, or nothing for names, flags and numbers. A bare `Vec3` has no walk, so a point
//! that does not say its role does not compile;
//! - `length = label`, `angle = label`, `unitless = label`: an expression input (a `String`, an
//! optional one, a list or an array of them, or `(f64, String)` pairs). The label is any
//! - no attribute: the field's own type walks itself (`impl Walk`): a reference (`SketchId`,
//! `BodyName`, `FeatureName`), a point with its role (`FacePick`, `Position`, `Dir`), a plane, a
//! nested struct, a list of them, or nothing for names, flags and numbers. An `Expr` or a bare
//! `Vec3` has no walk, so an expression or a point that does not say what it is does not
//! compile;
//! - `length = label`, `angle = label`, `unitless = label`: an expression input (an `Expr`, an
//! optional one, a list or an array of them, or `(f64, Expr)` pairs). The label is any
//! expression: a `&'static str`, an array of them (one per element), or a closure
//! `|i: usize, n: usize| -> &'static str`; it may read the other fields;
//! - `sketch`, `feature`, `body`: a reference (an id, or a name, optional or a list);
//! - `pick`, `pinned`, `pos`, `dir`: a point (`Vec3`, optional or a list) with that role;
//! `pick_if = cond`: a pick when `cond` holds, else a position;
//! - `pinned`: a face pick that stays where it is when the feature moves;
//! `pick_if = cond`: a face pick when `cond` holds, else a position;
//! - `placement`: a primitive's placement point (its X, Y and Z expressions are not inputs);
//! - `profiles_of = sketch_field`, optionally with `when = cond`: a profile selection;
//! - `after = field`: visit this field right after `field` instead of in declaration order (where
@@ -60,8 +61,6 @@ struct Roles {
geo: Option<Geo>,
/// The kind (`Length`, `Angle`, `Unitless`) and the label.
expr: Option<(&'static str, Expr)>,
/// `Sketch`, `Feature` or `Body`.
reference: Option<&'static str>,
after: Option<Ident>,
/// The positions inside this (nested) value pick faces here.
picks_faces: bool,
@@ -70,7 +69,7 @@ struct Roles {
impl Roles {
/// No role given: the field's type walks itself.
fn nested(&self) -> bool {
self.geo.is_none() && self.expr.is_none() && self.reference.is_none()
self.geo.is_none() && self.expr.is_none()
}
}
@@ -82,10 +81,7 @@ fn roles(field: &syn::Field) -> syn::Result<Roles> {
attr.parse_nested_meta(|meta| {
let name = meta.path.get_ident().map(Ident::to_string).unwrap_or_default();
match name.as_str() {
"pick" => r.geo = Some(Geo::Points("Pick")),
"pinned" => r.geo = Some(Geo::Points("Pinned")),
"pos" => r.geo = Some(Geo::Points("Pos")),
"dir" => r.geo = Some(Geo::Points("Dir")),
"placement" => r.geo = Some(Geo::Placement),
"pick_if" => r.geo = Some(Geo::PickIf(meta.value()?.parse()?)),
"profiles_of" => sketch = Some(meta.value()?.parse::<Ident>()?),
@@ -93,9 +89,6 @@ fn roles(field: &syn::Field) -> syn::Result<Roles> {
"length" => r.expr = Some(("Length", meta.value()?.parse()?)),
"angle" => r.expr = Some(("Angle", meta.value()?.parse()?)),
"unitless" => r.expr = Some(("Unitless", meta.value()?.parse()?)),
"sketch" => r.reference = Some("Sketch"),
"feature" => r.reference = Some("Feature"),
"body" => r.reference = Some("Body"),
"after" => r.after = Some(meta.value()?.parse()?),
"picks_faces" => r.picks_faces = true,
_ => return Err(meta.error("unknown walk role")),
@@ -157,14 +150,7 @@ fn walk_field(b: &Ident, r: &Roles, which: Which, mutable: bool) -> Tokens {
_ => quote!(),
}
}
Which::Refs => match r.reference {
Some(kind) => {
let t = if kind == "Sketch" { quote!(u64) } else { quote!(String) };
let kind = Ident::new(kind, Span::call_site());
quote! { #w::Each::<#t>::#each(#b, &mut |x| f(#w::Ref::#kind(x))); }
}
None => quote!(),
},
Which::Refs => quote!(),
Which::Geometry => match &r.geo {
Some(Geo::Points(v)) => {
let v = Ident::new(v, Span::call_site());
@@ -209,7 +195,7 @@ fn pre_values(b: &Ident, r: &Roles, which: Which) -> Tokens {
quote! {
let #wh: bool = #when;
let mut #id: Option<u64> = None;
#w::Each::<u64>::each(&*#sketch, &mut |s| #id = Some(*s));
#w::Each::<crate::SketchId>::each(&*#sketch, &mut |s| #id = Some(s.0));
}
}
_ => quote!(),
+62
View File
@@ -0,0 +1,62 @@
//! Lists the design-format changes in `src/format/`: one file per change, in `v<N>/` once a
//! release fixed its number, else in `pending/` (see `src/format/pending/README.md`). The list is
//! generated so that a change adds a file and never edits a line another change also edits.
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
/// The last format whose upgrade steps are written out in `format.rs` (`JSON_STEPS`).
const HAND_WRITTEN: u32 = 22;
fn main() {
let root = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("src/format");
println!("cargo:rerun-if-changed={}", root.display());
let mut folded: Vec<(u32, PathBuf)> = std::fs::read_dir(&root)
.unwrap_or_else(|e| panic!("{}: {e}", root.display()))
.filter_map(|entry| {
let path = entry.ok()?.path();
let version = path.file_name()?.to_str()?.strip_prefix('v')?.parse().ok()?;
path.is_dir().then_some((version, path))
})
.collect();
folded.sort();
for (i, (version, _)) in folded.iter().enumerate() {
let expected = HAND_WRITTEN + 1 + i as u32;
assert_eq!(*version, expected, "src/format/: expected v{expected}/ next, found v{version}/ (versions are contiguous)");
}
let mut out = String::new();
let mut mods = String::new();
out.push_str("/// The changes of each format after the hand-written ones, oldest first.\nconst FOLDED: &[(u32, &[Part])] = &[\n");
for (version, dir) in &folded {
let _ = writeln!(out, " ({version}, &[");
parts(dir, &format!("v{version}"), &mut out, &mut mods);
out.push_str(" ]),\n");
}
out.push_str("];\n\n/// The changes no release has a number for yet (`src/format/pending/`).\nconst PENDING: &[Part] = &[\n");
parts(&root.join("pending"), "pending", &mut out, &mut mods);
out.push_str("];\n");
out.push_str(&mods);
let dest = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("format_parts.rs");
std::fs::write(dest, out).unwrap();
}
/// The `Part`s of the change files in `dir`, in file-name order, and a module for each.
fn parts(dir: &Path, prefix: &str, out: &mut String, mods: &mut String) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut files: Vec<PathBuf> = entries.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x == "rs")).collect();
files.sort();
for path in files {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_string();
assert!(
!name.is_empty() && name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'),
"{}: name a format change file with lowercase letters, digits and dashes",
path.display()
);
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.trim_start().starts_with("//!"), "{}: start a format change with a `//!` note", path.display());
let module = format!("{prefix}_{}", name.replace('-', "_"));
let step = if text.contains("fn step(") { format!("Some({module}::step)") } else { "None".into() };
let _ = writeln!(out, " Part {{ name: {name:?}, step: {step} }},");
let _ = writeln!(mods, "#[path = {:?}]\nmod {module};", path.display().to_string());
}
}
+8 -3
View File
@@ -136,11 +136,16 @@ fn map_plane(m: &Mat, p: &Plane) -> Plane {
fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
match p {
PlaneRef::Custom { plane } => *plane = map_plane(m, plane),
PlaneRef::Placed { origin, x_dir, y_dir } => {
map_point_expr(m, origin);
*x_dir = apply_vector(m, *x_dir);
*y_dir = apply_vector(m, *y_dir);
}
PlaneRef::Face { plane, at, .. } => {
*plane = map_plane(m, plane);
*at = apply_point(m, *at);
}
PlaneRef::Offset { base, .. } => map_plane_ref(m, base),
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => map_plane_ref(m, base),
PlaneRef::AtAngle { base, axis_origin, axis_dir, .. } => {
map_plane_ref(m, base);
*axis_origin = apply_point(m, *axis_origin);
@@ -171,7 +176,7 @@ fn map_geo_ref(m: &Mat, g: &mut crate::construct::GeoRef) {
/// Map a point given as expressions by `m`: numbers stay numbers; a coordinate that is an
/// expression stays one (`m · (x, y, z) + t`, written out), so it still follows its parameters.
pub fn map_point_expr(m: &Mat, p: &mut crate::PointExpr) {
let num = |e: &String| e.trim().parse::<f64>().ok().filter(|v| v.is_finite());
let num = |e: &crate::Expr| e.trim().parse::<f64>().ok().filter(|v| v.is_finite());
if let [Some(x), Some(y), Some(z)] = [num(&p[0]), num(&p[1]), num(&p[2])] {
*p = crate::point_expr(apply_point(m, Vec3::new(x, y, z)));
return;
@@ -197,7 +202,7 @@ pub fn map_point_expr(m: &Mat, p: &mut crate::PointExpr) {
} else if constant.abs() >= 1e-12 {
e = format!("{e} {} {} mm", if constant < 0.0 { '-' } else { '+' }, constant.abs());
}
*out = e;
*out = e.into();
}
}
+7 -1
View File
@@ -78,7 +78,13 @@ mod tests {
(vec!["bad\nname".into()], 0, crate::IDENTITY),
(vec!["Block".into()], 0, scaled),
] {
assert!(doc.add_feature(crate::FeatureKind::BodyTransfer { bodies, from, to: 0, transform }, None).is_err());
assert!(
doc.add_feature(
crate::FeatureKind::BodyTransfer { bodies: bodies.into_iter().map(crate::BodyName).collect(), from, to: 0, transform },
None
)
.is_err()
);
assert_eq!(doc.to_json(), before);
}
}
+8 -6
View File
@@ -113,8 +113,10 @@ fn remap_plane(
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
}
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => remap_plane(base, ids, names, bodies, depth + 1)?,
PlaneRef::Construction { name } => mapped_name(name, names),
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => {
remap_plane(base, ids, names, bodies, depth + 1)?
}
PlaneRef::Construction { name } => mapped_name(&mut name.0, names),
PlaneRef::Face { name, anchor, .. } => {
if let Some(name) = name {
*name = remap_topology(name, ids, bodies);
@@ -221,10 +223,10 @@ impl crate::Feature {
*n = remap_topology(n, ids, bodies);
}
for (_, e, _) in self.kind.inputs_mut() {
*e = clone_expression(e, parameters);
*e = clone_expression(e, parameters).into();
}
for e in self.kind.point_expr_mut().into_iter().flatten() {
*e = clone_expression(e, parameters);
for e in self.kind.placement_exprs_mut() {
*e = clone_expression(e, parameters).into();
}
self.kind.remap_bodies(bodies);
match &mut self.kind {
@@ -283,7 +285,7 @@ fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> {
return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into()));
}
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => checked_plane(base, depth + 1)?,
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => checked_plane(base, depth + 1)?,
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => {
checked_geo(a, depth + 1)?;
checked_geo(b, depth + 1)?;
+29
View File
@@ -148,6 +148,35 @@ pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solidcraft_k
Some(bindings)
}
/// Cap signatures for a thin extrude (#1519). A thin wall's caps are bounded by offset curves
/// the sketch doesn't have, so [`extrude_cap_bindings`] can never certify them. A wall built
/// from one region is signed by the curves it follows instead: the cap of this extrude whose
/// wall runs along them. That is the signature its solid caps had when the wall follows the
/// whole profile (a rectangle's four lines), so a sketch attached to a solid cap keeps its face
/// when the extrude is made thin, and the other way round. Several regions (walls apart, or one
/// meeting itself) are left uncertified: they keep their history names.
pub(crate) fn thin_extrude_cap_bindings(curves: &[String], regions: usize, bodies: &[solidcraft_kernel::Body]) -> CapBindings {
use solidcraft_kernel::FaceSource;
let mut outer = curves.to_vec();
outer.sort();
outer.dedup();
let signed = regions == 1 && bodies.len() == 1 && valid_signature(&outer, &[]);
bodies
.iter()
.map(|body| {
let sources: Vec<_> = body.face_sources().into_iter().flatten().collect();
let ends =
|high: bool| sources.iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeCap { high: h, .. }) if *h == high)).count();
// One cap at each end, or none of them is certified.
let unique = ends(true) <= 1 && ends(false) <= 1;
sources
.iter()
.map(|source| (signed && unique && matches!(source, Some(FaceSource::ExtrudeCap { .. }))).then(|| (outer.clone(), Vec::new())))
.collect()
})
.collect()
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FaceCertificate {
+6 -6
View File
@@ -17,7 +17,7 @@ fn shell_document() -> (Document, u64, u64) {
let shell = doc
.add_feature(
FeatureKind::Shell {
faces: vec![Vec3::new(5., 6., 10.)],
faces: vec![crate::FacePick(Vec3::new(5., 6., 10.))],
thickness: "wall".into(),
body: Some("Case".into()),
direction: "inside".into(),
@@ -119,7 +119,7 @@ fn shell_planar_anchor_rebuild_cold_geometry_and_missing_source_errors() {
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: sk,
sketch: crate::SketchId(sk),
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"2"})).unwrap(),
operation: crate::Operation::NewBody,
@@ -282,7 +282,7 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: sk,
sketch: crate::SketchId(sk),
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
operation: crate::Operation::NewBody,
@@ -344,7 +344,7 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
let shell = doc
.add_feature(
FeatureKind::Shell {
faces: vec![Vec3::new(0., 0., 30.)],
faces: vec![crate::FacePick(Vec3::new(0., 0., 30.))],
thickness: "wall".into(),
body: Some("Case".into()),
direction: "inside".into(),
@@ -432,7 +432,7 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: sk,
sketch: crate::SketchId(sk),
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
operation: crate::Operation::NewBody,
@@ -557,7 +557,7 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: sk,
sketch: crate::SketchId(sk),
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
operation: crate::Operation::NewBody,
+2 -2
View File
@@ -82,7 +82,7 @@ pub enum PointDef {
/// Where an edge (line) crosses a plane.
EdgeAndPlane { edge: GeoRef, plane: GeoRef },
/// At a fraction `t` (0…1) of the way along an edge or sketch curve.
AlongPath { path: GeoRef, t: String },
AlongPath { path: GeoRef, t: crate::Expr },
}
/// Resolved construction geometry, kept in the model state by feature.
@@ -633,7 +633,7 @@ impl PlaneRef {
PlaneRef::ThreePoints { a, b, c } => vec![a.as_mut(), b.as_mut(), c.as_mut()],
PlaneRef::Tangent { face, .. } => vec![face.as_mut()],
PlaneRef::AlongPath { path, .. } => vec![path.as_mut()],
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => base.refs_mut(),
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => base.refs_mut(),
_ => Vec::new(),
}
}
+456 -401
View File
File diff suppressed because it is too large. Load diff
+131 -97
View File
@@ -951,15 +951,15 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
_ => None,
};
if let Some(path) = path
&& doc.sketch(path).is_ok_and(|sketch| !sketch.wires.is_empty())
&& doc.sketch(path.0).is_ok_and(|sketch| !sketch.wires.is_empty())
{
let mut hash = std::collections::hash_map::DefaultHasher::new();
let owner = doc.feature(path).map(|feature| feature.component).unwrap_or(0);
let owner = doc.feature(path.0).map(|feature| feature.component).unwrap_or(0);
for frame in [doc.component_transform(f.component), doc.component_transform(owner)] {
frame.iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
}
if let FeatureKind::Sweep { sketch, .. } | FeatureKind::SurfaceSweep { sketch, .. } = &f.kind {
let owner = doc.feature(*sketch).map(|feature| feature.component).unwrap_or(0);
let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0);
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
}
return hash.finish();
@@ -1951,7 +1951,7 @@ fn face_level_at(st: &ModelState, plane: &Plane, h: f64, targets: &[String]) ->
/// The body a face-based feature applies to: by name, or the one whose surface is nearest the
/// first point.
fn body_at(st: &ModelState, body: &Option<String>, points: &[Vec3]) -> Result<usize> {
fn body_at(st: &ModelState, body: &Option<crate::BodyName>, points: &[Vec3]) -> Result<usize> {
if let Some(n) = body {
return st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")));
}
@@ -2085,7 +2085,7 @@ fn edge_dir(e: &kernel::EdgeInfo) -> Vec3 {
}
/// References for edges picked at `pts` on the body a blend would go on (the state before it).
pub fn edge_refs(st: &ModelState, body: &Option<String>, pts: &[Vec3]) -> Vec<EdgeRef> {
pub fn edge_refs(st: &ModelState, body: &Option<crate::BodyName>, pts: &[Vec3]) -> Vec<EdgeRef> {
let Ok(ti) = blend_target(st, body, pts) else { return Vec::new() };
let Some(mb) = st.bodies.get(ti) else { return Vec::new() };
let size = mb.body.size();
@@ -2103,7 +2103,7 @@ pub fn edge_refs(st: &ModelState, body: &Option<String>, pts: &[Vec3]) -> Vec<Ed
}
/// Persistent names of the edges picked at `pts` on the body a blend would go on.
pub fn edge_names_for(st: &ModelState, body: &Option<String>, pts: &[Vec3]) -> Vec<String> {
pub fn edge_names_for(st: &ModelState, body: &Option<crate::BodyName>, pts: &[Vec3]) -> Vec<String> {
match blend_target(st, body, pts).ok().and_then(|i| st.bodies.get(i)) {
Some(b) => crate::naming::edge_names_at(b, pts),
None => Vec::new(),
@@ -2190,7 +2190,7 @@ fn resolve_edges(b: &Body, pts: &[Vec3], refs: &[EdgeRef], warning: &mut Option<
}
/// The body a fillet/chamfer applies to: by name, or the one closest to the first edge point.
fn blend_target(state: &ModelState, body: &Option<String>, edges: &[Vec3]) -> Result<usize> {
fn blend_target(state: &ModelState, body: &Option<crate::BodyName>, edges: &[Vec3]) -> Result<usize> {
if let Some(n) = body {
return state.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")));
}
@@ -2246,10 +2246,10 @@ pub(crate) fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef) -> Result<(Vec2, V
AxisRef::Line { origin, dir } => {
let pl = &ss.plane;
let d = dir.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?;
if d.dot(pl.normal()).abs() > 1e-9 || pl.height(*origin).abs() > 1e-6 {
if d.dot(pl.normal()).abs() > 1e-9 || pl.height(origin.0).abs() > 1e-6 {
return Err(DocError::Invalid("the revolve axis does not lie in the sketch plane".into()));
}
Ok((pl.to_local(*origin), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
Ok((pl.to_local(origin.0), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
}
}
}
@@ -2303,7 +2303,8 @@ fn hole_spots(st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
let FeatureKind::Hole { position, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) };
let (spots, dir) = match points {
Some(sp) => {
let ss = st.sketch(sp.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sp.sketch)))?;
let ss =
st.sketch(sp.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sp.sketch)))?;
let mut v = Vec::new();
for id in &sp.ids {
let p = ss.sketch.points.iter().find(|p| &p.id == id).ok_or_else(|| DocError::Unknown(format!("sketch point `{id}`")))?;
@@ -2315,7 +2316,7 @@ fn hole_spots(st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
}
None => {
let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
let at = hole_on_surface(st, *position, dir);
let at = hole_on_surface(st, position.0, dir);
let dir = hole_dir_into_face(st, at, dir);
return Ok((vec![hole_on_surface(st, at, dir)], dir));
}
@@ -2328,7 +2329,7 @@ fn hole_spots(st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
/// A hole's depth: down to the To Object (where the hole's axis meets the face there, else the
/// point's depth along the axis), else as given.
fn hole_depth_to(st: &ModelState, at: &Vec3, dir: Vec3, to: &Option<Vec3>, depth: &Option<String>) -> Result<Option<String>> {
fn hole_depth_to(st: &ModelState, at: &Vec3, dir: Vec3, to: &Option<Vec3>, depth: &Option<crate::Expr>) -> Result<Option<crate::Expr>> {
let Some(t) = to else { return Ok(depth.clone()) };
let mut d = (*t - *at).dot(dir);
// The face at the To point, met along the axis.
@@ -2345,7 +2346,7 @@ fn hole_depth_to(st: &ModelState, at: &Vec3, dir: Vec3, to: &Option<Vec3>, depth
if !(d > 1e-6) {
return Err(DocError::Invalid("the hole's To object is not ahead of the hole".into()));
}
Ok(Some(format!("{d} mm")))
Ok(Some(format!("{d} mm").into()))
}
/// A hole's spots as given (not yet moved onto the surface) and its direction.
@@ -2353,7 +2354,7 @@ fn hole_spots_raw(st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
let FeatureKind::Hole { position, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) };
match points {
Some(sp) => {
let ss = st.sketch(sp.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", sp.sketch)))?;
let ss = st.sketch(sp.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {}", sp.sketch)))?;
let v: Vec<Vec3> =
sp.ids.iter().filter_map(|id| ss.sketch.points.iter().find(|p| &p.id == id)).map(|p| ss.plane.to_world(p.pos)).take(10_000).collect();
let dir = sketch_hole_dir(st, &v, ss.plane.normal());
@@ -2362,7 +2363,7 @@ fn hole_spots_raw(st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
}
None => {
let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
Ok((vec![*position], hole_dir_into_face(st, hole_on_surface(st, *position, dir), dir)))
Ok((vec![position.0], hole_dir_into_face(st, hole_on_surface(st, position.0, dir), dir)))
}
}
}
@@ -2377,7 +2378,7 @@ fn hole_feature_tools(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Featu
}
let mut tools = Vec::new();
for at in spots {
let depth = hole_depth_to(st, &at, dir, to, depth)?;
let depth = hole_depth_to(st, &at, dir, &to.map(Vec3::from), depth)?;
tools.extend(hole_tools_and_prisms(vals, st, &at, dir, diameter, &depth, hole)?);
}
Ok(tools)
@@ -2454,7 +2455,7 @@ fn hole_tools(
position: &Vec3,
dir: Vec3,
diameter: &str,
depth: &Option<String>,
depth: &Option<crate::Expr>,
hole: &HoleKind,
) -> Result<Vec<Traced>> {
Ok(hole_tools_and_prisms(vals, st, position, dir, diameter, depth, hole)?.into_iter().map(|(tool, _)| tool).collect())
@@ -2471,7 +2472,7 @@ fn hole_tools_and_prisms(
position: &Vec3,
dir: Vec3,
diameter: &str,
depth: &Option<String>,
depth: &Option<crate::Expr>,
hole: &HoleKind,
) -> Result<Vec<(Traced, Option<more::PrimitivePrism>)>> {
let r = val(vals, diameter, Kind::Length)? / 2.0;
@@ -2746,7 +2747,7 @@ fn feature_tools_with_constructor(
FeatureKind::Extrude { sketch, profiles, extent, operation, targets, thin } => {
let ss = sketch_in_feature_frame(
doc,
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
let regions = match thin {
@@ -2762,14 +2763,14 @@ fn feature_tools_with_constructor(
// The face names follow `face_points`: To, then From.
let name = |k: usize| f.face_names.get(k).map(String::as_str);
let (to_name, from_name) = if extent.to.is_some() { (name(0), name(1)) } else { (None, name(0)) };
let to_face = extent.to.and_then(|p| object_face(&tst, to_name, p));
let to_face = extent.to.and_then(|p| object_face(&tst, to_name, p.0));
let centre = {
let pts: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
let c = pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64);
ss.plane.to_world(c)
};
let from = match extent.from {
Some(p) => height_at(&tst, object_face(&tst, from_name, p), &ss.plane, centre, p),
Some(p) => height_at(&tst, object_face(&tst, from_name, p.0), &ss.plane, centre, p.0),
None => 0.0,
};
let d = match extent.to {
@@ -2778,7 +2779,7 @@ fn feature_tools_with_constructor(
Some(e) => val(vals, e, Kind::Length)?,
None => 0.0,
};
let h = height_at(&tst, to_face, &ss.plane, centre, p);
let h = height_at(&tst, to_face, &ss.plane, centre, p.0);
let start = from + extent.start_offset.as_deref().map(|e| val(vals, e, Kind::Length)).transpose()?.unwrap_or(0.0);
// Past the face by the offset, in the direction travelled.
let to = h + if h >= start { off } else { -off };
@@ -2787,7 +2788,7 @@ fn feature_tools_with_constructor(
}
to - start
}
None if extent.through_all => through_all_distance(st, &ss.plane, targets)?,
None if extent.through_all => through_all_distance(st, &ss.plane, &crate::plain(targets))?,
None => val(vals, &extent.distance, Kind::Length)?,
};
if let Some(tp) = &extent.taper {
@@ -2805,7 +2806,7 @@ fn feature_tools_with_constructor(
// How far a tool grows past its profile at either end, at most.
let mut growth: f64 = 0.0;
// A face of the targets in the profile's plane (the same for every profile).
let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, targets);
let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, &crate::plain(targets));
let tools: Vec<Traced> = regions
.iter()
.map(|r| {
@@ -2814,7 +2815,7 @@ fn feature_tools_with_constructor(
// and the boolean finds no result there. The sides keep their slope, so
// the profile stays the section at the start.
let (lo, hi) = if sign < 0.0 { (from - len, from) } else { (from, from + len) };
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo, hi, *operation, targets);
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo, hi, *operation, &crate::plain(targets));
let (a, b) = if sign < 0.0 { (from - h2, from - l2) } else { (l2 - from, h2 - from) };
// A start that can't run on (the profile hangs over the face's edge):
// the sides stand straight for a hair first, so the boolean can push the
@@ -2882,7 +2883,7 @@ fn feature_tools_with_constructor(
if exact.iter().all(|tool| {
st.bodies
.iter()
.filter(|b| targets.is_empty() || targets.contains(&b.name))
.filter(|b| targets.is_empty() || targets.iter().any(|t| *t == b.name))
.any(|b| kernel::can_join_touching_faces(&b.body, &tool.body))
}) {
// Preserve a whole touching face: extending into a chamfer replaces
@@ -2893,11 +2894,12 @@ fn feature_tools_with_constructor(
continue;
}
}
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets);
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, &crate::plain(targets));
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
let original = r;
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
let r = fill_void_holes(st, &r, *operation, targets, &ss.plane, (l2, h2));
let plain_targets = crate::plain(targets);
let r = grow_profile_for_coplanar(st, r, *operation, &plain_targets, &probe);
let r = fill_void_holes(st, &r, *operation, &plain_targets, &ss.plane, (l2, h2));
untrimmed &= l2 == lo + off && h2 == hi + off && &r == original;
tools.extend(centered_extrude(&ss.plane, &r, l2, h2, Some(&ss))?);
footprints.push(r);
@@ -2938,7 +2940,10 @@ fn feature_tools_with_constructor(
// certified as a new body's are (see `Body::with_preserved_union_planar_sources`).
if matches!(operation, Operation::NewBody | Operation::Join) && extent.taper.is_none() {
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
let cap_bindings = crate::config::extrude_cap_bindings(&ss, &bodies, 10_000_000);
let cap_bindings = match thin {
Some(thin) => Some(crate::config::thin_extrude_cap_bindings(&thin.curves, regions.len(), &bodies)),
None => crate::config::extrude_cap_bindings(&ss, &bodies, 10_000_000),
};
let source_curves = cap_bindings.as_ref().map(|_| crate::config::ExactSketchCurves::new(&ss));
tools = tools
.into_iter()
@@ -2970,7 +2975,7 @@ fn feature_tools_with_constructor(
}
}
// Certificates set face sources only: the faces, and their lineage, stay.
let body = body.with_extrude_certificates(f.id, *sketch, &curves)?.with_extrude_cap_certificates(f.id, *sketch, &caps)?;
let body = body.with_extrude_certificates(f.id, sketch.0, &curves)?.with_extrude_cap_certificates(f.id, sketch.0, &caps)?;
Ok(Traced { body, lineage })
})
.collect::<Result<Vec<_>>>()?;
@@ -2980,7 +2985,7 @@ fn feature_tools_with_constructor(
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, convention, to, .. } => {
let ss = sketch_in_feature_frame(
doc,
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
@@ -2999,7 +3004,7 @@ fn feature_tools_with_constructor(
(ss.plane.to_world(o), ss.plane.dir_to_world(d)),
angles,
*symmetric,
*to,
to.map(Vec3::from),
*convention,
)?;
let (start, ang) = (extent.start, extent.sweep);
@@ -3014,7 +3019,7 @@ fn feature_tools_with_constructor(
(ss.plane.to_world(o), ss.plane.dir_to_world(d)),
angles,
*symmetric,
*to,
to.map(Vec3::from),
*convention,
)
} else {
@@ -3049,7 +3054,7 @@ fn feature_tools_with_constructor(
.collect::<Result<Vec<_>>>()?;
// To a curved face: turn past the whole target body, then cut back to its surface
// (the piece that starts at the profile).
let curved = curved_face(&tst, to.and_then(|p| object_face(&tst, f.face_names.first().map(String::as_str), p)), None);
let curved = curved_face(&tst, to.and_then(|p| object_face(&tst, f.face_names.first().map(String::as_str), p.0)), None);
let curved_body = curved.and_then(|bi| tst.bodies.get(bi));
// Each region's body: its sides named by the sketch curves, its caps start and end.
let tool = |r: &Region2, body: Body, built: Option<&Body>| crate::lineage::ProfileRoles::swept(Some(&ss), Some(r)).tool(body, built);
@@ -3111,11 +3116,11 @@ fn feature_tools_with_constructor(
// A profile lofted to a sketch point (an apex).
let [a, b] = &sections[..] else { return Err(DocError::Invalid("not supported yet: a point section in a loft of more than two".into())) };
let (prof, pt) = if a.point.is_some() { (b, a) } else { (a, b) };
let ss = st.sketch(prof.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", prof.sketch)))?;
let ss = st.sketch(prof.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {}", prof.sketch)))?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, &prof.profiles)?);
let [r] = &regions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
refuse_loft_holes(r)?;
let ps = st.sketch(pt.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", pt.sketch)))?;
let ps = st.sketch(pt.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {}", pt.sketch)))?;
let id = pt.point.as_deref().unwrap_or_default();
let q = ps.sketch.points.iter().find(|p| p.id == id).ok_or_else(|| DocError::Unknown(format!("point `{id}` in {}", ps.name)))?;
let body = kernel::loft_to_point(&ss.plane, &r.outer, ps.plane.to_world(q.pos))?;
@@ -3166,7 +3171,7 @@ fn feature_tools_with_constructor(
let mut sketches = Vec::new();
for sec in sections {
let ss = st
.sketch(sec.sketch)
.sketch(sec.sketch.0)
.ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, &sec.profiles)?);
let [r] = &regions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
@@ -3182,9 +3187,9 @@ fn feature_tools_with_constructor(
}
FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, .. } => {
let ctl = sweep_control(vals, orientation, twist, scale)?;
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
let profile;
let ss = if !ps.sketch.wires.is_empty() {
profile = profile_in_frame(doc, ss, f.component)?;
@@ -3206,7 +3211,7 @@ fn feature_tools_with_constructor(
}
FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => {
let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
let mapped = path_in_frame(doc, ps, f.component)?;
let ps = mapped.as_ref();
let r = val(vals, diameter, Kind::Length)? / 2.0;
@@ -3236,7 +3241,7 @@ fn feature_tools_with_constructor(
}
FeatureKind::Cylinder { base, axis, radius, height, .. } => Ok(vec![crate::lineage::cylinder_tool(kernel::cylinder(
point(vals, base)?,
*axis,
axis.0,
val(vals, radius, Kind::Length)?,
val(vals, height, Kind::Length)?,
)?)]),
@@ -3255,13 +3260,13 @@ fn feature_tools_with_constructor(
pub(crate) fn sweep_control(
vals: &BTreeMap<String, Value>,
orientation: &crate::SweepOrientation,
twist: &Option<String>,
scale: &Option<String>,
twist: &Option<crate::Expr>,
scale: &Option<crate::Expr>,
) -> Result<kernel::SweepControl> {
let orientation = match *orientation {
crate::SweepOrientation::Perpendicular => kernel::SweepOrientation::Perpendicular,
crate::SweepOrientation::Parallel => kernel::SweepOrientation::Parallel,
crate::SweepOrientation::Direction(d) => kernel::SweepOrientation::Direction(d),
crate::SweepOrientation::Direction(d) => kernel::SweepOrientation::Direction(d.0),
};
let twist = match twist {
Some(t) => val(vals, t, Kind::Angle)?,
@@ -3277,7 +3282,7 @@ pub(crate) fn sweep_control(
}
/// A pipe's section: a circle of radius `r`, hollow when it has a wall.
fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<String>) -> Result<Region2> {
fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<crate::Expr>) -> Result<Region2> {
let holes = match wall {
Some(w) => {
let wt = val(vals, w, Kind::Length)?;
@@ -3482,15 +3487,15 @@ pub(crate) fn extent_sides(vals: &BTreeMap<String, Value>, extent: &crate::Exten
/// Operation and targets of a feature that makes tools.
fn feature_op(f: &Feature) -> (Operation, Vec<String>) {
match &f.kind {
FeatureKind::Extrude { operation, targets, .. } | FeatureKind::Revolve { operation, targets, .. } => (*operation, targets.clone()),
FeatureKind::Extrude { operation, targets, .. } | FeatureKind::Revolve { operation, targets, .. } => (*operation, crate::plain(targets)),
FeatureKind::Box { operation, .. }
| FeatureKind::Cylinder { operation, .. }
| FeatureKind::Sphere { operation, .. }
| FeatureKind::Torus { operation, .. } => (*operation, Vec::new()),
FeatureKind::Hole { .. } => (Operation::Cut, Vec::new()),
FeatureKind::Pipe { operation, targets, .. } => (*operation, targets.clone()),
FeatureKind::Loft { operation, targets, .. } | FeatureKind::Sweep { operation, targets, .. } => (*operation, targets.clone()),
FeatureKind::Coil { operation, targets, .. } => (*operation, targets.clone()),
FeatureKind::Pipe { operation, targets, .. } => (*operation, crate::plain(targets)),
FeatureKind::Loft { operation, targets, .. } | FeatureKind::Sweep { operation, targets, .. } => (*operation, crate::plain(targets)),
FeatureKind::Coil { operation, targets, .. } => (*operation, crate::plain(targets)),
_ => (Operation::NewBody, Vec::new()),
}
}
@@ -3556,7 +3561,7 @@ fn pattern_transforms(doc: &Document, vals: &BTreeMap<String, Value>, st: &Model
};
let mut out = Vec::new();
match p {
crate::PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2 } => {
crate::PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2, alignment, alignment2, reversed, reversed2 } => {
let (n1, s1) = (count(count1)?, val(vals, spacing1, Kind::Length)?);
let d1 = dir1.normalized().ok_or_else(|| DocError::Invalid("pattern direction".into()))?;
let (n2, s2, d2) = match (dir2, count2, spacing2) {
@@ -3573,14 +3578,14 @@ fn pattern_transforms(doc: &Document, vals: &BTreeMap<String, Value>, st: &Model
if i == 0 && j == 0 {
continue;
}
out.push(translation(d1 * (s1 * i as f64) + d2 * (s2 * j as f64)));
out.push(translation(d1 * (s1 * alignment.multiplier(i, *reversed)) + d2 * (s2 * alignment2.multiplier(j, *reversed2))));
}
}
}
crate::PatternKind::Path { path_sketch, path, count: c, spacing, extent, flip, orient } => {
let n = count(c)?;
let d = val(vals, spacing, Kind::Length)?;
let ps = st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("path sketch {path_sketch}")))?;
let ps = st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("path sketch {path_sketch}")))?;
let mapped = path_in_frame(doc, ps, component)?;
let ps = mapped.as_ref();
let mut segs = path_segments(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))?;
@@ -3610,7 +3615,7 @@ fn pattern_transforms(doc: &Document, vals: &BTreeMap<String, Value>, st: &Model
out.push(m);
}
}
crate::PatternKind::Circular { origin, axis, count: c, angle } => {
crate::PatternKind::Circular { origin, axis, count: c, angle, alignment, reversed } => {
let n = count(c)?;
let total = val(vals, angle, Kind::Angle)?;
let full = (total.abs() - std::f64::consts::TAU).abs() < 1e-9;
@@ -3622,7 +3627,7 @@ fn pattern_transforms(doc: &Document, vals: &BTreeMap<String, Value>, st: &Model
0.0
};
for i in 1..n {
out.push(rotation(*origin, *axis, step * i as f64));
out.push(rotation(origin.0, axis.0, step * alignment.multiplier(i, *reversed)));
}
}
}
@@ -3719,7 +3724,7 @@ fn replay(
let r = val(vals, radius, Kind::Length)?;
// The edges the feature blended in this evaluation (found by name), falling back to
// its picked points.
let found = st.blend_edges.get(&src.id).cloned().unwrap_or_else(|| edges.clone());
let found = st.blend_edges.get(&src.id).cloned().unwrap_or_else(|| crate::plain(edges));
for m in mats {
let moved: Vec<Vec3> = found.iter().map(|p| crate::apply_point(m, *p)).collect();
let ti = blend_target(st, &None, &moved)?;
@@ -3754,7 +3759,7 @@ fn replay(
let d = crate::apply_vector(m, dir).normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
for p in &spots {
let at = hole_on_surface(st, crate::apply_point(m, *p), d);
let to = to.map(|t| crate::apply_point(m, t));
let to = to.map(|t| crate::apply_point(m, t.0));
let depth = hole_depth_to(st, &at, d, &to, depth)?;
// Each copy's faces are named as the hole's, with the instance.
copies.extend(
@@ -3993,6 +3998,9 @@ pub(crate) fn sheet_face_plane_in_frame(doc: &Document, st: &ModelState, comp: u
axis_dir: *axis_dir,
angle: angle.clone(),
},
PlaneRef::Flipped { base, about_y } => {
PlaneRef::Flipped { base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?), about_y: *about_y }
}
other => other.clone(),
})
}
@@ -4082,8 +4090,8 @@ fn chamfer_with(
b: &kernel::Body,
edges: &[Vec3],
d: f64,
distance2: &Option<String>,
angle: &Option<String>,
distance2: &Option<crate::Expr>,
angle: &Option<crate::Expr>,
flip: bool,
) -> Result<kernel::Body> {
let side = match (distance2, angle) {
@@ -4099,7 +4107,9 @@ fn chamfer_with(
fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
match &f.kind {
FeatureKind::BodyTransfer { bodies, from, to, transform } => crate::body_transfer::evaluate(doc, st, bodies, *from, *to, *transform),
FeatureKind::BodyTransfer { bodies, from, to, transform } => {
crate::body_transfer::evaluate(doc, st, &crate::plain(bodies), *from, *to, *transform)
}
FeatureKind::SheetBase { .. }
| FeatureKind::SheetContour { .. }
| FeatureKind::SheetFlange { .. }
@@ -4123,6 +4133,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::SurfaceSweep { .. }
| FeatureKind::SurfaceLoft { .. }
| FeatureKind::SurfaceBlend { .. }
| FeatureKind::SurfaceBoundary { .. }
| FeatureKind::SurfaceMatch { .. }
| FeatureKind::SurfaceFair { .. }
| FeatureKind::SurfaceOffset { .. }
| FeatureKind::SurfaceRuled { .. }
| FeatureKind::SurfaceUntrim { .. } => surface::eval(doc, vals, f, st, warning),
@@ -4186,12 +4199,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
// Cuts through sheet metal become cut-outs of its flat pattern.
if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref(), warning)?;
let handled = more::sheet_cut(f, st, &bodies, &crate::plain(targets), constructor.as_ref(), warning)?;
if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, targets, &handled);
return apply_beside_sheet_cut(st, f, tools, *operation, &crate::plain(targets), &handled);
}
}
apply_traced(st, f, tools, *operation, targets)
apply_traced(st, f, tools, *operation, &crate::plain(targets))
}
FeatureKind::Hole { thread, diameter, .. } => {
let (spots, dir) = hole_spots(st, f)?;
@@ -4219,14 +4232,14 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
Some(e) => Some(val(vals, e, Kind::Length)?),
None => None,
};
let t = thread_on(st, f.id, *face, designation.as_deref(), l)?;
let t = thread_on(st, f.id, face.0, designation.as_deref(), l)?;
if !*modeled {
st.threads.push(t);
return Ok(());
}
let i = st.bodies.iter().position(|b| b.name == t.body).ok_or_else(|| DocError::Invalid("threaded body".into()))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let cyl = kernel::cylinder_face_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("no cylindrical face there to thread".into()))?;
let cyl = kernel::cylinder_face_at(&mb.body, face.0).ok_or_else(|| DocError::Invalid("no cylindrical face there to thread".into()))?;
let tol = t.pitch * 1e-3;
let groove = thread_groove(&t, cyl.radius, ((t.start - cyl.start).abs() < tol, (t.end - cyl.end).abs() < tol))?;
// The B-rep boolean can't take helical walls in reasonable time: the body is cut
@@ -4244,9 +4257,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
FeatureKind::Shell { faces, thickness, body, direction, tangent_chain } => {
let t = val(vals, thickness, Kind::Length)?;
let i = body_at(st, body, faces)?;
let i = body_at(st, body, &crate::plain(faces))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let open = if *tangent_chain { smooth_chain(&mb, faces) } else { faces.clone() };
let open = if *tangent_chain { smooth_chain(&mb, &crate::plain(faces)) } else { crate::plain(faces) };
let input = mb.traced();
// The shell, and the operands of the operation that made it.
let (nb, operands) = match direction.as_str() {
@@ -4283,9 +4296,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
FeatureKind::Draft { faces, angle, neutral, pull, body } => {
let a = val(vals, angle, Kind::Angle)?;
let pl = doc.resolve_plane_in(vals, neutral, 0, Some(st))?;
let i = body_at(st, body, faces)?;
let i = body_at(st, body, &crate::plain(faces))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let nb = Traced::step(kernel::draft(&mb.body, faces, &pl, *pull, a)?, &[&mb.traced()]);
let nb = Traced::step(kernel::draft(&mb.body, &crate::plain(faces), &pl, pull.0, a)?, &[&mb.traced()]);
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
}
@@ -4329,7 +4342,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let mut made = Vec::new();
for p in cells {
// The cell: inside the tools that hold the point, outside the others.
let inside: Vec<bool> = tb.iter().map(|b| b.mesh().contains(*p)).collect();
let inside: Vec<bool> = tb.iter().map(|b| b.mesh().contains(p.0)).collect();
let mut acc: Option<Traced> = None;
for (b, _) in tb.iter().zip(&inside).filter(|(_, i)| **i) {
acc = match acc {
@@ -4347,16 +4360,16 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
made.push(cell);
}
if *remove_tools {
st.bodies.retain(|b| !tools.contains(&b.name));
st.bodies.retain(|b| !tools.iter().any(|t| *t == b.name));
}
apply_traced(st, f, made, *operation, &[])
}
FeatureKind::SplitFace { faces, body, plane, tool, sketch } => {
let i = body_at(st, body, faces)?;
let i = body_at(st, body, &crate::plain(faces))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let mut picked: Vec<usize> = Vec::new();
for p in faces {
let fi = crate::appearance::face_index_at(&mb, *p)
let fi = crate::appearance::face_index_at(&mb, p.0)
.ok_or_else(|| DocError::Invalid(format!("Split Face: no face of {} at {p:?}", mb.name)))?;
if !picked.contains(&fi) {
picked.push(fi);
@@ -4365,12 +4378,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let nb = match (tool, sketch) {
(Some(t), _) => {
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
let face = crate::appearance::face_index_at(tb, t.point)
let face = crate::appearance::face_index_at(tb, t.point.0)
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?;
kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Face { body: &tb.body, face })?
}
(None, Some(sid)) => {
let ss = st.sketch(*sid).ok_or_else(|| DocError::Unknown(format!("sketch {sid}")))?;
let ss = st.sketch(sid.0).ok_or_else(|| DocError::Unknown(format!("sketch {sid}")))?;
// Each curve's exact segments (lines and arcs split by their own plane and cylinder).
let curves: Vec<Vec<solidcraft_geom::Seg2>> = (0..ss.sketch.curves.len())
.filter(|ci| ss.sketch.curves.get(*ci).is_some_and(|c| !c.construction))
@@ -4393,14 +4406,21 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
Ok(())
}
FeatureKind::Split { body, plane, tool } => {
FeatureKind::Split { body, plane, tool, sketch, line } => {
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Unknown(format!("body `{body}`"))) };
let parts = match tool {
// A sketch line: the plane through it along the sketch's normal.
_ if sketch.is_some() || line.is_some() => {
let (Some(sketch), Some(line)) = (sketch, line) else {
return Err(DocError::Invalid("a sketch line tool needs both its sketch and its line".into()));
};
kernel::split_by_plane(&mb.body, &sketch_line_plane(st, sketch.0, line)?)?
}
// A face of a body (its surface extended).
Some(t) => {
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
let face = crate::appearance::face_index_at(tb, t.point)
let face = crate::appearance::face_index_at(tb, t.point.0)
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?;
kernel::split_body(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face })?
}
@@ -4429,7 +4449,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
FeatureKind::Pattern { features, pattern, bodies } => {
let mats = pattern_transforms(doc, vals, st, f.component, pattern)?;
if bodies.is_empty() {
return replay(doc, vals, f, st, features, &mats, warning);
return replay(doc, vals, f, st, &crate::plain(features), &mats, warning);
}
// Bodies: each copy is a new body named after its source.
let mut output = 0;
@@ -4447,7 +4467,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
FeatureKind::Mirror { features, plane, bodies, combine } => {
let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?;
if bodies.is_empty() {
return replay(doc, vals, f, st, features, &[mirror_matrix(&pl)], warning);
return replay(doc, vals, f, st, &crate::plain(features), &[mirror_matrix(&pl)], warning);
}
let m = mirror_matrix(&pl);
let mut output = 0;
@@ -4482,13 +4502,13 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
FeatureKind::Fillet { edges, radius, body, .. } | FeatureKind::Chamfer { edges, distance: radius, body, .. } => {
let r = val(vals, radius, Kind::Length)?;
let ti = blend_target(st, body, edges)?;
let ti = blend_target(st, body, &crate::plain(edges))?;
let Some(mb) = st.bodies.get(ti) else { return Err(DocError::Invalid("body".into())) };
let edges = if f.edge_names.iter().any(|n| !n.is_empty()) {
// By name first; a name that is gone falls back to the point, with a warning.
let (mut out, mut notes) = (Vec::new(), Vec::new());
for (i, p) in edges.iter().enumerate() {
match f.edge_names.get(i).filter(|n| !n.is_empty()).map(|n| (n, crate::naming::locate_edge(mb, n, *p))) {
match f.edge_names.get(i).filter(|n| !n.is_empty()).map(|n| (n, crate::naming::locate_edge(mb, n, p.0))) {
Some((n, Ok(hit))) => {
if let Some(note) = hit.note(n) {
notes.push(note);
@@ -4503,9 +4523,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
// The name is gone. A pick on the axis of a round must not fall
// through to the tangency seam (#775). A sharp edge that moved
// still resolves from the point, which an edit relies on.
if pick_lies_on_a_round_axis(&mb.body, *p) {
if pick_lies_on_a_round_axis(&mb.body, p.0) {
notes.push(skipped);
} else if let Ok(q) = resolve_edges(&mb.body, &[*p], &refs, &mut w) {
} else if let Ok(q) = resolve_edges(&mb.body, &[p.0], &refs, &mut w) {
out.extend(q);
notes.push(match f.edge_names.get(i) {
Some(n) if !n.is_empty() => format!("edge `{n}` no longer exists; using the edge at the picked point"),
@@ -4526,7 +4546,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
out
} else {
resolve_edges(&mb.body, edges, &f.edge_refs, warning)?
resolve_edges(&mb.body, &crate::plain(edges), &f.edge_refs, warning)?
};
st.blend_edges.insert(f.id, edges.clone());
let input = mb.traced();
@@ -4540,7 +4560,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
for (t, e) in mid {
pts.push((*t, val(vals, e, Kind::Length)?));
}
one_by_one(input, &edges, |b, p| kernel::fillet_variable_points(b, p, *start, &pts, *smooth))?
one_by_one(input, &edges, |b, p| kernel::fillet_variable_points(b, p, start.0, &pts, *smooth))?
}
FeatureKind::Fillet { style: crate::FilletStyle::Spline { start, knots, radii }, .. } => {
let mut values = vec![r];
@@ -4548,7 +4568,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
values.push(val(vals, e, Kind::Length)?);
}
let law = crate::FilletStyle::spline(knots, values);
one_by_one(input, &edges, |b, p| kernel::fillet_variable_spline(b, p, *start, &law))?
one_by_one(input, &edges, |b, p| kernel::fillet_variable_spline(b, p, start.0, &law))?
}
FeatureKind::Fillet { fit: crate::FilletFit::Taper, .. } => {
let (b, tapers) = kernel::fillet_tapered(&mb.body, &edges, r)?;
@@ -4595,9 +4615,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
let (base, radius, height) = (point(vals, base)?, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?);
let b = kernel::cylinder(base, *axis, radius, height)?;
let b = kernel::cylinder(base, axis.0, radius, height)?;
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_cylinder_certificate(f.id)? } else { b };
let prism = more::PrimitivePrism::Cylinder { base, axis: *axis, radius, height };
let prism = more::PrimitivePrism::Cylinder { base, axis: axis.0, radius, height };
let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation, false, warning)?;
let tools = vec![crate::lineage::cylinder_tool(b)];
if !handled.is_empty() {
@@ -4625,9 +4645,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
return Err(DocError::Unknown(format!("body `{target}`")));
}
let op = if *operation == Operation::NewBody { Operation::Join } else { *operation };
apply_traced(st, f, tb, op, std::slice::from_ref(target))?;
apply_traced(st, f, tb, op, std::slice::from_ref(&target.0))?;
if !keep_tools {
st.bodies.retain(|b| !tools.contains(&b.name));
st.bodies.retain(|b| !tools.iter().any(|t| *t == b.name));
}
Ok(())
}
@@ -4661,20 +4681,20 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
FeatureKind::OffsetFace { faces, distance, body } => {
let d = val(vals, distance, Kind::Length)?;
let i = body_at(st, body, faces)?;
let i = body_at(st, body, &crate::plain(faces))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let nb = Traced::step(kernel::offset_faces(&mb.body, faces, d)?, &[&mb.traced()]);
let nb = Traced::step(kernel::offset_faces(&mb.body, &crate::plain(faces), d)?, &[&mb.traced()]);
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
}
Ok(())
}
FeatureKind::DeleteFace { faces, body, heal } => {
let i = body_at(st, body, faces)?;
let i = body_at(st, body, &crate::plain(faces))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let mut picked: Vec<usize> = Vec::new();
for p in faces {
let fi = crate::appearance::face_index_at(&mb, *p)
let fi = crate::appearance::face_index_at(&mb, p.0)
.ok_or_else(|| DocError::Invalid(format!("Delete Face: no face of {} at {p:?}", mb.name)))?;
if !picked.contains(&fi) {
picked.push(fi);
@@ -4691,7 +4711,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
FeatureKind::BoundingSolid { bodies, margin } => {
let m = val(vals, margin, Kind::Length)?;
let mut bb = solidcraft_geom::Aabb3::EMPTY;
for b in st.bodies.iter().filter(|b| bodies.is_empty() || bodies.contains(&b.name)) {
for b in st.bodies.iter().filter(|b| bodies.is_empty() || bodies.iter().any(|t| *t == b.name)) {
bb = bb.union(&b.mesh().bounds());
}
if bb.is_empty() {
@@ -4710,7 +4730,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
Some(a) => val(vals, a, Kind::Angle)?,
None => 0.0,
};
let axis = rotate_axis.unwrap_or(Vec3::Z);
let axis = rotate_axis.map(Vec3::from).unwrap_or(Vec3::Z);
// With `copy` set, the source body is kept in place and the transform lands on a new
// body pushed beside it instead of replacing it.
let mut output = 0;
@@ -5212,7 +5232,7 @@ mod chain_tests {
.unwrap();
doc.add_feature(
FeatureKind::Fillet {
edges: vec![Vec3::new(20.0, 0.0, 20.0)],
edges: vec![crate::Position(Vec3::new(20.0, 0.0, 20.0))],
radius: "4".into(),
body: None,
style: crate::FilletStyle::Constant,
@@ -5260,3 +5280,17 @@ mod body_name_tests {
assert!(began.elapsed() < std::time::Duration::from_secs(5), "{:?}", began.elapsed());
}
}
/// The plane a straight sketch line sweeps along its sketch's normal: through the line, its x
/// along the line and its y along the normal (a Split Body tool, #1552).
pub fn sketch_line_plane(st: &ModelState, sketch: u64, line: &str) -> Result<Plane> {
let ss = st.sketch(sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
let ci = ss.sketch.curve_index(line).ok_or_else(|| DocError::Unknown(format!("curve `{line}`")))?;
let (a, b) = match ss.sketch.segs(ci).as_slice() {
[solidcraft_geom::Seg2::Line { a, b }] => (*a, *b),
_ => return Err(DocError::Invalid(format!("`{line}` is not a straight line: split by a sketch line"))),
};
let (wa, wb) = (ss.plane.to_world(a), ss.plane.to_world(b));
let x = (wb - wa).normalized().ok_or_else(|| DocError::Invalid(format!("`{line}` has no length")))?;
Ok(Plane { origin: wa, x, y: ss.plane.normal() })
}
+68 -53
View File
@@ -10,7 +10,7 @@ use solidcraft_geom::{Loop2, Seg2};
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
match &f.kind {
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
let regions = select_profiles(ss, profiles)?;
let d = val(vals, depth, Kind::Length)?;
if !(d > 0.0) {
@@ -35,7 +35,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
crate::apply_vector(&sketch_transform, ss.plane.y),
)
.ok_or_else(|| DocError::Invalid("invalid cylinder sketch frame".into()))?;
let mut at = crate::apply_point(&crate::mat_mul(&inverse, &author_frame), face.at);
let mut at = crate::apply_point(&crate::mat_mul(&inverse, &author_frame), face.at.0);
let name = f
.face_names
.first()
@@ -67,10 +67,10 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
if st.bodies.is_empty() {
return Err(DocError::Invalid("there is no body to emboss".into()));
}
apply_op(st, f, tools, if *deboss { Operation::Cut } else { Operation::Join }, targets)
apply_op(st, f, tools, if *deboss { Operation::Cut } else { Operation::Join }, &crate::plain(targets))
}
FeatureKind::Rib { sketch, curves, thickness, depth, flip, web } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
let t = val(vals, thickness, Kind::Length)?;
if !(t > 0.0) {
return Err(DocError::Invalid("the rib thickness must be positive".into()));
@@ -95,17 +95,18 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
FeatureKind::ReplaceFace { faces, target, body } => {
let tp = doc.resolve_plane_in(vals, target, 0, Some(st))?;
let tn = tp.normal();
let i = body_at(st, body, faces)?;
let i = body_at(st, body, &crate::plain(faces))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let mut b = mb.body.clone();
for p in faces {
let n = planar_normal_at(&b, *p).ok_or_else(|| DocError::Invalid("not supported yet: replacing a face that is not planar".into()))?;
let n =
planar_normal_at(&b, p.0).ok_or_else(|| DocError::Invalid("not supported yet: replacing a face that is not planar".into()))?;
if n.dot(tn).abs() < 1.0 - 1e-9 {
return Err(DocError::Invalid("not supported yet: a target that is not parallel to the face".into()));
}
let d = (tp.origin - *p).dot(n);
let d = (tp.origin - p.0).dot(n);
if d.abs() > 1e-9 {
b = kernel::offset_faces(&b, &[*p], d)?;
b = kernel::offset_faces(&b, &[p.0], d)?;
}
}
if let Some(slot) = st.bodies.get_mut(i) {
@@ -115,17 +116,17 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip } => {
let mut m = translation(Vec3::ZERO);
if let (Some(a), Some(b)) = (from_normal.and_then(Vec3::normalized), to_normal.and_then(Vec3::normalized)) {
if let (Some(a), Some(b)) = (from_normal.and_then(|n| n.normalized()), to_normal.and_then(|n| n.normalized())) {
// Turn `a` onto −b (faces meet) or b (flipped), about the `from` point.
let want = if *flip { b } else { b * -1.0 };
let axis = a.cross(want);
let ang = axis.len().atan2(a.dot(want));
let axis = axis.normalized().unwrap_or_else(|| a.any_perp());
if ang.abs() > 1e-12 {
m = rotation(*from, axis, ang);
m = rotation(from.0, axis, ang);
}
}
let d = *to - *from;
let d = to.0 - from.0;
m[3][0] += d.x;
m[3][1] += d.y;
m[3][2] += d.z;
@@ -150,7 +151,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
return Err(DocError::Unknown(format!("body `{n}`")));
}
}
st.bodies.retain(|b| !bodies.contains(&b.name));
st.bodies.retain(|b| !bodies.iter().any(|t| *t == b.name));
Ok(())
}
_ => Err(DocError::Invalid(format!("{} is not evaluated here", f.name))),
@@ -331,7 +332,7 @@ fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> {
}
/// The sheet a feature works on: by body name, else the one with an edge nearest the pick.
fn sheet_for(st: &ModelState, body: &Option<String>, pick: Option<Vec3>) -> Result<usize> {
fn sheet_for(st: &ModelState, body: &Option<crate::BodyName>, pick: Option<Vec3>) -> Result<usize> {
if let Some(n) = body {
return st.sheets.iter().position(|s| &s.body == n).ok_or_else(|| DocError::Invalid(format!("`{n}` is not a sheet metal body")));
}
@@ -391,7 +392,7 @@ pub(super) fn sheet_eval(
use crate::sheet;
match &f.kind {
FeatureKind::SheetBase { sketch, profiles, rule, flip } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let r = doc.sheet_rule(rule.as_deref());
let rv = doc.rule_values(vals, &r)?;
@@ -425,7 +426,7 @@ pub(super) fn sheet_eval(
Ok(())
}
FeatureKind::SheetContour { sketch, curves, distance, rule, flip, reverse } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
for id in curves {
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?;
if !ss.sketch.segs(ci).iter().all(|s| matches!(s, Seg2::Line { .. })) {
@@ -449,8 +450,8 @@ pub(super) fn sheet_eval(
st.sheets.push(sh);
Ok(())
}
FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body } => {
let i = sheet_for(st, body, edges.first().map(|e| e.point))?;
FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body, corner_relief_shape, corner_relief } => {
let i = sheet_for(st, body, edges.first().map(|e| e.point.0))?;
let (h, a) = (val(vals, height, Kind::Length)?, val(vals, angle, Kind::Angle)?);
// Each edge's own height and angle, else the feature's.
let mut own = Vec::with_capacity(edges.len());
@@ -472,18 +473,32 @@ pub(super) fn sheet_eval(
doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius
}
};
// The corner relief these flanges ask for in place of the rule's.
let relief = sheet::ReliefOverride {
shape: match corner_relief_shape {
Some(name) => Some(
sheet::ReliefShape::parse(name)
.ok_or_else(|| DocError::Invalid(format!("unknown corner relief shape `{name}` (notch, round, rectangular or tear)")))?,
),
None => None,
},
size: match corner_relief {
Some(e) => Some(val(vals, e, Kind::Length)?),
None => None,
},
};
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
if sh.flat {
return Err(DocError::Invalid("refold the sheet before adding flanges".into()));
}
let points: Vec<Vec3> = edges.iter().map(|e| e.point).collect();
let points: Vec<Vec3> = edges.iter().map(|e| e.point.0).collect();
let each: Vec<sheet::EdgeFlange> = sheet_picks(sh, f, &points, warning)
.into_iter()
.zip(own)
.map(|(pick, (height, angle))| sheet::EdgeFlange { pick, height, angle })
.collect();
let n0 = sh.flanges.len();
let (_, notes) = sh.add_flanges_each(&each, rr, position, *flip)?;
let (_, notes) = sh.add_flanges_each(&each, rr, position, *flip, relief)?;
if !notes.is_empty() {
let notes = notes.join("; ");
*warning = Some(match warning.take() {
@@ -521,7 +536,7 @@ pub(super) fn sheet_eval(
Ok(())
}
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
let i = sheet_for(st, body, edges.first().copied())?;
let i = sheet_for(st, body, edges.first().copied().map(Vec3::from))?;
let l = val(vals, length, Kind::Length)?;
let g = match gap {
Some(e) => val(vals, e, Kind::Length)?,
@@ -531,7 +546,7 @@ pub(super) fn sheet_eval(
}
};
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
let picks = sheet_picks(sh, f, edges, warning);
let picks = sheet_picks(sh, f, &crate::plain(edges), warning);
let n0 = sh.flanges.len();
for e in &picks {
sh.add_hem(*e, l, g, *flip)?;
@@ -543,14 +558,14 @@ pub(super) fn sheet_eval(
// The sketch line, where it is now.
let (a, b) = match (sketch, curve) {
(Some(sk), Some(c)) => {
let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk}")))?;
let ss = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk}")))?;
let ci = ss.sketch.curve_index(c).ok_or_else(|| DocError::Unknown(format!("curve `{c}`")))?;
match ss.sketch.segs(ci).as_slice() {
[Seg2::Line { a, b }] => (ss.plane.to_world(*a), ss.plane.to_world(*b)),
_ => return Err(DocError::Invalid("the fold line must be a sketch line".into())),
}
}
_ => (*a, *b),
_ => (a.0, b.0),
};
let i = sheet_for(st, body, Some((a + b) * 0.5))?;
let ang = val(vals, angle, Kind::Angle)?;
@@ -564,7 +579,7 @@ pub(super) fn sheet_eval(
// A fixed point that touches no material at all (e.g. far above the sheet) still picks
// the side it lies on, so a stored fold keeps recomputing; it says so (#371).
if let Some(p) = fixed
&& !on_sheet_material(st, i, *p)
&& !on_sheet_material(st, i, p.0)
{
*warning = Some(format!(
"{} at ({:.3}, {:.3}, {:.3}); the side of the fold line it lies on stays",
@@ -579,7 +594,7 @@ pub(super) fn sheet_eval(
return Err(DocError::Invalid("refold the sheet before folding it".into()));
}
let n0 = sh.flanges.len();
sh.add_fold(a, b, ang, rr, position, *flip, *fixed, warning)?;
sh.add_fold(a, b, ang, rr, position, *flip, fixed.map(Vec3::from), warning)?;
tag_flanges(sh, n0, f.id);
rebuild_sheet(st, i)
}
@@ -604,7 +619,7 @@ pub(super) fn sheet_eval(
let mut picks = Vec::with_capacity(bends.len());
for (i, p) in bends.iter().enumerate() {
let name = f.edge_names.get(i).filter(|n| !n.is_empty());
picks.push(match name.map(|n| (n, crate::naming::locate_edge(&mb, n, *p))) {
picks.push(match name.map(|n| (n, crate::naming::locate_edge(&mb, n, p.0))) {
Some((n, Ok(hit))) => {
if let Some(note) = hit.note(n) {
append_warning(warning, note);
@@ -613,12 +628,12 @@ pub(super) fn sheet_eval(
}
Some((n, Err(_))) => {
append_warning(warning, format!("edge `{n}` no longer exists; using the edge at the picked point"));
*p
p.0
}
None => *p,
None => p.0,
});
}
let (mut sh, notes) = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
let (mut sh, notes) = sheet::convert_body(&mb, &r.name, &rv, face.0, &picks)?;
for note in notes {
append_warning(warning, note);
}
@@ -627,10 +642,10 @@ pub(super) fn sheet_eval(
}
FeatureKind::SheetConvert { body, face, rule, .. } => {
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick a planar face of the plate".into()))?;
let n = planar_normal_at(&mb.body, face.0).ok_or_else(|| DocError::Invalid("pick a planar face of the plate".into()))?;
// Thickness: the body's extent along the face normal.
let extent = |mesh: &solidcraft_geom::Mesh| {
mesh.positions.iter().map(|p| (*p - *face).dot(n)).fold((f64::MAX, f64::MIN), |(lo, hi), d| (lo.min(d), hi.max(d)))
mesh.positions.iter().map(|p| (*p - face.0).dot(n)).fold((f64::MAX, f64::MIN), |(lo, hi), d| (lo.min(d), hi.max(d)))
};
// The tolerance (the mesh's chords, and how far off the face's plane a point of it may
// be) is at most a tenth of the thickness, not only a thousandth of the size. On a long
@@ -648,13 +663,13 @@ pub(super) fn sheet_eval(
// The face outline: boundary edges of the triangles on that face's plane, where it runs
// along a round wall an arc of the wall's circle (not chords: the plate check below
// compares its area with the exact volume).
let plane = Plane::from_normal(*face + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?;
let plane = Plane::from_normal(face.0 + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?;
// Recesses in one face (an engraved label) become pockets; the outline is the other face's.
let recesses = plate_recesses(&mb.body, &mesh, &plane, *face, n, (lo, hi), tol)?;
let recesses = plate_recesses(&mb.body, &mesh, &plane, face.0, n, (lo, hi), tol)?;
let mut walls = std::collections::HashMap::new();
let mut wall = |f: u32| *walls.entry(f).or_insert_with(|| sheet::round_wall(&mb.body, &plane, n, f));
let mut loops: Vec<Loop2> =
face_outline(&mesh, &plane, *face, n, recesses.level, tol).iter().map(|l| sheet::outline_loop(l, &mut wall, tol * 1e-2)).collect();
face_outline(&mesh, &plane, face.0, n, recesses.level, tol).iter().map(|l| sheet::outline_loop(l, &mut wall, tol * 1e-2)).collect();
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
let mut it = loops.into_iter();
let outer = it.next().ok_or_else(|| DocError::Invalid("no outline on that face".into()))?.ccw();
@@ -770,7 +785,7 @@ pub(super) fn sheet_cut(
if extent.taper.is_some() || !any_eligible_sheet(f, st, targets) {
return Ok(Vec::new());
}
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
let Some(ss) = st.sketch(sketch.0).cloned() else { return Ok(Vec::new()) };
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
// The outlines of the sketch's text: the sheet remembers where its letters went.
let text: Vec<Seg2> = ss
@@ -1465,7 +1480,7 @@ fn plate_recesses(body: &Body, mesh: &Mesh, plane: &Plane, at: Vec3, n: Vec3, (l
}
/// The body a placed plastic feature joins: by name, else the one under the point.
fn body_under(st: &ModelState, body: &Option<String>, p: Vec3) -> Result<String> {
fn body_under(st: &ModelState, body: &Option<crate::BodyName>, p: Vec3) -> Result<String> {
let i = body_at(st, body, &[p])?;
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("there is no body".into()))?;
let tolerance = (b.body.size() * 1e-5).max(1e-4);
@@ -1527,7 +1542,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
rib_offset,
body,
} => {
let target = body_under(st, body, *position)?;
let target = body_under(st, body, position.0)?;
let (r0, h) = (len(diameter)? / 2.0, len(height)?);
if !(r0 > 0.0 && h > 0.0) {
return Err(DocError::Invalid("the boss diameter and height must be positive".into()));
@@ -1545,9 +1560,9 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
return Err(DocError::Invalid("the draft closes the boss before its top".into()));
}
let delta = (h * 0.05).clamp(1e-3, 0.5);
let (x, _, z) = local_frame(*direction, None)?;
let (x, _, z) = local_frame(direction.0, None)?;
// The post: a (tapered) cylinder from a little inside the face.
let base = Plane::new(*position - z * delta, x, z.cross(x)).ok_or_else(|| DocError::Invalid("boss frame".into()))?;
let base = Plane::new(position.0 - z * delta, x, z.cross(x)).ok_or_else(|| DocError::Invalid("boss frame".into()))?;
let r_in = r0 + delta * dr.tan();
let disc = Region2 { outer: Loop2::circle(Vec2::ZERO, r_in), holes: Vec::new() };
let post = if dr.abs() > 1e-12 {
@@ -1580,7 +1595,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
for k in 0..n as usize {
let a = std::f64::consts::TAU * k as f64 / n;
let (u, v) = (x * a.cos() + z.cross(x) * a.sin(), z.cross(x) * a.cos() - x * a.sin());
let rp = Plane::new(*position, u, v).ok_or_else(|| DocError::Invalid("rib frame".into()))?;
let rp = Plane::new(position.0, u, v).ok_or_else(|| DocError::Invalid("rib frame".into()))?;
let rect = Region2 {
outer: Loop2::polygon(&[
Vec2::new(r0 * 0.5, -t / 2.0),
@@ -1599,7 +1614,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
let i = st.bodies.iter().position(|b| b.name == target).ok_or_else(|| DocError::Invalid("boss body".into()))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("boss body".into())) };
let mut warn = None;
let edges = resolve_edges(&mb.body, &[*position + x * r0], &[], &mut warn)?;
let edges = resolve_edges(&mb.body, &[position.0 + x * r0], &[], &mut warn)?;
let nb = mb.traced().then(kernel::fillet(&mb.body, &edges, rf)?);
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
@@ -1614,7 +1629,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
if !(rh > 0.0 && rh < r_top && depth > 0.0) {
return Err(DocError::Invalid("the boss hole must fit inside the boss".into()));
}
let hp = Plane::new(*position, x, z.cross(x)).ok_or_else(|| DocError::Invalid("hole frame".into()))?;
let hp = Plane::new(position.0, x, z.cross(x)).ok_or_else(|| DocError::Invalid("hole frame".into()))?;
let tool = kernel::extrude(&hp, &[Region2 { outer: Loop2::circle(Vec2::ZERO, rh), holes: Vec::new() }], h - depth, h + delta)?;
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "hole")).collect();
apply_traced(st, f, tool, Operation::Cut, std::slice::from_ref(&target))?;
@@ -1622,9 +1637,9 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
Ok(())
}
FeatureKind::Lip { face, width, height, groove, gap, outside, body } => {
let target = body_under(st, body, *face)?;
let target = body_under(st, body, face.0)?;
let mb = st.body(&target).cloned().ok_or_else(|| DocError::Invalid("body".into()))?;
let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick the planar rim face".into()))?;
let n = planar_normal_at(&mb.body, face.0).ok_or_else(|| DocError::Invalid("pick the planar rim face".into()))?;
let (w, h) = (len(width)?, len(height)?);
let g = match gap {
Some(e) => len(e)?,
@@ -1634,10 +1649,10 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
if !(w > 0.0 && h > 0.0 && g >= 0.0) {
return Err(DocError::Invalid("the lip width and height must be positive".into()));
}
let plane = Plane::from_normal(*face, n).ok_or_else(|| DocError::Invalid("rim plane".into()))?;
let plane = Plane::from_normal(face.0, n).ok_or_else(|| DocError::Invalid("rim plane".into()))?;
let tol = (mb.body.size() * 1e-3).max(1e-3);
let mesh = mb.body.tessellate(tol)?;
let mut loops = face_loops(&mesh, &plane, *face, n, 0.0, tol);
let mut loops = face_loops(&mesh, &plane, face.0, n, 0.0, tol);
loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs()));
let ccw = |l: &[Vec2]| if poly_area(l) < 0.0 { l.iter().rev().copied().collect::<Vec<_>>() } else { l.to_vec() };
// The inner edge is an opening (the cavity under a rim): not the foot of a boss or a
@@ -1671,7 +1686,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
// A lip: the band raised from the rim's own faces (its walls continue the walls
// below, which a join can't do: they would coincide).
if !*groove
&& let Ok(b) = kernel::raise_band(&mb.body, *face, w, h, *outside)
&& let Ok(b) = kernel::raise_band(&mb.body, face.0, w, h, *outside)
&& let Some(slot) = st.bodies.iter_mut().find(|x| x.name == target)
{
*slot = ModelBody::traced_new(target.clone(), Traced::step(b, &[&mb.traced()]), slot.feature);
@@ -1701,17 +1716,17 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
}
}
FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, body } => {
let target = body_under(st, body, *position)?;
let target = body_under(st, body, position.0)?;
let (l, t, w, cd, cl) = (len(length)?, len(thickness)?, len(width)?, len(catch_depth)?, len(catch_length)?);
if !(l > 0.0 && t > 0.0 && w > 0.0 && cd > 0.0 && cl > 0.0 && cl < l) {
return Err(DocError::Invalid("the snap fit's sizes must be positive and the catch shorter than the arm".into()));
}
let (x, y, z) = local_frame(*direction, Some(*hook))?;
let (x, y, z) = local_frame(direction.0, Some(hook.0))?;
let delta = (l * 0.05).clamp(1e-3, 0.5);
// Side profile (x = toward the catch, y = up the arm), extruded across the width.
let pts =
[Vec2::new(-t, -delta), Vec2::new(0.0, -delta), Vec2::new(0.0, l - cl), Vec2::new(cd, l - cl), Vec2::new(0.0, l), Vec2::new(-t, l)];
let o = *position - y * (w / 2.0);
let o = position.0 - y * (w / 2.0);
let pl = Plane::new(o, x, z).ok_or_else(|| DocError::Invalid("snap fit frame".into()))?;
let mut lp = Loop2::polygon(&pts);
if lp.signed_area() < 0.0 {
@@ -1724,7 +1739,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target))
}
FeatureKind::Rest { position, direction, along, width, length, height, draft, thickness, body } => {
let target = body_under(st, body, *position)?;
let target = body_under(st, body, position.0)?;
let (w, h) = (len(width)?, len(height)?);
let l = length.as_deref().map(len).transpose()?;
let dr = match draft {
@@ -1747,7 +1762,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
return Err(DocError::Invalid("the draft closes the rest before its top".into()));
}
let delta = (h * 0.05).clamp(1e-3, 0.5);
let (x, y, z) = local_frame(*direction, *along)?;
let (x, y, z) = local_frame(direction.0, along.map(Vec3::from))?;
// Sized at the face: the tool starts a little inside, grown by the draft over that.
let e = delta * dr.tan();
let shape = |grow: f64| match l {
@@ -1764,7 +1779,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
None => Vec::new(),
};
let region = Region2 { outer: shape(e), holes };
let base = Plane::new(*position - z * delta, x, y).ok_or_else(|| DocError::Invalid("rest frame".into()))?;
let base = Plane::new(position.0 - z * delta, x, y).ok_or_else(|| DocError::Invalid("rest frame".into()))?;
let tool = if dr.abs() > 1e-12 {
vec![kernel::extrude_tapered(&base, &region, h + delta, 1.0, -dr)?]
} else {
+184 -30
View File
@@ -8,19 +8,25 @@ use solidcraft_geom::Seg2;
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
match &f.kind {
FeatureKind::Patch { sketch, profiles, body, edges, crown } => {
FeatureKind::Patch { sketch, profiles, body, edges, crown, continuity, points } => {
let mut made = Vec::new();
if let Some(sk) = sketch {
let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
let ss = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
for r in &regions {
made.push(kernel::patch_region(&ss.plane, r)?);
}
}
if !edges.is_empty() {
let i = body_at(st, body, edges)?;
let i = body_at(st, body, &crate::plain(edges))?;
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("body".into()))?;
made.push(kernel::patch_edges_crowned(&b.body, edges, *crown)?);
let spec = kernel::PatchSpec {
continuity: continuity.all,
edges: continuity.edges.iter().map(|e| (e.edge.0, e.g)).collect(),
points: crate::plain(points),
crown: *crown,
};
made.push(kernel::patch_edges_with(&b.body, &crate::plain(edges), &spec)?);
}
if made.is_empty() {
return Err(DocError::Invalid("a patch needs a sketch region or a loop of edges".into()));
@@ -59,7 +65,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
if tools.is_empty() {
return Err(DocError::Invalid("pick the surfaces to thicken".into()));
}
apply_op(st, f, tools, *operation, targets)
apply_op(st, f, tools, *operation, &crate::plain(targets))
}
FeatureKind::SurfaceTrim { body, plane, tool, keep } => {
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
@@ -67,11 +73,11 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
let trimmed = match tool {
Some(t) => {
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
let face = crate::appearance::face_index_at(tb, t.point)
let face = crate::appearance::face_index_at(tb, t.point.0)
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?;
kernel::trim(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face }, *keep)?
kernel::trim(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face }, keep.0)?
}
None => kernel::trim(&mb.body, &kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?), *keep)?,
None => kernel::trim(&mb.body, &kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?), keep.0)?,
};
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, trimmed, mb.feature);
@@ -88,18 +94,20 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
};
let at = |limit| kernel::ExtendOptions { mode, limit, neighbours: *neighbours };
let out = match up_to {
None => kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::Distance(val(vals, distance, Kind::Length)?)))?,
Some(crate::ExtendTo::Bounds) => kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::Bounds))?,
None => {
kernel::extend_surface(&mb.body, &crate::plain(edges), &at(kernel::ExtendLimit::Distance(val(vals, distance, Kind::Length)?)))?
}
Some(crate::ExtendTo::Bounds) => kernel::extend_surface(&mb.body, &crate::plain(edges), &at(kernel::ExtendLimit::Bounds))?,
Some(crate::ExtendTo::Plane(plane)) => {
let tool = kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?);
kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::UpTo(&tool)))?
kernel::extend_surface(&mb.body, &crate::plain(edges), &at(kernel::ExtendLimit::UpTo(&tool)))?
}
Some(crate::ExtendTo::Face(t)) => {
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
let face = crate::appearance::face_index_at(tb, t.point)
let face = crate::appearance::face_index_at(tb, t.point.0)
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the target point", t.body)))?;
let tool = kernel::SplitTool::Face { body: &tb.body, face };
kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::UpTo(&tool)))?
kernel::extend_surface(&mb.body, &crate::plain(edges), &at(kernel::ExtendLimit::UpTo(&tool)))?
}
};
if let Some(slot) = st.bodies.get_mut(i) {
@@ -113,7 +121,17 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
if from.edges.is_empty() || to.edges.is_empty() {
return Err(DocError::Invalid("a blend needs a chain of edges at each end".into()));
}
let (made, gap) = kernel::blend_between(&a, &from.edges, &b, &to.edges, *continuity, *bulge, *align, BLEND_STATIONS, *stitch)?;
let (made, gap) = kernel::blend_between(
&a,
&crate::plain(&from.edges),
&b,
&crate::plain(&to.edges),
*continuity,
*bulge,
align.map(|[p, q]| [p.0, q.0]),
BLEND_STATIONS,
*stitch,
)?;
if gap > 1e-9 {
*warning = Some(format!("the blend's boundary lies up to {gap:.4} mm from the edges it was built on"));
}
@@ -121,16 +139,88 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
// A stitched blend takes the place of both bodies it joins; otherwise it is a new
// surface body of its own.
if *stitch {
let gone = [from.body.clone(), to.body.clone()];
let gone = [from.body.0.clone(), to.body.0.clone()];
st.bodies.retain(|x| !gone.contains(&x.name));
}
st.bodies.push(ModelBody::new(name, made, f.id));
Ok(())
}
FeatureKind::SurfaceBoundary { sketch, curves, orders, crown, samples } => {
let ss = sketch_in_feature_frame(
doc,
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
let boundaries = boundary_frames(&ss, curves, orders, *samples)?;
let body = kernel::boundary_patch(&boundaries, *crown, *samples)?;
let name = new_name(st, f, 0);
st.bodies.push(ModelBody::new(name, body, f.id));
Ok(())
}
FeatureKind::SurfaceMatch { body, faces, target, edges, order } => {
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let tb = st.body(target).ok_or_else(|| DocError::Unknown(format!("body `{target}`")))?.body.clone();
if edges.is_empty() {
return Err(DocError::Invalid("a match needs a chain of the target's edges".into()));
}
// Picked by a point on the face; without picks, the body's first face.
let picked: Vec<solidcraft_geom::Vec3> = if faces.is_empty() {
let faces = mb.body.faces(mb.body.size() * 1e-2)?;
vec![faces.first().ok_or_else(|| DocError::Invalid(format!("`{body}` has no face to match")))?.centroid]
} else {
crate::plain(faces)
};
let made = kernel::match_surface(&mb.body, &picked, &tb, &crate::plain(edges), *order)?;
// A match rebuilds the face in place: the body keeps its name, and whatever meets it
// downstream still finds it.
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, made, mb.feature);
}
Ok(())
}
FeatureKind::SurfaceFair { body, faces, strength, iterations, keep_edges, tolerance } => {
let tol = val(vals, tolerance, Kind::Length)?;
let opts = kernel::FairOptions::new(*strength, *iterations as usize, *keep_edges, tol)?;
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
// Picked by a point on the face; without picks, every face of the body.
let mut picked: Vec<usize> = Vec::new();
if faces.is_empty() {
picked.extend(0..mb.body.face_count());
} else {
for p in faces {
let fi =
crate::appearance::face_index_at(&mb, p.0).ok_or_else(|| DocError::Invalid(format!("Fair: no face of {body} at {p:?}")))?;
if !picked.contains(&fi) {
picked.push(fi);
}
}
}
if picked.is_empty() {
return Err(DocError::Invalid("the body has no faces to fair".into()));
}
// The face keeps every edge it had: only the surface between them is faired. The
// energy before and after goes into the feature's warning, so the reply can say how
// much the face moved.
let (faired, report) = kernel::fair_faces(&mb.body, &picked, &opts)?;
*warning = Some(format!(
"faired {} face(s): energy {:.6} → {:.6} in {} step(s), control points moved up to {:.4} mm",
picked.len(),
report.energy_before,
report.energy_after,
report.steps,
report.movement
));
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, faired, mb.feature);
}
Ok(())
}
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
let ss = sketch_in_feature_frame(
doc,
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
if extent.through_all || extent.taper.is_some() || extent.to.is_some() || extent.from.is_some() {
@@ -147,7 +237,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
FeatureKind::SurfaceRevolve { sketch, profiles, curves, axis, angle, angle2, symmetric } => {
let ss = sketch_in_feature_frame(
doc,
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
let (o, d) = revolve_axis(&ss, axis)?;
@@ -168,7 +258,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
FeatureKind::Unstitch { body, faces } => {
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let (pieces, rest) = kernel::unstitch(&mb.body, faces)?;
let (pieces, rest) = kernel::unstitch(&mb.body, &crate::plain(faces))?;
let source = mb.traced();
// The faces left keep the body's name; each face taken out is a new surface body.
match rest {
@@ -190,7 +280,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
FeatureKind::ReverseNormal { body, faces } => {
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let turned = mb.traced().then(kernel::reverse_faces(&mb.body, faces)?);
let turned = mb.traced().then(kernel::reverse_faces(&mb.body, &crate::plain(faces))?);
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::traced_new(mb.name, turned, mb.feature);
}
@@ -198,9 +288,9 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale } => {
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale)?;
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
// As Sweep: a 3D path sketch is followed in the feature's frame, the curves with it.
let profile;
let ss = if !ps.sketch.wires.is_empty() {
@@ -229,7 +319,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
for sec in sections {
let ss = sketch_in_feature_frame(
doc,
st.sketch(sec.sketch)
st.sketch(sec.sketch.0)
.ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?,
f.component,
)?;
@@ -259,9 +349,9 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
Some(t) => val(vals, t, Kind::Length)?,
None => 0.01,
};
let i = body_at(st, body, faces)?;
let i = body_at(st, body, &crate::plain(faces))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let picks = if *chain { smooth_chain(&mb, faces) } else { faces.clone() };
let picks = if *chain { smooth_chain(&mb, &crate::plain(faces)) } else { crate::plain(faces) };
let mut picked: Vec<usize> = Vec::new();
for p in &picks {
let fi =
@@ -282,10 +372,10 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
RuledMode::Tangent => kernel::RuledMode::Tangent,
RuledMode::Angle => {
let a = angle.as_ref().ok_or_else(|| DocError::Invalid("a ruled surface at an angle needs `angle`".into()))?;
kernel::RuledMode::Angle { angle: val(vals, a, Kind::Angle)?, reference: *direction }
kernel::RuledMode::Angle { angle: val(vals, a, Kind::Angle)?, reference: direction.map(Vec3::from) }
}
RuledMode::Direction => kernel::RuledMode::Direction(
direction.ok_or_else(|| DocError::Invalid("a ruled surface along a direction needs `direction`".into()))?,
direction.map(Vec3::from).ok_or_else(|| DocError::Invalid("a ruled surface along a direction needs `direction`".into()))?,
),
};
let corners = match corners {
@@ -293,8 +383,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
RuledCorners::Round => kernel::RuledCorners::Round,
};
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let r = kernel::Ruled { distance: d, mode, flip: *flip, corners, face: *face };
let made = Traced::step(kernel::ruled_surface(&mb.body, edges, &r)?, &[&mb.traced()]);
let r = kernel::Ruled { distance: d, mode, flip: *flip, corners, face: face.map(Vec3::from) };
let made = Traced::step(kernel::ruled_surface(&mb.body, &crate::plain(edges), &r)?, &[&mb.traced()]);
let name = new_name(st, f, 0);
st.bodies.push(ModelBody::traced_new(name, made, f.id));
Ok(())
@@ -302,11 +392,11 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
FeatureKind::SurfaceUntrim { face, holes, outer, in_place } => {
let i = st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", face.body)))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let fi = crate::appearance::face_index_at(&mb, face.point)
let fi = crate::appearance::face_index_at(&mb, face.point.0)
.ok_or_else(|| DocError::Invalid(format!("Untrim: no face of {} at {:?}", mb.name, face.point)))?;
let which = match holes {
None => kernel::UntrimHoles::All,
Some(points) => kernel::UntrimHoles::At(points),
Some(points) => kernel::UntrimHoles::At(&crate::plain(points)),
};
// A surface body's face is untrimmed where it is when asked; a solid's is copied out.
let replace = *in_place && mb.body.is_surface();
@@ -331,6 +421,70 @@ fn push_sheet(st: &mut ModelState, f: &Feature, traced: crate::lineage::Traced)
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
}
/// The boundaries `surface.boundary` builds its patch from: each picked curve's own stations in
/// the sketch's frame, every one of them leaving the boundary square into the loop — the direction
/// from the curve towards the middle of the loop, taken across its tangent — over the plane the
/// curve lies in (so a chain of planar curves makes a flat patch, and a network that is not
/// coplanar is followed as the plane through the curve's own tangent). `orders` sets how much of
/// that plane each side repeats.
fn boundary_frames(
ss: &SolvedSketch,
curves: &[String],
orders: &[solidcraft_kernel::Continuity],
samples: usize,
) -> Result<Vec<solidcraft_kernel::Boundary>> {
use solidcraft_kernel::{Boundary, Frame};
// The stations along each curve, evenly in its own arclength, so every side carries the same
// number of frames and the patch's net is square.
let mut sides: Vec<Vec<solidcraft_geom::Vec3>> = Vec::with_capacity(curves.len());
for id in curves {
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("sketch curve `{id}`")))?;
let pieces = ss.sketch.exact_pieces(ci);
if pieces.is_empty() {
return Err(DocError::Invalid(format!("curve `{id}` has no exact geometry to build on")));
}
let lengths: Vec<f64> = pieces.iter().map(Seg2::length).collect();
let total: f64 = lengths.iter().sum();
if !total.is_finite() || total <= 1e-9 {
return Err(DocError::Invalid(format!("curve `{id}` has no length")));
}
let mut pts = Vec::with_capacity(samples);
let (mut used, mut at) = (0.0, 0usize);
for k in 0..samples {
let want = total * k as f64 / (samples.saturating_sub(1)).max(1) as f64;
while at + 1 < pieces.len() && used + lengths[at] < want {
used += lengths[at];
at += 1;
}
let (Some(piece), Some(len)) = (pieces.get(at), lengths.get(at)) else { return Err(DocError::Invalid("boundary piece".into())) };
let t = ((want - used) / len.max(1e-12)).clamp(0.0, 1.0);
pts.push(ss.plane.to_world(piece.point_at(t)));
}
sides.push(pts);
}
// The middle of the loop, so every side leaves towards the inside of the patch.
let count = sides.iter().map(Vec::len).sum::<usize>().max(1) as f64;
let centre = sides.iter().flatten().fold(solidcraft_geom::Vec3::ZERO, |a, p| a + *p) * (1.0 / count);
let mut out = Vec::with_capacity(sides.len());
for (side, pts) in sides.iter().enumerate() {
let mut frames = Vec::with_capacity(pts.len());
for (i, p) in pts.iter().enumerate() {
// The tangent from the stations on either side, one-sided at the ends (where the side
// only has one neighbour).
let before = if i == 0 { *p } else { pts[i - 1] };
let after = if i + 1 == pts.len() { *p } else { pts[i + 1] };
let tangent = (after - before).normalized().ok_or_else(|| DocError::Invalid("a boundary curve has a stationary station".into()))?;
let across = centre - *p;
let leave = (across - tangent * across.dot(tangent))
.normalized()
.ok_or_else(|| DocError::Invalid("a boundary runs through the middle of the loop: no patch can leave it".into()))?;
frames.push(Frame { p: *p, leave, bend: Vec3::ZERO, bend_change: Vec3::ZERO });
}
out.push(Boundary { frames, order: orders.get(side).copied().unwrap_or(solidcraft_kernel::Continuity::G1) });
}
Ok(out)
}
/// What a surface builder sweeps: the boundary loops of the picked profiles, then the picked
/// curves chained end to end.
fn sheet_chains(ss: &SolvedSketch, profiles: Option<&ProfileSel>, curves: &[String]) -> Result<Vec<Vec<Seg2>>> {
+109
View File
@@ -15,6 +15,94 @@ const MAX_LEN: usize = 4096;
const MAX_DEPTH: usize = 64;
const MAX_ARGS: usize = 64;
/// An expression a feature holds, as the user wrote it (`2 * width + 5 mm`): kept as text and
/// evaluated with the design's parameters. It is saved as a plain string, and reads as one
/// (`&expr` is a `&str`); it changes only as a whole (`*expr = new.into()`).
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct Expr(String);
impl Expr {
pub fn new(s: impl Into<String>) -> Self {
Expr(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl std::ops::Deref for Expr {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl AsRef<str> for Expr {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<String> for Expr {
fn from(s: String) -> Self {
Expr(s)
}
}
impl From<&str> for Expr {
fn from(s: &str) -> Self {
Expr(s.to_string())
}
}
impl From<&String> for Expr {
fn from(s: &String) -> Self {
Expr(s.clone())
}
}
impl From<Expr> for String {
fn from(e: Expr) -> Self {
e.0
}
}
impl std::fmt::Display for Expr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl PartialEq<str> for Expr {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for Expr {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<Expr> for String {
fn eq(&self, other: &Expr) -> bool {
*self == other.0
}
}
impl PartialEq<String> for Expr {
fn eq(&self, other: &String) -> bool {
&self.0 == other
}
}
/// What an expression must evaluate to.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -281,6 +369,13 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
} else if c == '°' {
out.push((Tok::Id("deg".into()), at(i), at(i + 1)));
i += 1;
} else if c == '"' || c == '″' {
// Inch and foot marks (`1"`, `2'`, #1502), straight or as primes.
out.push((Tok::Id("in".into()), at(i), at(i + 1)));
i += 1;
} else if c == '\'' || c == '′' {
out.push((Tok::Id("ft".into()), at(i), at(i + 1)));
i += 1;
} else if c == '×' || c == '·' {
out.push((Tok::Op('*'), at(i), at(i + 1)));
i += 1;
@@ -856,6 +951,20 @@ mod tests {
}
}
#[test]
fn inch_and_foot_marks() {
// #1502: `1"` in a millimetre design is an inch, `2'` two feet, straight or as primes.
assert_eq!(ev("1\"", Kind::Length).unwrap(), 25.4);
assert_eq!(ev("1″", Kind::Length).unwrap(), 25.4);
assert_eq!(ev("2'", Kind::Length).unwrap(), 609.6);
assert_eq!(ev("2′", Kind::Length).unwrap(), 609.6);
assert_eq!(ev("1/2\"", Kind::Length).unwrap(), 12.7);
assert_eq!(ev("width + 1\"", Kind::Length).unwrap(), 65.4);
assert_eq!(ev("2' + 3\"", Kind::Length).unwrap(), 685.8);
// A mark alone is its unit alone, as `in` is.
assert_eq!(ev("\"", Kind::Length).ok(), ev("in", Kind::Length).ok());
}
#[test]
fn arithmetic_and_units() {
assert_eq!(ev("10", Kind::Length).unwrap(), 10.0);
+13 -7
View File
@@ -69,8 +69,12 @@ impl FaceAnchor {
}
let inverse = crate::mat_inverse(&self.author_frame(doc, author)?)
.ok_or_else(|| DocError::Invalid("attached sketch author frame is invalid".into()))?;
let source =
EmbossFace { body: self.target.body.clone(), at: self.target.point, path: self.path.clone(), author_path: self.author_path.clone() };
let source = EmbossFace {
body: self.target.body.clone().into(),
at: crate::FacePick(self.target.point),
path: self.path.clone(),
author_path: self.author_path.clone(),
};
Ok(crate::mat_mul(&inverse, &source.body_frame(doc, body)?))
}
}
@@ -78,7 +82,7 @@ impl FaceAnchor {
pub fn plane_face_anchor(plane: &PlaneRef) -> Option<&FaceAnchor> {
match plane {
PlaneRef::Face { anchor, .. } => anchor.as_deref(),
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane_face_anchor(base),
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => plane_face_anchor(base),
_ => None,
}
}
@@ -86,7 +90,7 @@ pub fn plane_face_anchor(plane: &PlaneRef) -> Option<&FaceAnchor> {
fn has_face_reference(plane: &PlaneRef) -> bool {
match plane {
PlaneRef::Face { .. } => true,
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => has_face_reference(base),
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => has_face_reference(base),
_ => false,
}
}
@@ -106,7 +110,9 @@ impl Document {
}
match plane {
PlaneRef::Face { anchor: Some(anchor), .. } => *plane = PlaneRef::Custom { plane: doc.resolve_face_anchor(anchor, author, state)? },
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => replace(doc, base, author, state, depth + 1)?,
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => {
replace(doc, base, author, state, depth + 1)?
}
_ => {}
}
Ok(())
@@ -447,7 +453,7 @@ mod tests {
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: source,
sketch: crate::SketchId(source),
profiles: crate::ProfileSel::Indices { indices: vec![0, 1] },
extent,
operation: crate::Operation::NewBody,
@@ -558,7 +564,7 @@ mod tests {
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: sk,
sketch: crate::SketchId(sk),
profiles: crate::ProfileSel::All,
extent,
operation: crate::Operation::NewBody,
+12 -13
View File
@@ -5,15 +5,14 @@
use std::collections::BTreeMap;
use crate::FeatureKind;
use crate::walk::Walk;
use crate::{BodyName, FeatureKind, FeatureName, SketchId};
/// A reference a feature holds: `S` is `&u64` or `&mut u64` (a sketch id), `N` is `&String` or
/// `&mut String` (a feature or body name).
pub(crate) enum Ref<S, N> {
/// A reference a feature holds: `S` is `&SketchId` or `&mut SketchId`, and so on.
pub(crate) enum Ref<S, F, B> {
Sketch(S),
Feature(N),
Body(N),
Feature(F),
Body(B),
}
impl FeatureKind {
@@ -23,7 +22,7 @@ impl FeatureKind {
let mut v = Vec::new();
self.walk_refs(&mut |r| {
if let Ref::Sketch(id) = r {
v.push(*id);
v.push(id.0);
}
});
v
@@ -34,13 +33,13 @@ impl FeatureKind {
pub fn remap_refs(&mut self, sketches: &BTreeMap<u64, u64>, names: &BTreeMap<String, String>) {
self.walk_refs_mut(&mut |r| match r {
Ref::Sketch(id) => {
if let Some(n) = sketches.get(id) {
*id = *n;
if let Some(n) = sketches.get(&id.0) {
*id = SketchId(*n);
}
}
Ref::Feature(name) => {
if let Some(n) = names.get(name) {
*name = n.clone();
if let Some(n) = names.get(name.as_ref()) {
*name = FeatureName(n.clone());
}
}
Ref::Body(_) => {}
@@ -51,9 +50,9 @@ impl FeatureKind {
pub fn remap_bodies(&mut self, bodies: &BTreeMap<String, String>) {
self.walk_refs_mut(&mut |r| {
if let Ref::Body(name) = r
&& let Some(n) = bodies.get(name)
&& let Some(n) = bodies.get(name.as_ref())
{
*name = n.clone();
*name = BodyName(n.clone());
}
});
}
+6 -6
View File
@@ -73,9 +73,9 @@ fn ref_record() -> String {
out
}
/// A variant with a field that refers to a sketch (a field named `*sketch*` holding an id, sketch
/// points or loft sections) must report it from `input_sketches`, or deleting the sketch leaves
/// the feature broken. New variants are caught by the walk's exhaustive match; this catches a
/// A variant with a field that refers to a sketch (a `SketchId`, sketch points or loft
/// sections) must report it from `input_sketches`, or deleting the sketch leaves the feature
/// broken. New variants are caught by the walk's exhaustive match; this catches a
/// sketch field added to an existing variant (its fixture has to set it).
/// Per fixture (with every reference and position set): the fields `transform_geometry` changes
/// under a turn and a shift (every point, direction and plane it maps), the face points that name
@@ -107,7 +107,7 @@ fn geometry_record() -> String {
out += &format!(" profiles: {}\n", profiles.join(", "));
}
if let Some(p) = kind.point_expr() {
out += &format!(" placement: {}\n", p.join(", "));
out += &format!(" placement: {}\n", p.iter().map(|e| e.as_str()).collect::<Vec<_>>().join(", "));
}
}
out
@@ -151,8 +151,8 @@ fn input_sketches_lists_every_variant_with_a_sketch_field() {
variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or("").to_lowercase();
continue;
}
let Some((name, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue };
let refers = (name.contains("sketch") && ty.contains("u64")) || ty.contains("SketchPoints") || ty.contains("LoftSection");
let Some((_, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue };
let refers = ty.contains("SketchId") || ty.contains("SketchPoints") || ty.contains("LoftSection");
if refers && !t.starts_with("//") && !with_sketch.contains(&variant) {
with_sketch.push(variant.clone());
}
@@ -84,6 +84,7 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
("draft", json!({"type": "draft", "faces": [o], "angle": "draft.angle", "neutral": plane("draft_neutral"), "pull": [0, 0, 1]})),
("construction_plane", json!({"type": "construction_plane", "plane": plane("cplane")})),
("split", json!({"type": "split", "body": "B", "plane": plane("split")})),
("split_line", json!({"type": "split", "body": "B", "plane": plane("split_line")})),
(
"construction_axis",
json!({"type": "construction_axis", "def": {"type": "two_planes", "a": geo_plane("axis_a"), "b": geo_plane("axis_b")}}),
@@ -114,7 +115,7 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
("sheet_contour", json!({"type": "sheet_contour", "sketch": 1, "curves": ["l1"], "distance": "contour.distance"})),
(
"sheet_flange",
json!({"type": "sheet_flange", "edges": [{"point": o, "angle": "flange.e1_angle", "height": "flange.e1_height"}, {"point": o, "angle": null, "height": "flange.e2_height"}], "height": "flange.height", "angle": "flange.angle", "radius": "flange.radius"}),
json!({"type": "sheet_flange", "edges": [{"point": o, "angle": "flange.e1_angle", "height": "flange.e1_height"}, {"point": o, "angle": null, "height": "flange.e2_height"}], "height": "flange.height", "angle": "flange.angle", "radius": "flange.radius", "corner_relief_shape": "round", "corner_relief": "flange.relief"}),
),
("sheet_hem", json!({"type": "sheet_hem", "edges": [o], "length": "hem.length", "gap": "hem.gap"})),
(
@@ -160,6 +161,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
("surface_loft", json!({"type": "surface_loft", "sections": []})),
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})),
// The Class-A surface family (surface.blend/boundary/match/fair).
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
("surface_boundary", json!({"type": "surface_boundary", "sketch": 1, "curves": ["l1", "l2"]})),
("surface_match", json!({"type": "surface_match", "body": "B", "faces": [o], "target": "C", "edges": [o]})),
("surface_fair", json!({"type": "surface_fair", "body": "B", "faces": [o], "tolerance": "sfair.tolerance"})),
("unstitch", json!({"type": "unstitch", "body": "B"})),
("reverse_normal", json!({"type": "reverse_normal", "body": "B"})),
("surface_untrim", json!({"type": "surface_untrim", "face": {"body": "B", "point": o}, "holes": [o], "outer": true})),
@@ -203,6 +209,7 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value {
"hole_simple" | "hole_drilled" | "hole_counterbore" | "hole_countersink" => json!({"points": {"sketch": 3, "ids": ["p"]}}),
"loft" => json!({"sections": [{"sketch": 4, "profiles": {"type": "all"}}, {"sketch": 5, "profiles": {"type": "all"}}], "targets": ["B"]}),
"split" | "surface_trim" => json!({"tool": tool}),
"split_line" => json!({"sketch": 6, "line": "l1"}),
"surface_loft" => json!({"sections": [{"sketch": 4}, {"sketch": 5}]}),
"split_face" => json!({"body": "B", "tool": tool, "sketch": 6}),
"emboss" => json!({"face": {"body": "B", "at": o}, "targets": ["B"]}),
@@ -161,6 +161,11 @@ split
Angle = split.angle (Angle)
Offset = split.offset (Length)
expressions: split.angle, split.offset
split_line
inputs:
Angle = split_line.angle (Angle)
Offset = split_line.offset (Length)
expressions: split_line.angle, split_line.offset
construction_axis
inputs:
Angle = axis_a.angle (Angle)
@@ -243,7 +248,8 @@ sheet_flange
Edge angle = flange.e1_angle (Angle)
Edge height = flange.e1_height (Length)
Edge height = flange.e2_height (Length)
expressions: flange.height, flange.angle, flange.radius, flange.e1_angle, flange.e1_height, flange.e2_height
Corner relief = flange.relief (Length)
expressions: flange.height, flange.angle, flange.radius, flange.e1_angle, flange.e1_height, flange.e2_height, flange.relief
sheet_hem
inputs:
Length = hem.length (Length)
@@ -334,6 +340,13 @@ surface_offset
Distance = soff.distance (Length)
Tolerance = soff.tolerance (Length)
expressions: soff.distance, soff.tolerance
surface_blend
surface_boundary
surface_match
surface_fair
inputs:
Tolerance = sfair.tolerance (Length)
expressions: sfair.tolerance
unstitch
reverse_normal
surface_untrim
@@ -76,6 +76,8 @@ construction_plane
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
split
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2]
split_line
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
construction_axis
moves: def.a.plane.axis_dir[0], def.a.plane.axis_dir[1], def.a.plane.axis_origin[0], def.a.plane.axis_origin[1], def.a.plane.axis_origin[2], def.b.plane.axis_dir[0], def.b.plane.axis_dir[1], def.b.plane.axis_origin[0], def.b.plane.axis_origin[1], def.b.plane.axis_origin[2]
construction_point_planes
@@ -152,6 +154,15 @@ surface_blend
surface_offset
moves: faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
surface_blend
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
surface_boundary
surface_match
moves: edges[0][0], edges[0][1], edges[0][2], faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
surface_fair
moves: faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
unstitch
moves: faces[0][0], faces[0][1], faces[0][2]
reverse_normal
@@ -81,6 +81,10 @@ construction_plane
split
remap_refs: plane.base.base.name
remap_bodies: body, tool.body
split_line
sketches: [6]
remap_refs: plane.base.base.name, sketch
remap_bodies: body
construction_axis
construction_point_planes
construction_point_along
@@ -173,6 +177,15 @@ surface_blend
remap_bodies: from.body
surface_offset
remap_bodies: body
surface_blend
remap_bodies: from.body
surface_boundary
sketches: [1]
remap_refs: sketch
surface_match
remap_bodies: body
surface_fair
remap_bodies: body
unstitch
remap_bodies: body
reverse_normal
+274 -11
View File
@@ -222,13 +222,111 @@
//! parameters); the source's `at`, `height` and `angle` then hold the numbers they last came
//! to. A text without `exprs` is set as it always was; 0.12.0 asks for an update instead of
//! dropping the expressions and keeping the text at its last numbers.
//!
//! Later formats are one file per change, so changes made side by side never edit the same line
//! (`src/format/pending/README.md`):
//! - `src/format/v<N>/`: the changes format N added, one file each, its `//!` note saying what is
//! saved and what older designs get, and a `step` when older designs are rewritten as they open
//! (23: motion link couplings, FreeCAD and Rhino imports; 24: an Edge Flange's own corner relief).
//! - `src/format/pending/`: changes no release has fixed a number for yet. While there are any,
//! this build writes the next number (which no release reads), with the names of these changes
//! in `format_pending`, so a later build applies just the steps a file is missing. At release
//! time `scripts/fold-format-pending.sh` moves them into the next `v<N>/`.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 22;
/// The format this build writes: the last folded one, or the next while changes are pending.
pub const FORMAT: u32 = FORMATS.current();
/// The last format whose upgrade step is written out in [`JSON_STEPS`]; later formats are made
/// of [`Part`]s (`src/format/`, listed by `build.rs`).
const HAND_WRITTEN: u32 = 22;
/// One change to the file format: a file in `src/format/v<N>/` or `src/format/pending/`.
pub(crate) struct Part {
/// The file's name, which a file written while the change was pending lists.
pub name: &'static str,
/// How a design from before the change is rewritten as it opens, if it is.
pub step: Option<JsonStep>,
}
include!(concat!(env!("OUT_DIR"), "/format_parts.rs"));
/// Every format this build knows and how to upgrade a design through them.
const FORMATS: Formats<'static> = Formats { hand: &JSON_STEPS, folded: FOLDED, pending: PENDING };
/// The names of the pending changes this build writes into a design (`format_pending`).
pub fn pending_names() -> Vec<String> {
FORMATS.pending.iter().map(|p| p.name.to_string()).collect()
}
/// The upgrade path: the hand-written steps up to [`HAND_WRITTEN`], the folded formats after it
/// (each a list of changes) and the pending changes, which make the next format.
struct Formats<'a> {
hand: &'a [JsonStep],
folded: &'a [(u32, &'a [Part])],
pending: &'a [Part],
}
impl Formats<'_> {
const fn current(&self) -> u32 {
self.hand.len() as u32 + 1 + self.folded.len() as u32 + if self.pending.is_empty() { 0 } else { 1 }
}
/// The changes format `version` is made of, if it is made of changes.
fn parts(&self, version: u32) -> Option<&[Part]> {
let after = version.checked_sub(self.hand.len() as u32 + 2)? as usize;
match self.folded.get(after) {
Some((_, parts)) => Some(parts),
None if after == self.folded.len() && !self.pending.is_empty() => Some(self.pending),
None => None,
}
}
/// Bring a design of format `version` up to [`Formats::current`]. `applied` is the file's
/// `format_pending`: the changes of its format it already has, when it was written while they
/// were pending; the others' steps run first. A pending change this build doesn't have is a
/// newer build's, and the design is refused.
fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
if version == 0 || version > self.current() {
return Err(DocError::Invalid(format!("not a SolidCraft design (format {version})")));
}
// A list beside a format that isn't made of changes means nothing (a tool or a test set
// an older number on a newer file): it is ignored, like any field an upgrade drops.
if let (Some(applied), Some(parts)) = (applied, self.parts(version)) {
let pending = version == self.current() && !self.pending.is_empty();
if let Some(unknown) = applied.iter().find(|name| pending && !parts.iter().any(|p| p.name == name.as_str())) {
return Err(DocError::Invalid(format!(
"this design was saved by a newer SolidCraft (file format {version} with `{unknown}`, which this version doesn't know): update SolidCraft to open it"
)));
}
for part in parts.iter().filter(|p| !applied.iter().any(|name| name == p.name)) {
part.step.map_or(Ok(()), |step| step(v))?;
}
}
for from in version..self.current() {
match self.hand.get(from as usize - 1) {
Some(step) => step(v)?,
None => {
for part in self.parts(from + 1).unwrap_or_default() {
part.step.map_or(Ok(()), |step| step(v))?;
}
}
}
}
Ok(())
}
}
/// Take a file's `format_pending` (the pending changes it has) out of its JSON.
fn take_pending(v: &mut Value) -> Result<Option<Vec<String>>> {
let Some(list) = v.as_object_mut().and_then(|o| o.remove("format_pending")) else { return Ok(None) };
let invalid = || DocError::Invalid("damaged design: `format_pending` is not a short list of names".into());
let list = list.as_array().filter(|l| l.len() <= 1000).ok_or_else(invalid)?;
list.iter().map(|n| n.as_str().filter(|n| n.len() <= 128).map(str::to_string).ok_or_else(invalid)).collect::<Result<_>>().map(Some)
}
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -243,9 +341,10 @@ pub fn version_of(s: &str) -> Option<u32> {
s.strip_prefix("solidcraft/").or_else(|| s.strip_prefix(LEGACY_FORMAT_PREFIX))?.parse().ok()
}
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
/// JSON-level upgrades up to [`HAND_WRITTEN`]: entry `i` turns format `i + 1` into `i + 2`.
/// Never add to it: a new format is a file in `src/format/pending/`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 21] = [
const JSON_STEPS: [JsonStep; HAND_WRITTEN as usize - 1] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -457,6 +556,7 @@ pub fn read(s: &str) -> Result<Document> {
Some(Value::String(f)) => version_of(f).ok_or_else(|| DocError::Invalid(format!("not a SolidCraft design (format `{f}`)")))?,
Some(_) => return Err(DocError::Invalid("not a SolidCraft design (format is not a string)".into())),
};
let applied = take_pending(&mut v)?;
if version == 0 {
return Err(DocError::Invalid("not a SolidCraft design (format 0)".into()));
}
@@ -476,9 +576,7 @@ pub fn read(s: &str) -> Result<Document> {
crate::format_offsets::validate_raw(&v)?;
let legacy_wires = validate_wires(&v, version)?;
validate_body_transfers(&v)?;
for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) {
step(&mut v)?;
}
FORMATS.upgrade(&mut v, version, applied.as_deref())?;
let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
let mut legacy_by_feature = std::collections::BTreeMap::<usize, Vec<usize>>::new();
for (feature, wire) in legacy_wires {
@@ -577,6 +675,7 @@ pub fn read(s: &str) -> Result<Document> {
let floor = largest.checked_add(1).ok_or_else(|| DocError::Invalid("damaged design: the id space is exhausted".into()))?;
d.next_id = d.next_id.max(floor).max(1);
d.format = format_string(FORMAT);
d.format_pending = pending_names();
Ok(d)
}
@@ -999,6 +1098,170 @@ fn normalise(d: &mut Document) -> Result<()> {
mod tests {
use super::*;
/// A pending change that records that its step ran, in `trace`.
fn traced(name: &'static str) -> Part {
fn step_a(v: &mut Value) -> Result<()> {
trace(v, "a")
}
fn step_b(v: &mut Value) -> Result<()> {
trace(v, "b")
}
fn trace(v: &mut Value, s: &str) -> Result<()> {
let o = v.as_object_mut().ok_or_else(|| DocError::Invalid("no object".into()))?;
o.entry("trace").or_insert_with(|| Value::Array(Vec::new())).as_array_mut().into_iter().for_each(|t| t.push(Value::from(s)));
Ok(())
}
Part { name, step: Some(if name.starts_with('a') { step_a } else { step_b }) }
}
fn trace_of(v: &Value) -> Vec<&str> {
v["trace"].as_array().into_iter().flatten().filter_map(Value::as_str).collect()
}
/// Formats after the hand-written ones follow on from them, one folder each, and the build
/// writes the next number exactly while changes are pending, so the last release (which
/// reads up to 22) refuses what a development build saves.
#[test]
fn formats_follow_on_and_pending_changes_take_the_next_number() {
assert_eq!(JSON_STEPS.len() as u32, HAND_WRITTEN - 1);
for (i, (version, parts)) in FOLDED.iter().enumerate() {
assert_eq!(*version, HAND_WRITTEN + 1 + i as u32);
assert!(!parts.is_empty(), "format {version} has no changes");
}
let last_folded = HAND_WRITTEN + FOLDED.len() as u32;
assert_eq!(FORMAT, last_folded + u32::from(!PENDING.is_empty()));
const { assert!(FORMAT >= 24, "designs saved as 23 and 24 by nightlies still open") };
assert_eq!(pending_names(), PENDING.iter().map(|p| p.name).collect::<Vec<_>>());
assert_eq!(Document::new("A").format_pending, pending_names());
}
/// The build lists every change file there is, in file-name order: a change is a file, so
/// two branches that each add one merge cleanly and the merged build has both.
#[test]
fn every_change_file_is_listed_in_name_order() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/format");
let names = |dir: &str| -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(root.join(dir))
.map(|d| d.filter_map(|e| e.ok()?.file_name().to_str()?.strip_suffix(".rs").map(str::to_string)).collect())
.unwrap_or_default();
names.sort();
names
};
assert_eq!(names("pending"), pending_names());
for (version, parts) in FOLDED {
assert_eq!(names(&format!("v{version}")), parts.iter().map(|p| p.name).collect::<Vec<_>>());
}
}
/// Two changes pending side by side both run, in name order, after every older format's
/// steps: an old design upgrades through all of them, and one from the last folded format
/// gets just the pending ones.
#[test]
fn pending_changes_apply_in_order_after_older_formats() {
let pending = [traced("a-first"), traced("b-second")];
let formats = Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending };
let next = HAND_WRITTEN + FOLDED.len() as u32 + 1;
assert_eq!(formats.current(), next);
for version in [1, HAND_WRITTEN, next - 1] {
let mut v = serde_json::json!({"features": [], "assembly": {"joints": [{"flip": false}]}});
formats.upgrade(&mut v, version, None).unwrap();
assert_eq!(trace_of(&v), ["a", "b"], "from format {version}");
// The hand-written steps ran too: format 3's joints were flipped on the way up.
assert_eq!(v["assembly"]["joints"][0]["flip"], Value::Bool(version < 4), "from format {version}");
}
// A design of the pending number written before `b-second` existed gets just that step.
let mut v = serde_json::json!({});
formats.upgrade(&mut v, next, Some(&["a-first".to_string()])).unwrap();
assert_eq!(trace_of(&v), ["b"]);
// One that has both, or that this build wrote, needs nothing.
let mut v = serde_json::json!({});
formats.upgrade(&mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap();
assert_eq!(trace_of(&v), Vec::<&str>::new());
// A change this build doesn't know is a newer build's: refused, not guessed at.
let err = formats.upgrade(&mut serde_json::json!({}), next, Some(&["c-later".into()])).unwrap_err();
assert!(err.to_string().contains("newer SolidCraft"), "{err}");
// A list on a format that isn't made of changes means nothing.
let mut v = serde_json::json!({});
formats.upgrade(&mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
assert_eq!(trace_of(&v), ["a", "b"]);
}
/// Folding the pending changes into a number (the release script) keeps every design
/// opening the same: a file saved while they were pending gets the steps it missed.
#[test]
fn folded_changes_upgrade_like_pending_ones() {
let parts = [traced("a-first"), traced("b-second")];
let mut folded: Vec<(u32, &[Part])> = FOLDED.to_vec();
let next = HAND_WRITTEN + folded.len() as u32 + 1;
folded.push((next, &parts));
let formats = Formats { hand: &JSON_STEPS, folded: &folded, pending: &[] };
assert_eq!(formats.current(), next);
let mut old = serde_json::json!({});
formats.upgrade(&mut old, HAND_WRITTEN, None).unwrap();
assert_eq!(trace_of(&old), ["a", "b"]);
let mut nightly = serde_json::json!({});
formats.upgrade(&mut nightly, next, Some(&["a-first".into()])).unwrap();
assert_eq!(trace_of(&nightly), ["b"]);
let mut released = serde_json::json!({});
formats.upgrade(&mut released, next, None).unwrap();
assert_eq!(trace_of(&released), Vec::<&str>::new());
}
/// `format_pending` is read and checked: a damaged list is refused, a list of changes this
/// build doesn't have is a newer build's, and saving writes this build's own list.
#[test]
fn format_pending_in_a_file_is_checked() {
let mut file: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
assert_eq!(file.get("format_pending").is_some(), !PENDING.is_empty());
for damaged in [serde_json::json!("x"), serde_json::json!([1]), serde_json::json!(["x".repeat(200)])] {
file["format_pending"] = damaged;
assert!(read(&file.to_string()).is_err());
}
file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]);
assert_eq!(read(&file.to_string()).is_err(), !PENDING.is_empty(), "only a pending format's list names changes");
file["format_pending"] = Value::from(pending_names());
let opened = read(&file.to_string()).unwrap();
assert_eq!(opened.format_pending, pending_names());
}
/// 24 added the corner relief of an edge flange: a no-op step carries a 23 design up, a flange
/// without a relief of its own saves as before, and one with it is saved under 24 and kept.
#[test]
fn version_twenty_three_designs_open_and_an_edge_flange_relief_is_saved_in_twenty_four() {
let flange = |shape: Option<&str>, size: Option<&str>| crate::FeatureKind::SheetFlange {
edges: vec![crate::FlangeEdge::at(solidcraft_geom::Vec3::ZERO)],
height: "10 mm".into(),
angle: "90 deg".into(),
radius: None,
position: "inside".into(),
flip: false,
body: None,
corner_relief_shape: shape.map(Into::into),
corner_relief: size.map(Into::into),
};
let saved = |document: &Document| -> Value { serde_json::from_str(&document.to_json()).unwrap() };
// A flange that follows the rule saves neither key, and a design of format 23 opens as it was.
let mut plain = Document::new("A");
plain.add_feature(flange(None, None), None).unwrap();
let mut file = saved(&plain);
let feature = file["features"].as_array().and_then(|f| f.last()).cloned().unwrap();
assert_eq!(feature["type"], "sheet_flange");
assert!(feature.get("corner_relief_shape").is_none() && feature.get("corner_relief").is_none(), "{feature}");
file["format"] = Value::from("solidcraft/23");
let opened = read(&file.to_string()).unwrap();
assert_eq!(opened.format, format_string(FORMAT));
assert_eq!(opened.features, plain.features);
// One that chooses its relief says so in the file, which is stamped 24 and keeps it.
let mut own = Document::new("A");
own.add_feature(flange(Some("round"), Some("6 mm")), None).unwrap();
let file = saved(&own);
let feature = file["features"].as_array().and_then(|f| f.last()).cloned().unwrap();
assert_eq!((&feature["corner_relief_shape"], &feature["corner_relief"]), (&Value::from("round"), &Value::from("6 mm")));
// A build that reads up to 23 asks for an update on that stamp instead of dropping the relief.
assert_eq!(version_of(file["format"].as_str().unwrap_or_default()), Some(FORMAT));
assert_eq!(read(&file.to_string()).unwrap(), own);
}
#[test]
fn version_thirteen_names_keep_their_meaning_under_the_legacy_mark() {
let file = serde_json::json!({
@@ -1172,7 +1435,7 @@ mod tests {
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch,
sketch: crate::SketchId(sketch),
profiles: ProfileSel::All,
extent: Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
@@ -1182,9 +1445,9 @@ mod tests {
start_offset: None,
through_all: false,
taper: Some("0 deg".into()),
to: Some(solidcraft_geom::Vec3::new(0.0, 0.0, 10.0)),
to: Some(crate::FacePick(solidcraft_geom::Vec3::new(0.0, 0.0, 10.0))),
to_offset: Some("offset + 1 mm".into()),
from: Some(solidcraft_geom::Vec3::new(0.0, 0.0, 5.0)),
from: Some(crate::FacePick(solidcraft_geom::Vec3::new(0.0, 0.0, 5.0))),
},
operation: Operation::NewBody,
targets: vec![],
@@ -1814,7 +2077,7 @@ mod tests {
"orientation": {"direction": [0.0, 1.0, 0.0]}, "twist": "90 deg", "scale": "0.5"});
let k: FeatureKind = serde_json::from_value(new.clone()).unwrap();
let FeatureKind::SurfaceSweep { orientation, .. } = &k else { panic!("{k:?}") };
assert_eq!(*orientation, SweepOrientation::Direction(solidcraft_geom::Vec3::Y));
assert_eq!(*orientation, SweepOrientation::Direction(crate::Dir(solidcraft_geom::Vec3::Y)));
assert_eq!(serde_json::to_value(&k).unwrap(), new);
let parallel: SweepOrientation = serde_json::from_value(serde_json::json!("parallel")).unwrap();
assert_eq!(parallel, SweepOrientation::Parallel);
+27
View File
@@ -0,0 +1,27 @@
# Pending design-format changes
One file per change to what a design file saves that no release has shipped yet. Adding a file
here is the whole format bump: never edit `FORMAT`, `JSON_STEPS` or another change's file.
- Name it after the change, lowercase with dashes: `placed-plane-origins.rs`.
- Start it with a `//!` note: what is saved, what an older design gets without it, and what
the last release does with a design that has it (it refuses the newer format number).
- If older designs must be rewritten as they open, add the step to the same file:
```rust
//! …the note…
use serde_json::Value;
pub fn step(v: &mut Value) -> crate::Result<()> {
// rewrite the design's JSON here
Ok(())
}
```
Steps run in file-name order, after the steps of every older format.
While this folder has a change in it, the build writes the next format number (the last
folded one + 1, which no release reads), and lists the changes a file has in its
`format_pending` field, so a later build applies only the steps that file is missing. At release
time `scripts/fold-format-pending.sh` moves these files into `../v<N>/`, which fixes that number
for good. See `crates/doc/src/format.rs` and CONTRIBUTING.md.
@@ -0,0 +1,4 @@
//! An Import's `file` can name a FreeCAD `.FCStd` or Rhino `.3dm` file (#1435, read through
//! cadmpeg); its `step` is still the STEP text the file was restated as, so the feature's fields
//! are unchanged and version 22 designs need no transformation; 0.13.x asks for an update before
//! it meets these imports.
@@ -0,0 +1,7 @@
//! A motion link can carry a `coupling`: a gear pair (`ra`, `rb`, `internal`), a rack and pinion
//! (`radius`) or a screw (`pitch`), each size a number in mm, an expression or a picked round
//! face, and `reverse`. The link's `ratio` is worked out from it whenever the design changes and
//! saved as well, so a link reads the same without it. Version 22 designs need no
//! transformation: a link without `coupling` is a plain motion link, as it always was; 0.13.0
//! (which reads up to 22) asks for an update instead of keeping a gear's ratio when its radius
//! changes.
@@ -0,0 +1,5 @@
//! An Edge Flange feature can choose its own corner relief (`corner_relief_shape`: notch, round,
//! rectangular or tear, and `corner_relief`, the size across), for the corners its flanges make.
//! Version 23 designs need no transformation: a feature without them follows the sheet metal
//! rule, as it always did, and saves as before; 0.13.0 (which reads up to 22) asks for an update
//! instead of silently building the rule's relief where a design asks for another.
@@ -0,0 +1,5 @@
//! The Class-A surface pass adds `SurfaceBoundary` (Boundary Surface: `sketch`, `curves`,
//! optional `orders`, `crown`, `samples`), `SurfaceMatch` (Match Surface: `body`, optional
//! `faces`, `target`, `edges`, optional `order`) and `SurfaceFair` (Fair Surface: `body`, optional
//! `faces`, `strength`, `iterations`, `keep_edges`, `tolerance`). Older designs need no
//! transformation; 0.13.x asks for an update instead of meeting a feature it can't read.
@@ -0,0 +1,4 @@
//! Rectangular and circular feature patterns store alignment and reversal, independently for
//! each rectangular direction. Defaults preserve older positive-direction placements.
//! Center retains the source and alternates copies on either side, as sketch patterns do.
//! The last release refuses this pending format and asks for an update instead of losing it.
@@ -0,0 +1,5 @@
//! A sketch can sit on a plane seen from its other side: `{"type": "flipped", "base": <plane>}`
//! (with `"about_y": true` when it turns about the plane's y axis rather than its x axis). New
//! sketches started from behind an origin or construction plane save this, so they face the
//! viewer. Older designs have no flipped planes and open unchanged; the last release refuses a
//! design that has one (it asks for an update) instead of putting the sketch on the wrong side.
@@ -0,0 +1,5 @@
//! A sketch line can be `infinite` (#1359): construction geometry through its two points, drawn
//! across the whole view, a boundary for trim and extend along its whole line. Saved only when
//! set: older designs need no transformation, and a line without it is the line it always was;
//! 0.13.x (which reads up to 24) asks for an update instead of drawing such a line between its
//! two points.
@@ -0,0 +1,6 @@
//! A `Patch` on edges can carry `continuity` (`all`: `g0`, `g1` or `g2`, and `edges`:
//! `[{edge: [x, y, z], g}]` for edges met otherwise; G0 everywhere when absent, and then not
//! saved) and `points` it passes through (none when absent). Older designs need no
//! transformation: a patch without them fills as it always did; a loop that is not a height field
//! over its plane, which 0.13.0 refused, now fills with one face. 0.13.x asks for an update
//! instead of dropping them.
@@ -0,0 +1,4 @@
//! An explicit plane (a mirror's, or any plane reference) can be `placed`: an `origin` of three
//! length expressions with `x_dir` and `y_dir`, so it follows the design's parameters (#1492). A
//! plane whose origin is plain numbers is still saved as `custom`. Older designs need no
//! transformation; 0.13.x asks for an update instead of failing to read the plane.
@@ -0,0 +1,5 @@
//! Sketch linear patterns save two independent live direction-line references and each
//! direction's alignment and reversal. Even centered totals keep the original fixed and use
//! reversal to choose which side receives the extra copy. Older patterns retain their fixed
//! vectors and positive-direction placement through defaulted fields, so no rewrite is needed.
//! The last release refuses this pending format instead of silently losing the references.
@@ -0,0 +1,4 @@
//! A Split Body feature can take a straight sketch line as its tool (`sketch`, the sketch's
//! feature id, and `line`, the line's id), splitting by the plane through the line along the
//! sketch's normal (#1552). Older designs need no transformation: a split without them uses its
//! plane or face as before; 0.13.x asks for an update instead of splitting by the stored plane.
+1 -1
View File
@@ -424,7 +424,7 @@ mod tests {
.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: if missing_sketch { 999_999 } else { id },
sketch: crate::SketchId(if missing_sketch { 999_999 } else { id }),
profiles: ProfileSel::OffsetBoundaries { associations: vec!["deleted-o1".into()] },
extent: crate::Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
+245 -56
View File
@@ -1,7 +1,8 @@
//! Joints between occurrences: joint origins (snap frames on component geometry, re-found on
//! every solve so they follow edits), joints (rigid, revolute, slider, cylindrical, pin-slot,
//! planar, ball) with their motion values and limits, motion links, and the solver that places
//! occurrences from the joint graph.
//! planar, ball) with their motion values and limits, motion links (gears, racks and screws are
//! links whose ratio comes from radii or a pitch), and the solver that places occurrences from
//! the joint graph.
//!
//! A joint makes B's joint frame coincide with A's frame moved by the joint's motion:
//! `T_B · F_b = T_A · F_a · M(q) · Flip`, so `T_B = T_A · F_a · M(q) · Flip · F_b⁻¹`, where `T`
@@ -151,6 +152,115 @@ pub struct MotionLink {
pub ratio: f64,
#[serde(default)]
pub offset: f64,
/// Gears, a rack and pinion or a screw: what the ratio comes from. The ratio is worked out
/// from it again whenever the design changes (`coupling_ratio`), so it follows the radii and
/// the pitch; the last one worked out is kept in `ratio`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coupling: Option<Coupling>,
}
/// A length a coupling's ratio comes from: millimetres, an expression over the design's
/// parameters, or the radius of the round face picked on an occurrence (a circle-centre snap,
/// found again on every solve like a joint origin).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Size {
Length(f64),
Expr(String),
Circle(JointOrigin),
}
/// The geometry a motion link's ratio comes from. `reverse` turns the follower the other way.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Coupling {
/// Two gears on revolute joints, `ra` on a's and `rb` on b's (pitch radii, or any two lengths
/// in their proportion such as tooth counts): b turns ra/rb as far as a, the other way round,
/// or the same way when one is an internal (ring) gear.
Gear {
ra: Size,
rb: Size,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
internal: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
reverse: bool,
},
/// A pinion of pitch radius `radius` on a revolute joint and a rack on a slider: the rack
/// travels the radius times the pinion's turn in radians (either joint may drive).
RackPinion {
radius: Size,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
reverse: bool,
},
/// A screw: the travel advances `pitch` per turn (a cylindrical joint's angle and distance,
/// or a revolute joint and a slider). Right-handed unless reversed.
Screw {
pitch: Size,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
reverse: bool,
},
}
impl Coupling {
/// The lengths the ratio comes from: a's gear and b's, or the pinion's radius or the pitch.
pub fn sizes(&self) -> Vec<&Size> {
match self {
Coupling::Gear { ra, rb, .. } => vec![ra, rb],
Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s],
}
}
pub fn sizes_mut(&mut self) -> Vec<&mut Size> {
match self {
Coupling::Gear { ra, rb, .. } => vec![ra, rb],
Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s],
}
}
}
/// Largest length a coupling takes (mm).
const MAX_SIZE: f64 = 1e7;
/// A coupling length in millimetres: positive and finite.
fn size_of(doc: &Document, st: &ModelState, s: &Size) -> Result<f64> {
let v = match s {
Size::Length(v) => *v,
Size::Expr(e) => doc.eval(e, crate::expr::Kind::Length)?,
Size::Circle(o) => circle_at(doc, st, o)?.1.radius,
};
if v.is_finite() && v > 1e-9 && v <= MAX_SIZE {
Ok(v)
} else {
Err(DocError::Invalid(format!("a gear, rack or screw needs a positive length, not {v}")))
}
}
/// The ratio a link's coupling gives it (b's value per a's, in radians and mm), worked out from
/// the geometry and parameters it is defined by; `None` for a plain link.
pub fn coupling_ratio(doc: &Document, st: &ModelState, l: &MotionLink) -> Option<Result<f64>> {
l.coupling.as_ref().map(|c| coupled(doc, st, l, c))
}
fn coupled(doc: &Document, st: &ModelState, l: &MotionLink, c: &Coupling) -> Result<f64> {
let angle = |id: u64, i: usize| doc.assembly.joints.iter().find(|j| j.id == id).is_some_and(|j| j.kind.is_angle(i));
let (turn_a, turn_b) = (angle(l.a, l.ia), angle(l.b, l.ib));
// Travel per radian, for a turn coupled to a travel either way round.
let travel = |k: f64| match (turn_a, turn_b) {
(true, false) => Ok(k),
(false, true) => Ok(1.0 / k),
_ => Err(DocError::Invalid("a rack or a screw couples a turn and a travel".into())),
};
let sign = |reverse: bool| if reverse { -1.0 } else { 1.0 };
match c {
Coupling::Gear { ra, rb, internal, reverse } => {
if !(turn_a && turn_b) {
return Err(DocError::Invalid("gears couple two turns".into()));
}
Ok(size_of(doc, st, ra)? / size_of(doc, st, rb)? * sign(!internal) * sign(*reverse))
}
Coupling::RackPinion { radius, reverse } => Ok(travel(size_of(doc, st, radius)?)? * sign(*reverse)),
Coupling::Screw { pitch, reverse } => Ok(travel(size_of(doc, st, pitch)? / std::f64::consts::TAU)? * sign(*reverse)),
}
}
/// Joints, joint origins and motion links of a design.
@@ -272,27 +382,35 @@ pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Resul
let (_, c, n) = best.ok_or_else(|| DocError::Invalid("joint origin: no planar face there".into()))?;
frame(c, n, None).ok_or_else(bad)
}
Snap::CircleCenter { pick, face } => {
let pick = &named_pick(st, *pick, face.as_deref())?;
let comp = component_of(doc, o.occurrence)?;
let cyl = component_bodies(doc, st, comp)
.into_iter()
.filter_map(|b| solidcraft_kernel::cylinder_face_at(&b.body, *pick))
.min_by(|a, b| {
let off = |c: &solidcraft_kernel::CylinderFace| {
let v = *pick - c.axis_point;
((v - c.axis * v.dot(c.axis)).len() - c.radius).abs()
};
off(a).total_cmp(&off(b))
})
.ok_or_else(|| DocError::Invalid("joint origin: no cylindrical face there".into()))?;
let s = (*pick - cyl.axis_point).dot(cyl.axis);
Snap::CircleCenter { .. } => {
let (pick, cyl) = circle_at(doc, st, o)?;
let s = (pick - cyl.axis_point).dot(cyl.axis);
let (at, dir) = if (s - cyl.start).abs() <= (cyl.end - s).abs() { (cyl.start, -1.0) } else { (cyl.end, 1.0) };
frame(cyl.axis_point + cyl.axis * at, cyl.axis * dir, None).ok_or_else(bad)
}
}
}
/// The pick of a circle-centre snap (on its named face) and the cylindrical face there
/// (component coordinates). Any other snap is refused.
fn circle_at(doc: &Document, st: &ModelState, o: &JointOrigin) -> Result<(Vec3, solidcraft_kernel::CylinderFace)> {
let Snap::CircleCenter { pick, face } = &o.snap else { return Err(DocError::Invalid("pick a round face".into())) };
let pick = named_pick(st, *pick, face.as_deref())?;
let comp = component_of(doc, o.occurrence)?;
let cyl = component_bodies(doc, st, comp)
.into_iter()
.filter_map(|b| solidcraft_kernel::cylinder_face_at(&b.body, pick))
.min_by(|a, b| {
let off = |c: &solidcraft_kernel::CylinderFace| {
let v = pick - c.axis_point;
((v - c.axis * v.dot(c.axis)).len() - c.radius).abs()
};
off(a).total_cmp(&off(b))
})
.ok_or_else(|| DocError::Invalid("joint origin: no cylindrical face there".into()))?;
Ok((pick, cyl))
}
/// World transform of an occurrence (through its parents).
pub fn occurrence_world(doc: &Document, occ: u64) -> Mat {
let mut m = IDENTITY;
@@ -1093,8 +1211,61 @@ pub fn solve(doc: &Document, st: &ModelState) -> Solution {
pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
let asm = &doc.assembly;
let mut sol = Solution::default();
let joints: Vec<&Joint> = asm.joints.iter().filter(|j| !j.suppressed).take(MAX_JOINTS).collect();
let mut vals = joint_values(asm);
let Built { tree, mut placed, closing, mut dof } = build(doc, st, &vals, &mut sol);
let mut stuck = BTreeSet::new();
if !closing.is_empty() {
(sol.values, stuck) = close_loops(&tree, &closing, asm, held, &mut vals);
if !sol.values.is_empty() {
placed = tree.place(&vals);
}
}
for id in &closing {
let Some(j) = tree.joints.get(id) else { continue };
let (Some(ta), Some(tb)) = (placed.get(&j.a.occurrence), placed.get(&j.b.occurrence)) else { continue };
let (Some(want), Some((_, fb))) = (tree.step(*id, true, ta, &vals), tree.frames.get(id)) else { continue };
// Measured at the joint, where the loop solver closes it.
let (dt, da) = mismatch(&mat_mul(&want, fb), &mat_mul(tb, fb));
if dt > 1e-6 || da > 1e-8 {
sol.conflicts.push((
j.id,
j.name.clone(),
format!("off by {dt:.4} mm and {:.3}° from the placement the other joints give", da.to_degrees()),
));
} else if stuck.contains(id) {
// It meets there, but the loop would have to come apart on the way (a rocker
// driven a full turn ends where it started).
sol.conflicts.push((j.id, j.name.clone(), "the loop can't stay closed on the way to these values".into()));
}
}
if !closing.is_empty() {
loop_dof(&tree, &closing, asm, &vals, &mut dof);
}
for o in &doc.occurrences {
dof.entry(o.id).or_insert(6);
}
placed.remove(&0);
sol.world = placed;
sol.dof = dof;
sol
}
/// The joint graph laid out for a solve: the tree, where it places each occurrence for the
/// values it was built with, the joints that close loops, and each occurrence's degrees of
/// freedom along the tree.
struct Built<'a> {
tree: Tree<'a>,
placed: BTreeMap<u64, Mat>,
closing: Vec<u64>,
dof: BTreeMap<u64, usize>,
}
/// Lay out the joint graph: the spanning tree out from the fixed occurrences (the root, grounded
/// ones; with neither involved, the first joint's A side) and the joints left over, which close
/// loops. Joints whose origins can't be resolved go to `sol.errors`; `sol.fixed` is set.
fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Solution) -> Built<'a> {
let asm = &doc.assembly;
let joints: Vec<&Joint> = asm.joints.iter().filter(|j| !j.suppressed).take(MAX_JOINTS).collect();
// Joint frames in component coordinates (errors leave the joint out).
let mut frames: BTreeMap<u64, (Mat, Mat)> = BTreeMap::new();
for j in &joints {
@@ -1152,7 +1323,7 @@ pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
if placed.contains_key(&other) {
continue;
}
let Some(m) = tree.step(j.id, from_a, &t, &vals) else { continue };
let Some(m) = tree.step(j.id, from_a, &t, vals) else { continue };
used.insert(j.id);
placed.insert(other, m);
tree.parent.insert(other, (occ, j.id, from_a));
@@ -1161,48 +1332,15 @@ pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
queue.push_back(other);
}
}
// Joints that close loops: move the loops' values to meet them, then check they agree.
// Joints that close loops: the solve moves the loops' values to meet them, then checks they
// agree.
let closing: Vec<u64> = joints
.iter()
.filter(|j| !used.contains(&j.id) && tree.frames.contains_key(&j.id))
.filter(|j| placed.contains_key(&j.a.occurrence) && placed.contains_key(&j.b.occurrence))
.map(|j| j.id)
.collect();
let mut stuck = BTreeSet::new();
if !closing.is_empty() {
(sol.values, stuck) = close_loops(&tree, &closing, asm, held, &mut vals);
if !sol.values.is_empty() {
placed = tree.place(&vals);
}
}
for id in &closing {
let Some(j) = tree.joints.get(id) else { continue };
let (Some(ta), Some(tb)) = (placed.get(&j.a.occurrence), placed.get(&j.b.occurrence)) else { continue };
let (Some(want), Some((_, fb))) = (tree.step(*id, true, ta, &vals), tree.frames.get(id)) else { continue };
// Measured at the joint, where the loop solver closes it.
let (dt, da) = mismatch(&mat_mul(&want, fb), &mat_mul(tb, fb));
if dt > 1e-6 || da > 1e-8 {
sol.conflicts.push((
j.id,
j.name.clone(),
format!("off by {dt:.4} mm and {:.3}° from the placement the other joints give", da.to_degrees()),
));
} else if stuck.contains(id) {
// It meets there, but the loop would have to come apart on the way (a rocker
// driven a full turn ends where it started).
sol.conflicts.push((j.id, j.name.clone(), "the loop can't stay closed on the way to these values".into()));
}
}
if !closing.is_empty() {
loop_dof(&tree, &closing, asm, &vals, &mut dof);
}
for o in &doc.occurrences {
dof.entry(o.id).or_insert(6);
}
placed.remove(&0);
sol.world = placed;
sol.dof = dof;
sol
Built { tree, placed, closing, dof }
}
impl Document {
@@ -1250,6 +1388,10 @@ impl Document {
}
}
#[path = "joints_reach.rs"]
mod reach;
pub use reach::{REACH_TOL, Reach, Reached, reach};
/// The frame matrix for an explicit origin / z / x (public for commands).
pub fn frame_of(o: Vec3, z: Vec3, x: Option<Vec3>) -> Option<Mat> {
frame(o, z, x)
@@ -1283,7 +1425,7 @@ mod tests {
suppressed: false,
})
.collect();
let links = (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0 }).collect();
let links = (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0, coupling: None }).collect();
Assembly { joints, links, ..Assembly::default() }
}
@@ -1306,6 +1448,53 @@ mod tests {
assert_eq!(again, vals);
}
/// A coupling saves as its definition: sizes as numbers, expressions or picked faces, flags
/// only when set; a link without one (as every design before couplings has) reads as before.
#[test]
fn couplings_read_and_write_their_definition() {
let old: MotionLink = serde_json::from_str(r#"{"a": 1, "b": 2, "ratio": 2}"#).unwrap();
assert_eq!((old.coupling, old.offset, old.ia), (None, 0.0, 0));
let text = r#"{"a": 1, "b": 2, "ratio": -0.5, "coupling": {"type": "gear", "ra": 10, "rb": "r2 * 2",
"internal": true}}"#;
let l: MotionLink = serde_json::from_str(text).unwrap();
let Some(Coupling::Gear { ra, rb, internal, reverse }) = &l.coupling else { panic!("{l:?}") };
assert_eq!((ra, rb, *internal, *reverse), (&Size::Length(10.0), &Size::Expr("r2 * 2".into()), true, false));
let back = serde_json::to_value(&l).unwrap();
assert_eq!(back["coupling"], serde_json::json!({"type": "gear", "ra": 10.0, "rb": "r2 * 2", "internal": true}));
let picked: Size = serde_json::from_str(r#"{"occurrence": 3, "snap": {"type": "circle_center", "pick": [1, 0, 0]}}"#).unwrap();
assert!(matches!(picked, Size::Circle(JointOrigin { occurrence: 3, snap: Snap::CircleCenter { .. } })));
let screw: Coupling = serde_json::from_str(r#"{"type": "screw", "pitch": 1.5, "reverse": true}"#).unwrap();
assert_eq!(screw, Coupling::Screw { pitch: Size::Length(1.5), reverse: true });
}
/// Ratios from the sizes: gears turn by their radii (the other way unless internal), a rack
/// and a screw travel per radian whichever of the two joints drives.
#[test]
fn coupling_ratios_follow_their_sizes() {
let mut doc = Document { assembly: chain(3, 0.0), ..Document::default() };
if let Some(j) = doc.assembly.joints.get_mut(2) {
j.kind = JointKind::Slider;
}
let st = ModelState::default();
let ratio = |a: u64, b: u64, c: Coupling| {
let l = MotionLink { a, ia: 0, b, ib: 0, ratio: 0.0, offset: 0.0, coupling: Some(c) };
coupling_ratio(&doc, &st, &l).map(|r| r.map_err(|e| e.to_string()))
};
let len = Size::Length;
assert_eq!(ratio(1, 2, Coupling::Gear { ra: len(10.0), rb: len(40.0), internal: false, reverse: false }), Some(Ok(-0.25)));
assert_eq!(ratio(1, 2, Coupling::Gear { ra: len(10.0), rb: len(40.0), internal: true, reverse: false }), Some(Ok(0.25)));
assert_eq!(ratio(1, 3, Coupling::RackPinion { radius: len(8.0), reverse: false }), Some(Ok(8.0)));
assert_eq!(ratio(3, 1, Coupling::RackPinion { radius: len(8.0), reverse: true }), Some(Ok(-0.125)));
let screw = ratio(1, 3, Coupling::Screw { pitch: len(std::f64::consts::TAU), reverse: false });
assert_eq!(screw, Some(Ok(1.0)));
assert!(matches!(ratio(1, 2, Coupling::RackPinion { radius: len(8.0), reverse: false }), Some(Err(_))));
assert!(matches!(ratio(1, 3, Coupling::Gear { ra: len(1.0), rb: len(1.0), internal: false, reverse: false }), Some(Err(_))));
for bad in [0.0, -2.0, f64::NAN, f64::INFINITY, 1e300] {
assert!(matches!(ratio(1, 2, Coupling::Gear { ra: len(bad), rb: len(1.0), internal: false, reverse: false }), Some(Err(_))), "{bad}");
}
assert!(matches!(ratio(1, 2, Coupling::Gear { ra: Size::Expr("nope".into()), rb: len(1.0), internal: false, reverse: false }), Some(Err(_))));
}
#[test]
fn motions_compose() {
assert!(close(&motion(JointKind::Revolute, &[std::f64::consts::FRAC_PI_2]), &rot_z(std::f64::consts::FRAC_PI_2)));
+445
View File
@@ -0,0 +1,445 @@
//! Inverse kinematics (`joint.reach`): move the values of the joints on an occurrence's chain so a
//! point of it reaches a target (and an axis of it points a given way), every value within its
//! limits, motion links followed, and the closed loops those values are in kept closed.
use std::cell::Cell;
use std::collections::BTreeSet;
use solidcraft_geom::Vec3;
use solidcraft_sketch::linalg;
use super::{
Assembly, Held, MAX_LOOP_VALUES, MAX_LOOP_WORK, MAX_LOOPS, Solution, Tree, Values, apply_links, build, closure_gaps, jacobian, joint_values,
largest, least_squares, limit_bounds, link_chains, loop_groups, loop_scale, unknowns,
};
use crate::{Document, ModelState, apply_point, apply_vector, mat_mul};
/// How close counts as reached: mm for the point, degrees for the axis.
pub const REACH_TOL: f64 = 1e-4;
/// What a reach asks.
#[derive(Clone, Debug)]
pub struct Reach {
/// The occurrence that reaches.
pub occurrence: u64,
/// Its point that goes to `to`, in its own (component) coordinates.
pub point: Vec3,
/// Where the point goes (world).
pub to: Vec3,
/// A unit axis of it (its own coordinates) and the unit world direction it turns to.
pub axis: Option<(Vec3, Vec3)>,
/// The joints whose values move toward the target (`None`: every joint on its chain). The
/// other joints of a closed loop they are in follow.
pub joints: Option<BTreeSet<u64>>,
}
/// How a reach ended.
#[derive(Clone, Debug, Default)]
pub struct Reached {
/// The new stored values of the joints that moved (joint id → values).
pub values: Values,
/// Short of the target: the values it ended on a limit of, with the target pulling past it
/// (joint, value index, the upper limit).
pub limits: Vec<(u64, usize, bool)>,
/// Short of the target: the closing joint of a loop that stopped it (the chain alone, the
/// loop left open, gets closer).
pub stopped_by_loop: Option<u64>,
}
/// How much a loop's gap (mm) outweighs a miss of the target (mm) in a step: enough to keep the
/// step along the mechanism, so putting it back on the loops moves it little.
const LOOP_WEIGHT: f64 = 1e3;
/// Steps a reach takes at most, each moving no value more than 10° or a tenth of the chain's
/// size (so it follows the mechanism's branch).
const MAX_REACH_STEPS: usize = 200;
const STEP_ANGLE: f64 = 10.0;
const STEP_SHARE: f64 = 0.1;
/// Move the values of the joints on `r.occurrence`'s chain (those in `r.joints`, when given) so
/// its point gets to `r.to`, or as close as the limits and closed loops let it: the nearest pose,
/// found from where it is (it stays on the mechanism's branch). Values a motion link sets move
/// through the start of their link chain; the other values of the loops they are in follow. An
/// error says why nothing can move.
pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, String> {
let asm = &doc.assembly;
let vals = joint_values(asm);
let built = build(doc, st, &vals, &mut Solution::default());
let tree = &built.tree;
if tree.fixed.contains_key(&r.occurrence) {
return Err("it is fixed in place (grounded, or the part its joints are placed from)".into());
}
if !tree.parent.contains_key(&r.occurrence) {
return Err("no joint places it: it is not connected to anything grounded (ground a part, or join it to a grounded part)".into());
}
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
let allowed = |id: &u64| r.joints.as_ref().is_none_or(|js| js.contains(id));
let path: BTreeSet<u64> = tree.path(r.occurrence).1.iter().map(|(k, _)| *k).collect();
// The values that move it: those of the joints on its chain (the ones given), a linked one
// through the start of its link chain (given or not), and the starts given of the chain's
// other linked values.
let chosen: BTreeSet<u64> = path.iter().copied().filter(allowed).collect();
let mut drive: Vec<(u64, usize)> = Vec::new();
for j in chosen.iter().filter_map(|id| tree.joints.get(id)) {
drive.extend((0..j.kind.dofs().len()).map(|i| (j.id, i)).filter(|v| !linked.contains(v)));
}
let starts = link_chains(asm, tree, &path, &linked).1.into_iter().filter(|v| allowed(&v.0));
for v in link_chains(asm, tree, &chosen, &linked).1.into_iter().chain(starts) {
if !drive.contains(&v) {
drive.push(v);
}
}
if drive.is_empty() {
return Err(if r.joints.is_some() {
"none of the joints given moves it"
} else {
"no joint on its chain moves (rigid, or set by motion links)"
}
.into());
}
// The loops those values are in, and the values of theirs that follow.
if built.closing.len() > MAX_LOOPS {
return Err(format!("the design has more than {MAX_LOOPS} closed loops"));
}
let groups = loop_groups(tree, &built.closing, asm, &linked);
let (mut vars, mut closers, mut around) = (drive.clone(), Vec::new(), path.clone());
let mut taken = BTreeSet::new();
while vars.len() <= MAX_LOOP_VALUES {
let more: Vec<usize> =
groups.iter().enumerate().filter(|(g, (_, _, t))| !taken.contains(g) && t.iter().any(|v| vars.contains(v))).map(|(g, _)| g).collect();
if more.is_empty() {
break;
}
for (c, a, _) in more.iter().filter_map(|g| groups.get(*g)) {
closers.extend(c.iter().copied());
around.extend(a.iter().copied());
for v in unknowns(tree, a, asm, &linked, &Held::new()) {
if !vars.contains(&v) {
vars.push(v);
}
}
}
taken.extend(more);
}
if vars.len() > MAX_LOOP_VALUES {
return Err(format!("its chain and loops have more than {MAX_LOOP_VALUES} values"));
}
let scale = loop_scale(tree, &around, &vals);
let bounds = limit_bounds(tree, &vars);
let x0: Vec<f64> = vars.iter().map(|(id, i)| vals.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
// A distance steps by a share of the chain's size, of the way to the target or of the point's
// reach from its joints, whichever is longest (one slider on its own has no size, and a part
// turning about a joint far from its point moves that far).
let point = tree.world(r.occurrence, &vals).map(|t| apply_point(&t, r.point));
let lever = path
.iter()
.filter_map(|id| {
let (j, (fa, _)) = (tree.joints.get(id)?, tree.frames.get(id)?);
Some(apply_point(&mat_mul(&tree.world(j.a.occurrence, &vals)?, fa), Vec3::ZERO).dist(point?))
})
.fold(0.0, f64::max);
let span = point.map_or(scale, |p| scale.max(p.dist(r.to)).max(lever));
let cap: Vec<f64> = vars
.iter()
.map(|(id, i)| if tree.joints.get(id).is_some_and(|j| j.kind.is_angle(*i)) { STEP_ANGLE.to_radians() } else { STEP_SHARE * span })
.collect();
// Every value around the chain and its loops, links included, moves at most a step at a time:
// a value a link sets can turn far more than the one that drives it, and a loop must stay
// closed on the way, as joint.drive steps its loops.
let mut paced: Vec<((u64, usize), f64)> = Vec::new();
for j in around.iter().filter_map(|id| tree.joints.get(id)) {
for i in 0..j.kind.dofs().len() {
paced.push(((j.id, i), if j.kind.is_angle(i) { STEP_ANGLE.to_radians() } else { STEP_SHARE * span }));
}
}
let tol = (1e-10 * scale).min(1e-7);
let mt = if r.axis.is_some() { 6 } else { 3 };
let spent = Cell::new(0usize);
let mut work = vals.clone();
let mut f = |x: &[f64]| misses(tree, asm, r, scale, &vars, &closers, x, &mut work, &spent);
let mut x = x0.clone();
let miss = |res: &[f64]| res.iter().take(mt).map(|v| v * v).sum::<f64>().sqrt();
let before = f(&x0).map_or(f64::INFINITY, |r| miss(&r));
let res = approach(&mut f, &|a, b| stride(asm, &vals, &vars, &paced, a, b), mt, &mut x, &bounds, &cap, tol)
.ok_or("a closed loop on its chain is open and can't close (joint.list shows its conflict)")?;
let mut out = Reached::default();
let point_miss = res.iter().take(3).map(|v| v * v).sum::<f64>().sqrt();
let turn_miss = (res.iter().skip(3).take(mt - 3).map(|v| v * v).sum::<f64>().sqrt() / scale).to_degrees();
if point_miss > REACH_TOL || turn_miss > REACH_TOL {
// The limits it rests on: the target pulls the value past them (−Jᵀr over the target's
// rows, ignoring pulls at right angles to the miss: under a millionth of the value's own
// reach times the miss).
let n = x.len();
if let Some(jac) = jacobian(&mut f, &x, res.len()) {
for (k, (((id, i), v), (lo, hi))) in vars.iter().zip(&x).zip(&bounds).enumerate() {
let column = |row: usize| jac.get(row * n + k).copied().unwrap_or(0.0);
let pull: f64 = -(0..mt).map(|row| column(row) * res.get(row).copied().unwrap_or(0.0)).sum::<f64>();
let eps = 1e-6 * (0..mt).map(|row| column(row).powi(2)).sum::<f64>().sqrt() * miss(&res);
let at = |b: f64| b.is_finite() && (v - b).abs() <= 1e-9 * b.abs().max(1.0);
if at(*hi) && pull > eps {
out.limits.push((*id, *i, true));
} else if at(*lo) && pull < -eps {
out.limits.push((*id, *i, false));
}
}
}
// A value a motion link sets, held at its own limit (the link clamps it there): the
// target's pull on it as if it were free.
if f(&x).is_some() {
for (id, i) in linked.iter().filter(|(id, _)| around.contains(id)) {
let (Some(v), Some((lo, hi))) = (work.get(id).and_then(|q| q.get(*i)).copied(), limit_bounds(tree, &[(*id, *i)]).first().copied())
else {
continue;
};
let at = |b: f64| b.is_finite() && (v - b).abs() <= 1e-9 * b.abs().max(1.0);
if !at(lo) && !at(hi) {
continue;
}
let nudged = |dv: f64| {
let mut w = work.clone();
if let Some(slot) = w.get_mut(id).and_then(|q| q.get_mut(*i)) {
*slot = v + dv;
}
target(tree, r, scale, &w)
};
let (Some(up), Some(down)) = (nudged(1e-6), nudged(-1e-6)) else { continue };
let pull: f64 = -up.iter().zip(&down).zip(&res).map(|((a, b), r)| (a - b) / 2e-6 * r).sum::<f64>();
let eps = 1e-6 * up.iter().zip(&down).map(|(a, b)| ((a - b) / 2e-6).powi(2)).sum::<f64>().sqrt() * miss(&res);
if at(hi) && pull > eps {
out.limits.push((*id, *i, true));
} else if at(lo) && pull < -eps {
out.limits.push((*id, *i, false));
}
}
}
// A loop stopped it when the chain alone gets closer.
// ponytail: names the first loop of several; trying each one open would name the one that binds.
if let Some(first) = closers.first() {
let k = drive.len();
let mut alone = vals.clone();
let mut g = |x: &[f64]| misses(tree, asm, r, scale, &drive, &[], x, &mut alone, &spent);
let (mut xo, bo, co) = (x0.get(..k).unwrap_or_default().to_vec(), bounds.get(..k).unwrap_or_default(), cap.get(..k).unwrap_or_default());
// The loops left open, nothing to pace.
if let Some(ro) = approach(&mut g, &|_, _| 0.0, mt, &mut xo, bo, co, tol)
&& miss(&ro) < miss(&res) - REACH_TOL.max(1e-9 * miss(&res))
{
out.stopped_by_loop = Some(*first);
}
}
}
// The stored values that changed, when that got it closer (by more than the solver's noise:
// a held part doesn't creep).
let gained = before - miss(&res) > 1e-7;
for ((id, i), v) in vars.iter().zip(&x).filter(|_| gained) {
let Some(j) = tree.joints.get(id) else { continue };
if j.values.get(*i) == Some(v) {
continue;
}
let q = out.values.entry(*id).or_insert_with(|| {
let mut q = j.values.clone();
q.resize(j.kind.dofs().len(), 0.0);
q
});
if let Some(slot) = q.get_mut(*i) {
*slot = *v;
}
}
Ok(out)
}
/// The target's misses with the values `vars` at `x`: the point's (mm), then the axis's (as mm
/// across the chain's `scale`), then the gaps of the loops `closers`, weighted.
fn misses(
tree: &Tree,
asm: &Assembly,
r: &Reach,
scale: f64,
vars: &[(u64, usize)],
closers: &[u64],
x: &[f64],
work: &mut Values,
spent: &Cell<usize>,
) -> Option<Vec<f64>> {
spent.set(spent.get() + 1);
if spent.get() > MAX_LOOP_WORK {
return None;
}
for ((id, i), v) in vars.iter().zip(x) {
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
*slot = *v;
}
}
apply_links(asm, work);
let mut out = target(tree, r, scale, work)?;
let n = out.len();
closure_gaps(tree, closers, work, scale, &mut out)?;
for v in out.iter_mut().skip(n) {
*v *= LOOP_WEIGHT;
}
out.iter().all(|v| v.is_finite()).then_some(out)
}
/// The target's misses for these values: the point's (mm), then the axis's (as mm across `scale`).
fn target(tree: &Tree, r: &Reach, scale: f64, vals: &Values) -> Option<Vec<f64>> {
let t = tree.world(r.occurrence, vals)?;
let d = apply_point(&t, r.point) - r.to;
let mut out = vec![d.x, d.y, d.z];
if let Some((a, z)) = r.axis {
let w = (apply_vector(&t, a) - z) * scale;
out.extend([w.x, w.y, w.z]);
}
Some(out)
}
/// How many steps apart the values `vars` at `a` and at `b` put the `paced` values (each with its
/// step), motion links followed.
fn stride(asm: &Assembly, vals: &Values, vars: &[(u64, usize)], paced: &[((u64, usize), f64)], a: &[f64], b: &[f64]) -> f64 {
let at = |x: &[f64]| {
let mut w = vals.clone();
for ((id, i), v) in vars.iter().zip(x) {
if let Some(slot) = w.get_mut(id).and_then(|q| q.get_mut(*i)) {
*slot = *v;
}
}
apply_links(asm, &mut w);
w
};
let (wa, wb) = (at(a), at(b));
let get = |w: &Values, id: &u64, i: usize| w.get(id).and_then(|q| q.get(i)).copied().unwrap_or(0.0);
paced.iter().map(|((id, i), step)| (get(&wb, id, *i) - get(&wa, id, *i)).abs() / step).fold(0.0, f64::max)
}
/// Levenberg-Marquardt that stays on the loops: move `x` (within `bounds`, at most `cap` per value
/// and a step of every value `stride` measures, links included, a step) to bring the first `mt` values of `f` (the target's misses) toward zero, the rest (the
/// loops' weighted gaps) at zero. Each step is solved over all the rows, values on a limit the
/// step pushes past held, then put back on the loops (`least_squares` on their gaps) and taken
/// when it gets closer. It ends at the target, or as close as the limits and loops let it get.
/// Returns `f` there, or `None` when the loops don't close at the start.
fn approach(
f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>,
stride: &dyn Fn(&[f64], &[f64]) -> f64,
mt: usize,
x: &mut Vec<f64>,
bounds: &[(f64, f64)],
cap: &[f64],
tol: f64,
) -> Option<Vec<f64>> {
let n = x.len();
let miss = |r: &[f64]| r.iter().take(mt).map(|v| v * v).sum::<f64>();
let close = |f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &mut Vec<f64>| {
least_squares(&mut |y| f(y).map(|r| r.get(mt..).unwrap_or_default().to_vec()), x, bounds, tol * LOOP_WEIGHT)
};
let loops = f(x)?.len() > mt;
if loops && !close(f, x) {
return None;
}
let mut r = f(x)?;
let (mut lambda, mut kicked) = (1e-3, false);
for _ in 0..MAX_REACH_STEPS {
if largest(r.get(..mt).unwrap_or_default()) <= tol {
break;
}
let m = r.len();
let Some(jac) = jacobian(f, x, m) else { break };
// The normal equations JᵀJ δ = −Jᵀr, and the target rows' share of the diagonal (the
// damping scales with it, so the loops' weight doesn't slow the step along them).
let (mut jtj, mut g, mut diag) = (linalg::Mat::zeros(n, n), vec![0.0; n], vec![0.0; n]);
for (row, ri) in r.iter().enumerate() {
let a = jac.get(row * n..(row + 1) * n).unwrap_or_default();
for (p, ap) in a.iter().enumerate().filter(|(_, v)| **v != 0.0) {
if let Some(gp) = g.get_mut(p) {
*gp -= ap * ri;
}
if row < mt
&& let Some(dp) = diag.get_mut(p)
{
*dp += ap * ap;
}
for (q, aq) in a.iter().enumerate().skip(p) {
jtj.add(p, q, ap * aq);
}
}
}
for p in 0..n {
for q in 0..p {
jtj.set(p, q, jtj.get(q, p));
}
}
let mut stepped = false;
while lambda < 1e12 && !stepped {
let mut held = vec![false; n];
// Solved, then again with the values held that it pushes past their limits.
for _ in 0..=n {
let mut a = jtj.clone();
let mut b = g.clone();
for p in 0..n {
a.add(p, p, lambda * (1.0 + diag.get(p).copied().unwrap_or(0.0)));
if held.get(p).copied().unwrap_or(false) {
for q in 0..n {
a.set(p, q, if p == q { 1.0 } else { 0.0 });
a.set(q, p, if p == q { 1.0 } else { 0.0 });
}
if let Some(bp) = b.get_mut(p) {
*bp = 0.0;
}
}
}
let Some(d) = linalg::solve(a, b) else { break };
let past: Vec<bool> =
x.iter().zip(&d).zip(bounds).map(|((v, dv), (lo, hi))| (*dv > 0.0 && *v >= *hi) || (*dv < 0.0 && *v <= *lo)).collect();
if past.iter().zip(&held).any(|(p, h)| *p && !*h) {
held = held.iter().zip(&past).map(|(h, p)| *h || *p).collect();
continue;
}
let full: Vec<f64> = x.iter().zip(&d).map(|(v, dv)| v + dv).collect();
let over = d.iter().zip(cap).map(|(d, c)| d.abs() / c).fold(1.0f64, f64::max).max(stride(x, &full));
let mut next: Vec<f64> = x.iter().zip(&d).zip(bounds).map(|((v, dv), (lo, hi))| (v + dv / over).max(*lo).min(*hi)).collect();
// Checked again once taken: a link held at a limit or the loops closed again may
// move it further than foreseen (joint.drive halves such steps too).
if (!loops || close(f, &mut next))
&& stride(x, &next) <= 2.0
&& let Some(rn) = f(&next)
&& miss(&rn) < miss(&r)
{
(*x, r) = (next, rn);
// A step cut down to the cap (as at a stretched, singular pose) damps the next one
// more instead of less, so it doesn't crawl along its longest value.
lambda = if over > 1.0 { (lambda * over).min(1e6) } else { (lambda / 3.0).max(1e-9) };
stepped = true;
}
break;
}
if !stepped {
lambda *= 4.0;
}
}
if !stepped {
// Where nothing changes the misses to first order but they aren't least (the target
// straight across a pivot from the point), a small kick off it gets closer, once.
if kicked {
break;
}
kicked = true;
// Both ways: one may run into a limit.
for sign in [1.0, -1.0] {
let mut next: Vec<f64> = x
.iter()
.zip(cap.iter().zip(bounds))
.enumerate()
.map(|(k, (v, (c, (lo, hi))))| (v + sign * 1e-3 * c * if k % 2 == 0 { 1.0 } else { -1.0 }).max(*lo).min(*hi))
.collect();
if (!loops || close(f, &mut next))
&& stride(x, &next) <= 2.0
&& let Some(rn) = f(&next)
&& miss(&rn) < miss(&r)
{
(*x, r, lambda, stepped) = (next, rn, 1e-3, true);
break;
}
}
if !stepped {
break;
}
}
}
Some(r)
}
+7 -2
View File
@@ -34,10 +34,12 @@ pub mod metadata;
pub use metadata::Metadata;
pub mod motion;
mod name_lookup;
mod names;
pub mod naming;
mod params;
pub mod physical_material;
mod piece_names;
mod points;
mod profile_refs;
pub mod progress;
mod walk;
@@ -57,10 +59,13 @@ pub use document::*;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for,
edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, object_height, parse_metric_thread, pattern_matrices,
revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, state_in_frame, state_in_frame_at, through_all_distance,
world_state, world_state_reusing,
revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane, state_in_frame, state_in_frame_at,
through_all_distance, world_state, world_state_reusing,
};
pub use expr::Expr;
pub use names::{BodyName, FeatureName, SketchId};
pub use params::{ParamRow, ParamUser, cycles};
pub use points::{Dir, FacePick, Position, plain};
pub use solidcraft_kernel as kernel;
pub use solidcraft_sketch as sketch;
+146
View File
@@ -0,0 +1,146 @@
//! What a feature refers to: bodies and features by name, sketches by id. Each is saved as the
//! plain name or id it wraps, but has its own type, so a body name can't be passed where a
//! feature name or an expression is meant.
use std::ops::Deref;
use serde::{Deserialize, Serialize};
macro_rules! name_type {
($(#[$doc:meta])* $name:ident) => {
$(#[$doc])*
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(pub String);
impl Deref for $name {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::borrow::Borrow<str> for $name {
fn borrow(&self) -> &str {
&self.0
}
}
impl From<String> for $name {
fn from(s: String) -> Self {
$name(s)
}
}
impl From<&str> for $name {
fn from(s: &str) -> Self {
$name(s.to_string())
}
}
impl From<&String> for $name {
fn from(s: &String) -> Self {
$name(s.clone())
}
}
impl From<$name> for String {
fn from(n: $name) -> String {
n.0
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl PartialEq<str> for $name {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for $name {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<String> for $name {
fn eq(&self, other: &String) -> bool {
&self.0 == other
}
}
impl PartialEq<$name> for String {
fn eq(&self, other: &$name) -> bool {
*self == other.0
}
}
impl PartialEq<$name> for str {
fn eq(&self, other: &$name) -> bool {
self == other.0
}
}
};
}
name_type!(
/// A body, by name.
BodyName
);
name_type!(
/// A feature, by name.
FeatureName
);
/// A sketch, by its feature id.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SketchId(pub u64);
impl Deref for SketchId {
type Target = u64;
fn deref(&self) -> &u64 {
&self.0
}
}
impl From<u64> for SketchId {
fn from(id: u64) -> Self {
SketchId(id)
}
}
impl From<SketchId> for u64 {
fn from(id: SketchId) -> u64 {
id.0
}
}
impl PartialEq<u64> for SketchId {
fn eq(&self, other: &u64) -> bool {
self.0 == *other
}
}
impl PartialEq<SketchId> for u64 {
fn eq(&self, other: &SketchId) -> bool {
*self == other.0
}
}
impl std::fmt::Display for SketchId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
+5 -5
View File
@@ -540,7 +540,7 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
let c = ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64);
match &f.kind {
FeatureKind::Extrude { sketch, .. } => {
let Some(ss) = before.sketch(*sketch) else { return format!("F{fid}:face:{fi}") };
let Some(ss) = before.sketch(sketch.0) else { return format!("F{fid}:face:{fi}") };
let n = ss.plane.normal();
// A face across the extrusion is a cap; others sweep a sketch curve.
let flat = !ns.is_empty() && ns.iter().all(|m| m.cross(n).len() < 1e-6);
@@ -552,7 +552,7 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
nearest_curve(ss, Vec2::new(q.x, q.y)).map(|id| format!("F{fid}:side:{id}")).unwrap_or_else(|| format!("F{fid}:face:{fi}"))
}
FeatureKind::Revolve { sketch, axis, .. } => {
let Some(ss) = before.sketch(*sketch) else { return format!("F{fid}:face:{fi}") };
let Some(ss) = before.sketch(sketch.0) else { return format!("F{fid}:face:{fi}") };
let Ok((o2, d2)) = crate::eval::revolve_axis(ss, axis) else { return format!("F{fid}:face:{fi}") };
let (o, d) = (ss.plane.to_world(o2), (ss.plane.to_world(o2 + d2) - ss.plane.to_world(o2)).normalized().unwrap_or(Vec3::Z));
// A flat face holding the axis is an angle cap: on the sketch plane, or the far one.
@@ -582,7 +582,7 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
FeatureKind::Loft { sections, .. } => sections.first().map(|x| x.sketch),
_ => None,
};
let Some(ss) = sk.and_then(|k| before.sketch(k)) else { return format!("F{fid}:face:{fi}") };
let Some(ss) = sk.and_then(|k| before.sketch(k.0)) else { return format!("F{fid}:face:{fi}") };
let n = ss.plane.normal();
let h = |p: &Vec3| (*p - ss.plane.origin).dot(n);
let flat = !ns.is_empty() && ns.windows(2).all(|w| w[0].cross(w[1]).len() < 1e-6);
@@ -613,10 +613,10 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
format!("F{fid}:{role}")
}
FeatureKind::Cylinder { axis, .. } => {
let flat = !ns.is_empty() && ns.iter().all(|m| m.cross(*axis).len() < 1e-6);
let flat = !ns.is_empty() && ns.iter().all(|m| m.cross(axis.0).len() < 1e-6);
let role = if !flat {
"side"
} else if ns.first().is_some_and(|m| m.dot(*axis) > 0.0) {
} else if ns.first().is_some_and(|m| m.dot(axis.0) > 0.0) {
"top"
} else {
"bottom"
+33 -9
View File
@@ -8,7 +8,7 @@ use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::expr::{self, Kind, ParamDef, Value};
use crate::expr::{self, Expr, Kind, ParamDef, Value};
use crate::walk::Walk;
use crate::{DocError, Document, FeatureKind, MAX_PARAMS, Result};
@@ -23,7 +23,7 @@ pub(crate) enum ExprRole {
impl FeatureKind {
/// The feature's numeric inputs: (label, expression, kind), in a fixed order.
pub fn inputs_mut(&mut self) -> Vec<(&'static str, &mut String, Kind)> {
pub fn inputs_mut(&mut self) -> Vec<(&'static str, &mut Expr, Kind)> {
let mut v = Vec::new();
self.walk_exprs_mut(&mut |(label, e, kind, role)| {
if role == ExprRole::Input {
@@ -38,7 +38,31 @@ impl FeatureKind {
let mut v = Vec::new();
self.walk_exprs(&mut |(label, e, kind, role)| {
if role == ExprRole::Input {
v.push((label, e.clone(), kind));
v.push((label, e.to_string(), kind));
}
});
v
}
/// The coordinates of the points that place the feature: a primitive's position, an
/// explicit plane's origin. They are not inputs (no parameter names), but follow
/// parameters as inputs do.
pub fn placement_exprs_mut(&mut self) -> Vec<&mut Expr> {
let mut v = Vec::new();
self.walk_exprs_mut(&mut |(_, e, _, role)| {
if role == ExprRole::Placement {
v.push(e);
}
});
v
}
/// Read-only view of [`FeatureKind::placement_exprs_mut`].
pub fn placement_exprs(&self) -> Vec<&str> {
let mut v = Vec::new();
self.walk_exprs(&mut |(_, e, _, role)| {
if role == ExprRole::Placement {
v.push(e.as_str());
}
});
v
@@ -236,7 +260,7 @@ impl Document {
if let Some(f) = next.features.get_mut(i)
&& let Some((_, e, _)) = f.kind.inputs_mut().into_iter().nth(k)
{
*e = expr_s.clone();
*e = expr_s.clone().into();
}
if let Some(c) = comment {
next.param_comments.insert(name.to_string(), c.to_string());
@@ -284,7 +308,7 @@ impl Document {
if *target != normalized {
changed.insert(name.clone());
}
*target = normalized;
*target = normalized.into();
}
let (_, errors) = next.param_values();
for (name, error) in errors {
@@ -575,7 +599,7 @@ impl Document {
}
}
}
if f.kind.point_expr().is_some_and(|p| p.iter().any(|e| expr::references(e).iter().any(|r| r == name))) {
if f.kind.placement_exprs().iter().any(|e| expr::references(e).iter().any(|r| r == name)) {
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: "Position".into() });
}
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
@@ -646,17 +670,17 @@ impl Document {
*x = new.to_string();
}
}
for e in f.kind.point_expr_mut().into_iter().flatten() {
for e in f.kind.placement_exprs_mut() {
let r = expr::rename_reference(e, old, new);
if r != *e {
*e = r;
*e = r.into();
n += 1;
}
}
for (_, e, _) in f.kind.inputs_mut() {
let r = expr::rename_reference(e, old, new);
if r != *e {
*e = r;
*e = r.into();
n += 1;
}
}
+61
View File
@@ -0,0 +1,61 @@
//! The points a feature holds, by what they are: a point that picks a face, any other
//! position, a direction. Each is a `Vec3` (and is saved as one), but a different type, so a
//! direction can't be moved like a position, and a pick can't be mistaken for either.
use std::ops::{Deref, DerefMut};
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
macro_rules! point_type {
($(#[$doc:meta])* $name:ident) => {
$(#[$doc])*
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(pub Vec3);
impl Deref for $name {
type Target = Vec3;
fn deref(&self) -> &Vec3 {
&self.0
}
}
impl DerefMut for $name {
fn deref_mut(&mut self) -> &mut Vec3 {
&mut self.0
}
}
impl From<Vec3> for $name {
fn from(v: Vec3) -> Self {
$name(v)
}
}
impl From<$name> for Vec3 {
fn from(p: $name) -> Vec3 {
p.0
}
}
};
}
point_type!(
/// A point that picks a face: it moves with the feature, and names the face it picks.
FacePick
);
point_type!(
/// A position (an edge pick, an axis origin, a fold line): it moves with the feature.
Position
);
point_type!(
/// A direction: it turns with the feature, and never moves.
Dir
);
/// The plain values of a list of picks, positions, directions or names (for code that takes
/// `Vec3`s or `String`s).
pub fn plain<T, P: Clone + Into<T>>(ps: &[P]) -> Vec<T> {
ps.iter().map(|p| p.clone().into()).collect()
}
+2 -2
View File
@@ -309,7 +309,7 @@ mod tests {
.add_feature(
crate::FeatureKind::Extrude {
thin: None,
sketch: feature,
sketch: crate::SketchId(feature),
profiles: selection.clone(),
extent: crate::Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
@@ -436,7 +436,7 @@ mod tests {
.add_feature(
crate::FeatureKind::Extrude {
thin: None,
sketch: feature,
sketch: crate::SketchId(feature),
profiles: selection.clone(),
extent: crate::Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
+5 -2
View File
@@ -261,8 +261,11 @@ fn resolve_uncached(
// Its path is a curve of its own sketch: set with the sketch (`Sketch::text_source_geometry`).
LinkSource::Text { .. } => return Err(DocError::Invalid("a text on a path is set with its sketch".into())),
LinkSource::Plane { name } => {
let pr =
if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } };
let pr = if Plane::named(name).is_some() {
PlaneRef::Origin { name: name.clone() }
} else {
PlaneRef::Construction { name: name.clone().into() }
};
let other = doc.resolve_plane_in(vals, &pr, 0, Some(st)).map_err(|_| lost(&format!("plane {name}")))?;
plane_trace(plane, &other).into_iter().collect()
}
+45 -22
View File
@@ -361,6 +361,15 @@ impl ReliefShape {
}
}
/// The corner relief an edge flange feature asks for in place of the rule's. A value left out
/// follows the rule.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ReliefOverride {
pub shape: Option<ReliefShape>,
/// How far across the relief is (mm).
pub size: Option<f64>,
}
/// A rule's values (mm, unit-less K).
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct RuleValues {
@@ -2151,7 +2160,7 @@ impl SheetBody {
flip: bool,
) -> Result<(Vec<usize>, Vec<String>)> {
let each: Vec<EdgeFlange> = picks.iter().map(|&pick| EdgeFlange { pick, height, angle }).collect();
self.add_flanges_each(&each, radius, position, flip)
self.add_flanges_each(&each, radius, position, flip, ReliefOverride::default())
}
/// Edge flanges that each have their own height and angle, in one go: every edge is trimmed
@@ -2164,8 +2173,16 @@ impl SheetBody {
/// the base that ends beside more of the base gets the rule's bend relief there
/// (`relieve_bend_ends`). Where cut-outs have taken part of a picked edge away, the flange
/// runs along the stretch they leave nearest the pick and takes its setback off that stretch
/// only; an edge they took whole is refused.
pub fn add_flanges_each(&mut self, edges: &[EdgeFlange], radius: f64, position: &str, flip: bool) -> Result<(Vec<usize>, Vec<String>)> {
/// only; an edge they took whole is refused. The corners between these flanges are relieved
/// as the rule says, except where `relief` names another shape or size.
pub fn add_flanges_each(
&mut self,
edges: &[EdgeFlange],
radius: f64,
position: &str,
flip: bool,
relief: ReliefOverride,
) -> Result<(Vec<usize>, Vec<String>)> {
if edges.is_empty() || edges.len() > 1000 {
return Err(DocError::Invalid("pick 1…1000 edges".into()));
}
@@ -2293,7 +2310,7 @@ impl SheetBody {
for (&i, &free) in made.iter().zip(&freed) {
notes.extend(self.relieve_bend_ends(i, free));
}
notes.extend(self.close_corners(&made));
notes.extend(self.close_corners(&made, relief));
notes.extend(self.close_return_corners(&made));
Ok((made, notes))
}
@@ -2711,9 +2728,10 @@ impl SheetBody {
out
}
/// Close the corners the new flanges make, then cut the rule's relief at each (or tear it).
/// Returns a note for every corner left open, without its relief or not torn.
fn close_corners(&mut self, made: &[usize]) -> Vec<String> {
/// Close the corners the new flanges make, then cut the relief at each (or tear it): the
/// rule's, but for the shape or size `relief` names. Returns a note for every corner left
/// open, without its relief or not torn.
fn close_corners(&mut self, made: &[usize], relief: ReliefOverride) -> Vec<String> {
let corners = self.corners(made);
let mut notes = Vec::new();
let mut closed = Vec::with_capacity(corners.len());
@@ -2723,7 +2741,7 @@ impl SheetBody {
Err(why) => notes.push(format!("the flanges at {} are left open: {why}", self.shown(k.c))),
}
}
notes.extend(self.relieve_corners(&corners, &closed));
notes.extend(self.relieve_corners(&corners, &closed, relief));
notes
}
@@ -2733,16 +2751,18 @@ impl SheetBody {
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
}
/// Cut the rule's relief at each of `corners`, or tear those of them that are `closed`.
/// Returns a note for every corner without its relief or not torn.
fn relieve_corners(&mut self, corners: &[Corner], closed: &[Corner]) -> Vec<String> {
/// Cut the relief at each of `corners`, or tear those of them that are `closed`: the rule's,
/// but for the shape or size `relief` names. Returns a note for every corner without its
/// relief or not torn.
fn relieve_corners(&mut self, corners: &[Corner], closed: &[Corner], relief: ReliefOverride) -> Vec<String> {
let mut notes = Vec::new();
match self.relief {
let (shape, size) = (relief.shape.unwrap_or(self.relief), relief.size.unwrap_or(self.relief_size));
match shape {
ReliefShape::Notch => {}
// The reliefs come last: they move the bends' ends, which is how the corners were found.
ReliefShape::Round | ReliefShape::Rectangular => {
for k in corners {
if let Err(why) = self.relieve_corner(k, corners) {
if let Err(why) = self.relieve_corner(k, corners, shape, size) {
notes.push(format!("no corner relief at {}: {why}", self.shown(k.c)));
}
}
@@ -2918,7 +2938,7 @@ impl SheetBody {
Ok(())
}
/// Cut the rule's relief where two bends meet at a corner of the base, `relief_size / 2`
/// Cut a relief `size` across where two bends meet at a corner of the base, `size / 2`
/// from the corner along both edges: a round about the corner through the angle the base
/// fills there, or the square with that side (a kite where the corner isn't square, its
/// cuts square to the two edges). The base's edges then start that far from the corner, so
@@ -2928,8 +2948,7 @@ impl SheetBody {
/// that would leave nothing of an edge it starts on (with the relief at that edge's other
/// end, where there is one), or one reaching as far from the corner as another edge of the
/// base is, is refused with the reason.
fn relieve_corner(&mut self, k: &Corner, corners: &[Corner]) -> std::result::Result<(), String> {
let size = self.relief_size;
fn relieve_corner(&mut self, k: &Corner, corners: &[Corner], shape: ReliefShape, size: f64) -> std::result::Result<(), String> {
if !(size >= MIN_CORNER_RELIEF) {
return Err(format!("the relief size {size:.4} mm is below {MIN_CORNER_RELIEF} mm"));
}
@@ -2961,7 +2980,7 @@ impl SheetBody {
}
// The relief from the edge that ends at the corner to the one that starts there, and
// how far from the corner it reaches.
let (relief, reach) = match self.relief {
let (relief, reach) = match shape {
ReliefShape::Notch | ReliefShape::Tear => return Ok(()),
ReliefShape::Round => (vec![Seg2::Arc { center: c, radius: r, start: da.angle(), sweep: -alpha }], r),
ReliefShape::Rectangular => {
@@ -4238,7 +4257,9 @@ pub fn rename_plane_sheet_body(plane: &mut crate::PlaneRef, old: &str, new: &str
*name = renamed;
}
}
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } => rename_plane_sheet_body(base, old, new),
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } | crate::PlaneRef::Flipped { base, .. } => {
rename_plane_sheet_body(base, old, new)
}
_ => {}
}
}
@@ -4248,7 +4269,9 @@ pub fn rename_plane_sheet_body(plane: &mut crate::PlaneRef, old: &str, new: &str
pub fn plane_sheet_face(plane: &crate::PlaneRef) -> Option<&str> {
match plane {
crate::PlaneRef::Face { name: Some(name), anchor: None, .. } if split_sheet_face_name(name).is_some() => Some(name),
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } => plane_sheet_face(base),
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } | crate::PlaneRef::Flipped { base, .. } => {
plane_sheet_face(base)
}
_ => None,
}
}
@@ -6075,7 +6098,7 @@ mod tests {
/// sheet and the notes on corners left open.
fn flanges_each(base: Region2, rv: &RuleValues, each: &[EdgeFlange]) -> (SheetBody, Vec<String>) {
let mut s = base_flange("Body1", "AL2", rv, Plane::XY, base);
let notes = s.add_flanges_each(each, rv.bend_radius, "inside", false).unwrap().1;
let notes = s.add_flanges_each(each, rv.bend_radius, "inside", false, ReliefOverride::default()).unwrap().1;
(s, notes)
}
@@ -6630,7 +6653,7 @@ mod tests {
let (mut s, _) = tray(&rv, 90.0, 20.0);
let each: Vec<EdgeFlange> =
(0..4).map(|i| EdgeFlange { pick: far_edge_pick(&s, i, true), height: 12.0, angle: [90f64, 70.0][i % 2].to_radians() }).collect();
let (made, notes) = s.add_flanges_each(&each, 1.0, "inside", false).unwrap();
let (made, notes) = s.add_flanges_each(&each, 1.0, "inside", false, ReliefOverride::default()).unwrap();
assert!(notes.is_empty() && made == [4, 5, 6, 7], "{made:?} {notes:?}");
for (a, a_end, b, b_end) in TRAY_RETURNS {
let (fa, fb) = (&s.flanges[a], &s.flanges[b]);
@@ -6803,7 +6826,7 @@ mod tests {
EdgeFlange { pick: far_edge_pick(&s, i, true), height: 12.0, angle: deg.to_radians() }
})
.collect();
let (_, notes) = s.add_flanges_each(&each, 1.0, "inside", false).unwrap();
let (_, notes) = s.add_flanges_each(&each, 1.0, "inside", false, ReliefOverride::default()).unwrap();
assert!(notes.is_empty(), "{what}: {notes:?}");
returns_keep_out_of_the_walls(&s, &what);
holds_the_flat_material(&s, &what);
+2 -2
View File
@@ -53,7 +53,7 @@ use std::f64::consts::{FRAC_PI_2, PI, TAU};
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, Vec2, Vec3};
use solidcraft_kernel::{Body, FaceSurface, face_surface};
use super::{FlatWork, RuleValues, SheetBody, SheetFlange, base_flange, point_in, polys_cross, signed_area, snap};
use super::{FlatWork, ReliefOverride, RuleValues, SheetBody, SheetFlange, base_flange, point_in, polys_cross, signed_area, snap};
use crate::{DocError, ModelBody, Result, apply_point, mat_inverse};
const MAX_BENDS: usize = 1000;
@@ -1002,7 +1002,7 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
Err(why) => notes.push(format!("the corner at {} stays cut open: {why}", sh.shown(k.c))),
}
}
notes.extend(sh.relieve_corners(&corners, &closed));
notes.extend(sh.relieve_corners(&corners, &closed, ReliefOverride::default()));
Ok((sh, notes))
}
+6 -6
View File
@@ -30,7 +30,7 @@ fn plate_doc() -> (Document, u64) {
doc.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: s,
sketch: crate::SketchId(s),
profiles: ProfileSel::All,
extent: Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
@@ -108,7 +108,7 @@ fn box_fillet_cut_timeline() {
let (mut doc, _) = plate_doc();
doc.add_feature(
FeatureKind::Fillet {
edges: vec![Vec3::new(0.0, 0.0, 10.0)],
edges: vec![Position(Vec3::new(0.0, 0.0, 10.0))],
radius: "3".into(),
body: None,
style: Default::default(),
@@ -123,7 +123,7 @@ fn box_fillet_cut_timeline() {
doc.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: s2,
sketch: crate::SketchId(s2),
profiles: ProfileSel::Points { points: vec![Vec2::new(20.0, 15.0)] },
extent: Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
@@ -162,7 +162,7 @@ fn errors_are_reported_not_fatal() {
let (mut doc, s) = plate_doc();
doc.add_feature(
FeatureKind::Revolve {
sketch: s,
sketch: crate::SketchId(s),
profiles: ProfileSel::All,
axis: AxisRef::SketchAxis { axis: "y".into() },
angle: "90 deg".into(),
@@ -178,7 +178,7 @@ fn errors_are_reported_not_fatal() {
.unwrap();
doc.add_feature(
FeatureKind::Fillet {
edges: vec![Vec3::new(500.0, 0.0, 0.0)],
edges: vec![Position(Vec3::new(500.0, 0.0, 0.0))],
radius: "1".into(),
body: None,
style: Default::default(),
@@ -244,7 +244,7 @@ fn primitives_and_combine() {
doc.add_feature(
FeatureKind::Cylinder {
base: point_expr(Vec3::new(5.0, 5.0, 0.0)),
axis: Vec3::Z,
axis: Dir(Vec3::Z),
radius: "2".into(),
height: "20".into(),
operation: Operation::NewBody,
+83 -21
View File
@@ -13,13 +13,13 @@ pub(crate) use crate::expr::Kind;
pub(crate) use crate::feature_geometry::Geo;
pub(crate) use crate::feature_refs::Ref;
pub(crate) use crate::params::ExprRole;
use crate::{PlaneRef, PointExpr};
use crate::{BodyName, Dir, Expr, FacePick, FeatureName, PlaneRef, PointExpr, Position, SketchId};
/// An expression: its label, the expression, its kind and its role.
pub(crate) type ExprItem<'a> = (&'static str, &'a String, Kind, ExprRole);
pub(crate) type ExprItemMut<'a> = (&'static str, &'a mut String, Kind, ExprRole);
pub(crate) type RefItem<'a> = Ref<&'a u64, &'a String>;
pub(crate) type RefItemMut<'a> = Ref<&'a mut u64, &'a mut String>;
pub(crate) type ExprItem<'a> = (&'static str, &'a Expr, Kind, ExprRole);
pub(crate) type ExprItemMut<'a> = (&'static str, &'a mut Expr, Kind, ExprRole);
pub(crate) type RefItem<'a> = Ref<&'a SketchId, &'a FeatureName, &'a BodyName>;
pub(crate) type RefItemMut<'a> = Ref<&'a mut SketchId, &'a mut FeatureName, &'a mut BodyName>;
pub(crate) type GeoItem<'a> = Geo<&'a Vec3, &'a PlaneRef, &'a GeoRef, &'a PointExpr, &'a ProfileSel>;
pub(crate) type GeoItemMut<'a> = Geo<&'a mut Vec3, &'a mut PlaneRef, &'a mut GeoRef, &'a mut PointExpr, &'a mut ProfileSel>;
@@ -40,8 +40,11 @@ impl Walk for bool {}
impl Walk for u64 {}
impl Walk for i8 {}
impl Walk for f64 {}
impl Walk for usize {}
impl Walk for u32 {}
impl Walk for ProfileSel {}
impl Walk for solidcraft_sketch::Sketch {}
impl Walk for solidcraft_sketch::PatternAlignment {}
impl Walk for solidcraft_kernel::StepNode {}
impl Walk for crate::document::Operation {}
impl Walk for solidcraft_kernel::Continuity {}
@@ -56,6 +59,52 @@ impl Walk for crate::document::RuledMode {}
impl Walk for crate::document::RuledCorners {}
impl Walk for crate::document::ExtrudeConvention {}
/// A reference walks itself: a sketch, a feature or a body.
macro_rules! reference {
($t:ty, $role:ident) => {
impl Walk for $t {
fn walk_refs<'a>(&'a self, f: &mut dyn FnMut(RefItem<'a>)) {
f(Ref::$role(self))
}
fn walk_refs_mut<'a>(&'a mut self, f: &mut dyn FnMut(RefItemMut<'a>)) {
f(Ref::$role(self))
}
}
};
}
reference!(SketchId, Sketch);
reference!(FeatureName, Feature);
reference!(BodyName, Body);
/// A point walks itself with its role: a face pick, a position or a direction. As a plain
/// `Vec3` it also serves a field whose attribute gives it another role (`pinned`, `pick_if`).
macro_rules! point {
($t:ty, $role:ident) => {
impl Walk for $t {
fn walk_geometry<'a>(&'a self, f: &mut dyn FnMut(GeoItem<'a>)) {
f(Geo::$role(&self.0))
}
fn walk_geometry_mut<'a>(&'a mut self, f: &mut dyn FnMut(GeoItemMut<'a>)) {
f(Geo::$role(&mut self.0))
}
}
impl Each<Vec3> for $t {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a Vec3)) {
f(&self.0)
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut Vec3)) {
f(&mut self.0)
}
}
};
}
point!(FacePick, Pick);
point!(Position, Pos);
point!(Dir, Dir);
/// Containers walk what they hold, in order.
macro_rules! walk_each {
($([$($g:tt)*] $t:ty),*) => {$(
@@ -182,36 +231,36 @@ impl<T> Each<T> for Option<Vec<T>> {
/// The expressions of an expression field, each with its index and their count (for its label).
pub(crate) trait ExprField {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String));
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String));
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a Expr));
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut Expr));
}
impl ExprField for String {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
impl ExprField for Expr {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a Expr)) {
f(0, 1, self)
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut Expr)) {
f(0, 1, self)
}
}
/// A position along an edge with its radius (a variable fillet's mid radii).
impl ExprField for (f64, String) {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
impl ExprField for (f64, Expr) {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a Expr)) {
f(0, 1, &self.1)
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut Expr)) {
f(0, 1, &mut self.1)
}
}
impl<T: ExprField> ExprField for Option<T> {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a Expr)) {
if let Some(t) = self {
t.each(f)
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut Expr)) {
if let Some(t) = self {
t.each_mut(f)
}
@@ -222,13 +271,13 @@ impl<T: ExprField> ExprField for Option<T> {
macro_rules! expr_list {
($([$($g:tt)*] $t:ty),*) => {$(
impl<$($g)*> ExprField for $t {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a Expr)) {
let n = self.len();
for (i, t) in self.iter().enumerate() {
t.each(&mut |_, _, e| f(i, n, e));
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut Expr)) {
let n = self.len();
for (i, t) in self.iter_mut().enumerate() {
t.each_mut(&mut |_, _, e| f(i, n, e));
@@ -264,8 +313,9 @@ impl<F: Fn(usize, usize) -> &'static str> Label for F {
}
}
/// The offsets and angles of a plane, outermost first (nesting is capped like every walk over a
/// plane), the construction plane it is built on, and the plane itself.
/// The offsets and angles of a plane, outermost first, and an explicit origin's coordinates
/// (nesting is capped like every walk over a plane), the construction plane it is built on, and
/// the plane itself.
impl Walk for PlaneRef {
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
let mut p = self;
@@ -280,6 +330,12 @@ impl Walk for PlaneRef {
p = base;
}
PlaneRef::AlongPath { t, .. } => return f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
PlaneRef::Placed { origin, .. } => {
for (label, e) in ["X", "Y", "Z"].into_iter().zip(origin) {
f((label, e, Kind::Length, ExprRole::Placement));
}
return;
}
_ => return,
}
}
@@ -297,6 +353,12 @@ impl Walk for PlaneRef {
p = base;
}
PlaneRef::AlongPath { t, .. } => return f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
PlaneRef::Placed { origin, .. } => {
for (label, e) in ["X", "Y", "Z"].into_iter().zip(origin) {
f((label, e, Kind::Length, ExprRole::Placement));
}
return;
}
_ => return,
}
}
@@ -305,7 +367,7 @@ impl Walk for PlaneRef {
let mut p = self;
for _ in 0..64 {
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => p = base,
PlaneRef::Construction { name } => return f(Ref::Feature(name)),
_ => return,
}
@@ -315,7 +377,7 @@ impl Walk for PlaneRef {
let mut p = self;
for _ in 0..64 {
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => p = base,
PlaneRef::Construction { name } => return f(Ref::Feature(name)),
_ => return,
}
@@ -288,3 +288,27 @@ fn cut_of_a_whole_face_beside_round_corners_after_a_chamfer() {
let top = mesh.bounds().max.z;
assert!((top - 15.0).abs() < 1e-6, "top at {top}");
}
/// The display tessellation of loft bodies joined into a bike frame plans at every display
/// quality and every refinement step. The edge nodes on an intersection of two lofts once
/// stepped back along the curve (the edge's fit rang where truck's parameter changes speed, and
/// took a leader point off the curve past a vertex), so a face's boundary crossed itself and
/// the body fell back to the reference mesher, which drew a sheet across the down tube.
#[test]
fn joined_loft_frames_plan_their_display_at_every_quality() {
use solidcraft_doc::display_quality::{DisplayQuality, MAX_REFINE};
for (script, name) in [
(include_str!("../../../examples/regressions/loft_join_bracket_hull_triangle.json"), "DT2"),
(include_str!("../../../examples/regressions/front_triangle_hull_triangle.json"), "FrontTriangle"),
] {
let s = replay(script);
let state = s.model.state();
let body = &state.body(name).unwrap().body;
for q in DisplayQuality::ALL {
for k in 0..=MAX_REFINE {
let plan = body.display_plan_angular(q.refined_chord(body.size(), k), q.refined_angle(k));
assert!(plan.is_ok(), "{name} at {} + {k}: {:?}", q.id(), plan.err());
}
}
}
}
+114
View File
@@ -0,0 +1,114 @@
//! How headless pictures of the model are drawn: a renderer the host installs. The engine
//! describes what a picture shows ([`crate::view::scene`]); drawing it is a GPU job the engine
//! cannot do itself (the GPU crate sits above it), so the application, the CLI and the MCP
//! server install one ([`install`]) that draws with the viewport's own pipelines (or, as a last
//! resort the host chooses, the CPU rasterizer). Every picture names the renderer that drew it.
//! Without a renderer, a picture is an error and a save simply has no thumbnail.
use std::sync::{Arc, RwLock};
use solidcraft_render::{Camera, Scene};
/// A picture: straight RGBA, rows top to bottom, and the renderer that drew it (`gpu`,
/// `gpu-software` or `cpu`).
pub struct Picture {
pub rgba: Vec<u8>,
pub renderer: &'static str,
}
/// A picture as PNG bytes, and the renderer that drew it.
pub struct Png {
pub bytes: Vec<u8>,
pub renderer: &'static str,
}
/// What a reply says when the CPU rasterizer drew the picture.
pub const CPU_WARNING: &str = "drawn by the CPU rasterizer (no GPU adapter qualified, or SOLIDCRAFT_GPU=cpu): surface analysis colours, highlights and legends are not drawn; `solidcraft-cli gpu` lists the adapters";
/// The warning that goes with a picture drawn by `renderer`, if any.
pub fn warning(renderer: &str) -> Option<&'static str> {
(renderer == "cpu").then_some(CPU_WARNING)
}
/// Draws `scene` seen by `cam` at `width` × `height` pixels (`width * height * 4` bytes of RGBA),
/// or says why it could not.
pub type Renderer = dyn Fn(&Scene, &Camera, u32, u32) -> Result<Picture, String> + Send + Sync;
static RENDERER: RwLock<Option<Arc<Renderer>>> = RwLock::new(None);
/// Make `renderer` draw this process's headless pictures (replacing any installed before).
pub fn install(renderer: Arc<Renderer>) {
if let Ok(mut r) = RENDERER.write() {
*r = Some(renderer);
}
}
/// The installed renderer, if any.
pub fn installed() -> Option<Arc<Renderer>> {
RENDERER.read().ok().and_then(|r| r.clone())
}
/// `scene` drawn by the installed renderer.
pub fn rgba(scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Picture, String> {
rgba_with(installed().as_deref(), scene, cam, width, height)
}
/// `scene` drawn by the installed renderer, as PNG.
pub fn png(scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Png, String> {
png_with(installed().as_deref(), scene, cam, width, height)
}
/// [`rgba`] with `renderer` instead of the installed one.
pub fn rgba_with(renderer: Option<&Renderer>, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Picture, String> {
let renderer = renderer.ok_or("no renderer is installed to draw pictures in this process")?;
let picture = renderer(scene, cam, width, height)?;
if picture.rgba.len() as u64 != u64::from(width) * u64::from(height) * 4 {
return Err(format!("the renderer returned {} bytes for {width} × {height} pixels", picture.rgba.len()));
}
if let Some(w) = warning(picture.renderer) {
log::warn!("{w}");
}
Ok(picture)
}
/// [`png`] with `renderer` instead of the installed one.
pub fn png_with(renderer: Option<&Renderer>, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Png, String> {
let Picture { rgba, renderer } = rgba_with(renderer, scene, cam, width, height)?;
let bytes = solidcraft_render::png(width as usize, height as usize, rgba).ok_or_else(|| "PNG encoding failed".to_string())?;
Ok(Png { bytes, renderer })
}
/// For tests: a stand-in for the renderer the hosts install, a flat picture whose first byte
/// counts the scene's meshes.
#[cfg(test)]
pub(crate) fn install_test_renderer() {
install(Arc::new(|scene: &Scene, _: &Camera, w: u32, h: u32| {
let mut rgba = vec![200; (w * h * 4) as usize];
if let Some(first) = rgba.first_mut() {
*first = scene.meshes.len() as u8;
}
Ok(Picture { rgba, renderer: "gpu" })
}));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn without_a_renderer_a_picture_is_an_error_not_a_crash() {
let e = png_with(None, &Scene::default(), &Camera::default(), 16, 16).err().unwrap();
assert!(e.contains("no renderer"), "{e}");
}
#[test]
fn a_renderer_that_returns_the_wrong_size_is_an_error() {
let short: &Renderer = &|_, _, _, _| Ok(Picture { rgba: vec![0; 3], renderer: "gpu" });
assert!(rgba_with(Some(short), &Scene::default(), &Camera::default(), 2, 2).is_err());
let plain: &Renderer = &|_, _, w, h| Ok(Picture { rgba: vec![255; (w * h * 4) as usize], renderer: "cpu" });
let png = png_with(Some(plain), &Scene::default(), &Camera::default(), 2, 2).unwrap();
assert!(png.bytes.starts_with(b"\x89PNG"));
assert_eq!(png.renderer, "cpu");
assert_eq!((warning("cpu"), warning("gpu"), warning("gpu-software")), (Some(CPU_WARNING), None, None));
}
}
+1 -1
View File
@@ -837,7 +837,7 @@ mod tests {
solidcraft_doc::FeatureKind::Move { bodies, .. } => Some(bodies.clone()),
_ => None,
});
assert_eq!(moved, Some(vec!["Lid".to_string()]), "the move follows the rename");
assert_eq!(moved, Some(vec![solidcraft_doc::BodyName::from("Lid")]), "the move follows the rename");
assert!(s.model.results.iter().all(|r| r.error.is_none()));
}
+1 -1
View File
@@ -45,7 +45,7 @@ fn explicit_planes(p: &mut PlaneRef) {
*p = PlaneRef::Custom { plane };
}
}
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => explicit_planes(base),
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => explicit_planes(base),
_ => {}
}
}
+6 -5
View File
@@ -1,4 +1,5 @@
//! Independent copies of a component definition graph, assembled and evaluated atomically.
use solidcraft_doc::BodyName;
use std::collections::{BTreeMap, BTreeSet};
use crate::params::bad;
@@ -327,7 +328,7 @@ pub(super) fn paste(
let entry = feats.iter().find(|f| {
!f.suppressed
&& matches!(&f.kind, FeatureKind::BodyTransfer { bodies, from, to, .. }
if !selected.contains(from) && bodies.contains(&body.name)
if !selected.contains(from) && bodies.iter().any(|b| *b == body.name)
&& s.model.result(f.id).is_some_and(|result| !result.skipped && result.error.is_none()
&& result.state.body(&body.name).is_some_and(|entered| source.model_body_component(entered) == *to)))
});
@@ -426,7 +427,7 @@ pub(super) fn paste(
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
&& !face.path.is_empty()
&& let FeatureKind::Emboss { face: Some(old_face), .. } = &old.kind
&& bodies.contains_key(&old_face.body)
&& bodies.contains_key(old_face.body.as_ref())
{
face.path = cloned_path(&source, &old_face.path, component, &clone_path, &occurrences)?;
}
@@ -483,7 +484,7 @@ pub(super) fn paste(
.collect();
if !moved_out.is_empty() {
let name = unique("Assigned-out bodies", &mut used);
let id = scratch.doc_mut().add_feature(FeatureKind::Remove { bodies: moved_out }, Some(&name))?;
let id = scratch.doc_mut().add_feature(FeatureKind::Remove { bodies: moved_out.into_iter().map(BodyName).collect() }, Some(&name))?;
if let Some(f) = scratch.doc_mut().feature_mut(id) {
f.component = root;
}
@@ -526,7 +527,7 @@ pub(super) fn paste(
for feature in &feats {
if let FeatureKind::Emboss { face: Some(face), .. } = &feature.kind
&& !face.path.is_empty()
&& !bodies.contains_key(&face.body)
&& !bodies.contains_key(face.body.as_ref())
{
notices.push(format!("{} retains external selected occurrence path {:?}", feature.name, face.path));
}
@@ -588,7 +589,7 @@ pub(super) fn paste(
}
if let FeatureKind::Pattern { features: references, .. } | FeatureKind::Mirror { features: references, .. } = &feature.kind {
for name in references {
if !names.contains_key(name) {
if !names.contains_key(name.as_ref()) {
notices.push(format!("{} retains external feature reference {name}", feature.name));
}
}
@@ -3,7 +3,7 @@
use std::collections::{BTreeMap, BTreeSet};
use solidcraft_doc::Document;
use solidcraft_doc::joints::{Assembly, JointOrigin, Snap};
use solidcraft_doc::joints::{Assembly, Coupling, JointOrigin, Size, Snap};
use crate::{EngineError, Result};
@@ -105,6 +105,14 @@ fn budget(assembly: &Assembly) -> Result<()> {
if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite()) {
return Err(invalid("motion link contains non-finite values"));
}
for size in assembly.links.iter().filter_map(|l| l.coupling.as_ref()).flat_map(Coupling::sizes) {
match size {
Size::Length(v) if !v.is_finite() => return Err(invalid("motion link contains non-finite values")),
Size::Expr(e) => text(e)?,
Size::Circle(o) => finite_origin(o)?,
Size::Length(_) => {}
}
}
if references > MAX_REFERENCES {
return Err(invalid("assembly references exceed the processing budget"));
}
@@ -291,9 +299,16 @@ pub(super) fn clone_assembly(
if !touched_joints.contains(&old.a) && !touched_joints.contains(&old.b) {
continue;
}
let mut item = old.clone();
// A gear's picked round faces are on occurrences of the copy too.
let faces = item.coupling.as_mut().is_none_or(|c| {
c.sizes_mut().into_iter().all(|size| match size {
Size::Circle(o) => origin(o, occurrences, features, bodies).map(|new| *o = new).is_some(),
_ => true,
})
});
match (joints.get(&old.a), joints.get(&old.b)) {
(Some(aa), Some(bb)) => {
let mut item = old.clone();
(Some(aa), Some(bb)) if faces => {
item.a = *aa;
item.b = *bb;
a.links.push(item);
@@ -1,6 +1,6 @@
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
use solidcraft_doc::joints::{Joint, JointKind, MotionLink, NamedOrigin};
use solidcraft_doc::joints::{Coupling, Joint, JointKind, MotionLink, NamedOrigin, Size};
use solidcraft_doc::motion::{ContactSet, ExplodedView, MotionStudy, StudyKey};
use solidcraft_doc::{IDENTITY, Occurrence};
use solidcraft_geom::Vec3;
@@ -43,8 +43,10 @@ fn documents() -> (Document, Document) {
},
NamedOrigin { name: "Outside".into(), origin: at(9) },
];
source.assembly.links =
vec![MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3 }, MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0 }];
source.assembly.links = vec![
MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3, coupling: None },
MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0, coupling: None },
];
source.assembly.contact_sets = vec![
ContactSet { id: 1, name: "Internal contact".into(), occurrences: vec![1, 2], suppressed: true },
ContactSet { id: 2, name: "Cross contact".into(), occurrences: vec![1, 9], suppressed: false },
@@ -96,6 +98,29 @@ fn internal_relationships_receive_independent_ids_and_remapped_typed_references(
assert_eq!(source.assembly.joints[0].values[0], 0.25);
}
/// A gear's picked round face is on the copy's occurrence and face; one picked on geometry the
/// copy doesn't have is reported, not pointed back at the original.
#[test]
fn copied_gears_take_their_picked_faces_along() {
let (mut source, _) = documents();
let circle = |face: &str| Size::Circle(JointOrigin { occurrence: 1, snap: Snap::CircleCenter { pick: Vec3::X, face: Some(face.into()) } });
let gear = |ra| Coupling::Gear { ra, rb: Size::Length(5.0), internal: false, reverse: false };
source.assembly.links = vec![
MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.0, coupling: Some(gear(circle("F1:side"))) },
MotionLink { a: 2, ia: 0, b: 1, ib: 0, ratio: -0.5, offset: 0.0, coupling: Some(gear(circle("F99:side"))) },
];
let mut destination = source.clone();
destination.occurrences.extend([101, 102].map(occurrence));
let report =
clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new())
.unwrap();
let copied = destination.assembly.links.get(2).and_then(|l| l.coupling.clone());
let Some(Coupling::Gear { ra: Size::Circle(o), .. }) = copied else { panic!("{:?}", destination.assembly.links) };
assert!(o.occurrence == 101 && matches!(&o.snap, Snap::CircleCenter { face: Some(f), .. } if f == "F11:side"), "{o:?}");
assert_eq!(destination.assembly.links.len(), 3, "the gear on foreign geometry stays behind");
assert!(report.iter().any(|m| m.contains("motion link")), "{report:?}");
}
#[test]
fn naming_remaps_exact_tokens_unicode_and_legacy_body_identity() {
let features = BTreeMap::from([(1, 21), (10, 30)]);
@@ -376,7 +376,7 @@ fn generated_copy_parameters_cannot_heal_an_unrelated_missing_reference() {
let feature = s.doc_mut().feature_mut(broken).unwrap();
feature.component = 0;
if let solidcraft_doc::FeatureKind::Box { width, .. } = &mut feature.kind {
*width = missing;
*width = missing.into();
}
s.refresh();
let original_error = s.model.result(broken).unwrap().error.clone();
+2 -1
View File
@@ -1,5 +1,6 @@
//! Atomic timeline ownership changes, preserving the selected world geometry.
use super::*;
use solidcraft_doc::BodyName;
use std::collections::{BTreeMap, BTreeSet};
type UniqueFrames = BTreeMap<u64, Option<(Vec<u64>, Mat)>>;
@@ -83,7 +84,7 @@ fn transfer(s: &mut Session, name: &str, to: u64, frames: &UniqueFrames, cmd: &s
if let Some(look) = inherited {
s.doc_mut().appearances.bodies.insert(name.to_string(), look);
}
let id = s.doc_mut().add_feature(FeatureKind::BodyTransfer { bodies: vec![name.to_string()], from, to, transform }, None)?;
let id = s.doc_mut().add_feature(FeatureKind::BodyTransfer { bodies: vec![BodyName::from(name)], from, to, transform }, None)?;
if let Some(feature) = s.doc_mut().feature_mut(id) {
feature.component = to;
}
+2 -2
View File
@@ -135,8 +135,8 @@ fn geo(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result<GeoRef>
Ok(g)
}
fn t_param(s: &Session, p: &Value, cmd: &str) -> Result<String> {
let t = if p.get("t").is_some() { req_expr(cmd, p, "t")? } else { "0.5".to_string() };
fn t_param(s: &Session, p: &Value, cmd: &str) -> Result<solidcraft_doc::Expr> {
let t = if p.get("t").is_some() { req_expr(cmd, p, "t")? } else { "0.5".into() };
check_expr(s, &t, solidcraft_doc::expr::Kind::Unitless, cmd, "t")?;
Ok(t)
}
+137 -7
View File
@@ -546,6 +546,129 @@ fn merge(dst: &mut Value, src: &Value, depth: usize) {
}
}
/// One step of a path into a feature's JSON definition.
#[derive(Clone)]
enum Step {
Key(String),
Index(usize),
}
fn path_text(path: &[Step]) -> String {
let mut t = String::new();
for s in path {
match s {
Step::Key(k) if t.is_empty() => t.push_str(k),
Step::Key(k) => t.push_str(&format!(".{k}")),
Step::Index(i) => t.push_str(&format!("[{i}]")),
}
}
t
}
fn at_path<'a>(v: &'a Value, path: &[Step]) -> Option<&'a Value> {
path.iter().try_fold(v, |v, s| match s {
Step::Key(k) => v.get(k),
Step::Index(i) => v.get(i),
})
}
/// Does the stored feature read the field at `path` of `merged` (a feature's JSON with the edit
/// merged in)? serde skips a field it doesn't know whatever its value, but no field type takes a
/// flag, a text and nested lists alike.
fn reads_field(merged: &Value, path: &[Step]) -> bool {
let Some((last, parent)) = path.split_last() else { return true };
[json!(true), json!("\u{1}"), json!([[[]]])].into_iter().any(|probe| {
let mut v = merged.clone();
let slot = parent.iter().try_fold(&mut v, |v, s| match s {
Step::Key(k) => v.get_mut(k),
Step::Index(i) => v.get_mut(*i),
});
match (slot, last) {
(Some(Value::Object(o)), Step::Key(k)) => {
o.insert(k.clone(), probe);
}
(Some(Value::Array(a)), Step::Index(i)) => match a.get_mut(*i) {
Some(x) => *x = probe,
None => return false,
},
_ => return false,
}
serde_json::from_value::<Feature>(v).is_err()
})
}
/// The first field of `set` that the feature's definition doesn't have. A field the definition
/// shows before or after the edit is known; one it leaves out (an unset option, a value at its
/// default) is known when the stored feature reads it. Setting an absent field to `null`
/// changes nothing either way and passes.
fn unknown_field(
set: &Value,
before: Option<&Value>,
after: Option<&Value>,
merged: &Value,
path: &mut Vec<Step>,
depth: usize,
) -> Option<Vec<Step>> {
if depth > 16 {
return None;
}
let check = |step: Step, v: &Value, path: &mut Vec<Step>| {
let one = [step];
let (b, a) = (before.and_then(|x| at_path(x, &one)), after.and_then(|x| at_path(x, &one)));
let [step] = one;
path.push(step);
let found = if b.is_none() && a.is_none() && !v.is_null() && !reads_field(merged, path) {
Some(path.clone())
} else {
unknown_field(v, b, a, merged, path, depth + 1)
};
path.pop();
found
};
match set {
Value::Object(o) => o.iter().find_map(|(k, v)| check(Step::Key(k.clone()), v, path)),
Value::Array(items) => items.iter().enumerate().take(10_000).find_map(|(i, v)| check(Step::Index(i), v, path)),
_ => None,
}
}
/// "unknown field `offset`; ConstructionPlane has `plane.base.name`, `plane.distance`…": the
/// editable fields where it was given, as paths.
fn unknown_field_message(field: &[Step], def: &Value, type_name: &str) -> String {
fn leaves(v: &Value, prefix: &str, top: bool, depth: usize, out: &mut Vec<String>) {
match v {
Value::Object(o) if depth < 4 && !o.is_empty() => {
for (k, x) in o {
if top && NOT_EDITABLE.contains(&k.as_str()) {
continue;
}
let at = if prefix.is_empty() { k.clone() } else { format!("{prefix}.{k}") };
leaves(x, &at, false, depth + 1, out);
}
}
_ if !prefix.is_empty() => out.push(format!("`{prefix}`")),
_ => {}
}
}
let parent = field.split_last().map(|(_, p)| p).unwrap_or_default();
let mut known = Vec::new();
if let Some(at) = at_path(def, parent) {
leaves(at, &path_text(parent), parent.is_empty(), 0, &mut known);
}
const SHOWN: usize = 40;
let more = known.len().saturating_sub(SHOWN);
known.truncate(SHOWN);
let mut list = known.join(", ");
if more > 0 {
list.push_str(&format!(" and {more} more"));
}
let field = path_text(field);
if list.is_empty() {
return format!("unknown field `{field}`; timeline.inspect lists {type_name}'s fields");
}
format!("unknown field `{field}`; {type_name} has {list} (timeline.inspect shows them with their values)")
}
/// `profiles` (also a loft section's) given as the feature commands take it (`"all"`,
/// `[index]`, `[{curves}]`, `[{point}]`...) becomes the stored form; the stored form passes.
fn command_profiles(set: &Value, cmd: &str) -> Result<Value> {
@@ -624,7 +747,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
let mut v = serde_json::to_value(&f).map_err(|e| EngineError::Other(e.to_string()))?;
let set = &command_profiles(set, cmd)?;
merge(&mut v, set, 0);
let mut nf: Feature = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid feature: {e}")))?;
let mut nf: Feature = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("invalid feature: {e}")))?;
nf.id = f.id;
if let (FeatureKind::Extrude { extent: old, .. }, FeatureKind::Extrude { extent: new, .. }) = (&f.kind, &mut nf.kind)
&& old.direction != solidcraft_doc::Direction::Symmetric
@@ -636,8 +759,15 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
if std::mem::discriminant(&nf.kind) != std::mem::discriminant(&f.kind) {
return Err(bad(cmd, "the feature type cannot change"));
}
// serde drops fields the definition doesn't have without a word: refuse them, or a typo
// would report success and change nothing.
let after = serde_json::to_value(&nf).map_err(|e| EngineError::Other(e.to_string()))?;
let before = serde_json::to_value(&f).map_err(|e| EngineError::Other(e.to_string()))?;
if let Some(field) = unknown_field(set, Some(&before), Some(&after), &v, &mut Vec::new(), 0) {
return Err(bad(cmd, unknown_field_message(&field, &before, f.kind.type_name())));
}
if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &nf.kind {
solidcraft_doc::validate_body_transfer(&s.doc, bodies, *from, *to, transform)?;
solidcraft_doc::validate_body_transfer(&s.doc, &solidcraft_doc::plain(bodies), *from, *to, transform)?;
nf.component = *to;
}
if let (FeatureKind::Sketch { sketch: a, .. }, FeatureKind::Sketch { sketch: b, .. }) = (&nf.kind, &f.kind)
@@ -653,22 +783,22 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
&& a != b
{
let before = s.model.state_before(id);
nf.edge_refs = solidcraft_doc::edge_refs(&before, body, a);
nf.edge_names = solidcraft_doc::edge_names_for(&before, body, a);
nf.edge_refs = solidcraft_doc::edge_refs(&before, body, &solidcraft_doc::plain(a));
nf.edge_names = solidcraft_doc::edge_names_for(&before, body, &solidcraft_doc::plain(a));
}
// New or reordered flange edges: their names now, so each edge's own angle and height stay
// with the edge they were given for.
if let (FeatureKind::SheetFlange { edges: a, .. }, FeatureKind::SheetFlange { edges: b, .. }) = (&nf.kind, &f.kind) {
let points = |e: &[solidcraft_doc::FlangeEdge]| e.iter().map(|x| x.point).collect::<Vec<_>>();
if points(a) != points(b) {
nf.edge_names = solidcraft_doc::sheet::sheet_edge_names(&s.model.state_before(id), &points(a));
nf.edge_names = solidcraft_doc::sheet::sheet_edge_names(&s.model.state_before(id), &solidcraft_doc::plain(&points(a)));
}
}
// New bend edges for a conversion: the names of the body's edges there now.
if let (FeatureKind::SheetConvert { bends: a, body, .. }, FeatureKind::SheetConvert { bends: b, body: was, .. }) = (&nf.kind, &f.kind)
&& (a != b || body != was)
{
nf.edge_names = solidcraft_doc::sheet::bend_edge_names(&s.model.state_before(id), body, a);
nf.edge_names = solidcraft_doc::sheet::bend_edge_names(&s.model.state_before(id), body, &solidcraft_doc::plain(a));
}
// Profiles picked by index or point are kept by their curves, as when the feature was made.
if nf.kind.clone().profile_sels_mut() != f.kind.clone().profile_sels_mut() {
@@ -915,7 +1045,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
&& before.body == after.body
&& before.path == after.path
&& before.author_path == after.author_path
&& before.at.dist(after.at) < 1e-8
&& before.at.dist(after.at.0) < 1e-8
{
*after = before.clone();
}
+30
View File
@@ -492,3 +492,33 @@ fn timeline_folders_can_nest_and_reject_cycles() {
run(&mut s, "timeline.ungroup", json!({"group": root}));
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, None, "deleting a folder promotes its children");
}
/// A field the feature's definition doesn't have is refused with the fields it does have,
/// instead of reporting success and changing nothing (#1491).
#[test]
fn edit_refuses_unknown_fields() {
let mut s = Session::default();
run(&mut s, "construct.plane.offset", json!({"base": "XZ", "offset": 50, "name": "P"}));
let e = s.execute("timeline.edit", &json!({"feature": "P", "set": {"offset": "-50"}})).unwrap_err().to_string();
assert!(e.contains("unknown field `offset`") && e.contains("`plane.distance`"), "{e}");
let e = s.execute("timeline.edit", &json!({"feature": "P", "set": {"plane": {"dist": "-50"}}})).unwrap_err().to_string();
assert!(e.contains("unknown field `plane.dist`") && e.contains("`plane.distance`"), "{e}");
let def = run(&mut s, "timeline.inspect", json!({"feature": "P"}))["definition"].clone();
assert_eq!(def["plane"]["distance"], json!("50"), "nothing changed: {def}");
// The field it does have edits as before.
run(&mut s, "timeline.edit", json!({"feature": "P", "set": {"plane": {"distance": "-50"}}}));
let def = run(&mut s, "timeline.inspect", json!({"feature": "P"}))["definition"].clone();
assert_eq!(def["plane"]["distance"], json!("-50"), "{def}");
// Fields the definition leaves out at their default are still fields: `combine: false`
// and `bodies: []` aren't shown on a feature mirror, and set them as they are.
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
run(&mut s, "solid.mirror", json!({"features": ["Base"], "plane": "YZ", "name": "Twin"}));
run(&mut s, "timeline.edit", json!({"feature": "Twin", "set": {"combine": false, "bodies": []}}));
run(&mut s, "timeline.edit", json!({"feature": "Twin", "set": {"combine": true}}));
// An unknown field next to known ones refuses the whole edit.
let e = s.execute("timeline.edit", &json!({"feature": "Twin", "set": {"combine": false, "flip": true}})).unwrap_err().to_string();
assert!(e.contains("unknown field `flip`") && e.contains("`combine`") && e.contains("`plane.name`"), "{e}");
let def = run(&mut s, "timeline.inspect", json!({"feature": "Twin"}))["definition"].clone();
assert_eq!(def["combine"], json!(true), "{def}");
}
+41
View File
@@ -125,3 +125,44 @@ fn face_join_of_a_step_import_keeps_all_32_neighbours_unchanged() {
let FeatureKind::Extrude { targets, .. } = &s.doc.feature(result["feature"].as_u64().unwrap()).unwrap().kind else { panic!("extrude") };
assert_eq!(targets, &["Wall"]);
}
/// #1349: a profile sketched on a body's face joins that body, and leaves alone a neighbour
/// whose face it lies on too: a rib on the side of a bracket, drawn down over the motor the
/// bracket sits on, welded the motor in (a join that ran for minutes on the imported motor and
/// failed). Explicit targets still join both.
#[test]
fn join_of_a_profile_sketched_on_a_face_leaves_a_neighbour_it_lies_on() {
for targets in [None, Some(json!(["Bracket", "Motor"]))] {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Motor"}));
run(&mut s, "solid.box", json!({"corner":[0,0,20],"length":40,"width":30,"height":5,"body_name":"Bracket"}));
let sketch = run(&mut s, "sketch.create", json!({"plane": {"face": [40, 15, 22.5]}}))["sketch"].as_u64().unwrap();
let plane = s.model.state().sketch(sketch).unwrap().plane;
let at = |y: f64, z: f64| {
let p = plane.to_local(Vec3::new(40.0, y, z));
json!([p.x, p.y])
};
// On the bracket's side and down over the motor's.
run(&mut s, "sketch.rectangle.two_point", json!({"p0": at(5.0, 10.0), "p1": at(25.0, 25.0)}));
run(&mut s, "sketch.finish", json!({}));
let mut params =
json!({"sketch": sketch, "profiles": [{"point": at(15.0, 22.5)}, {"point": at(15.0, 15.0)}], "distance": 1, "operation": "join"});
if let Some(t) = &targets {
params["targets"] = t.clone();
}
let result = run(&mut s, "solid.extrude", params);
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
let state = s.model.state();
let volume = |name: &str| solidcraft_kernel::measure(&state.body(name).unwrap().body).unwrap().volume;
if targets.is_some() {
assert_eq!(state.bodies.len(), 1);
assert!((volume("Bracket") - (24_000.0 + 6000.0 + 300.0)).abs() < 1e-6, "{}", volume("Bracket"));
continue;
}
assert_eq!(state.bodies.len(), 2);
assert!((volume("Motor") - 24_000.0).abs() < 1e-6, "the motor is untouched");
assert!((volume("Bracket") - (6000.0 + 300.0)).abs() < 1e-6, "{}", volume("Bracket"));
let FeatureKind::Extrude { targets, .. } = &s.doc.feature(result["feature"].as_u64().unwrap()).unwrap().kind else { panic!("extrude") };
assert_eq!(targets, &["Bracket"]);
}
}
+3 -3
View File
@@ -35,7 +35,7 @@ fn generated_face_profiles_keep_exact_links_live_after_edit_and_reopen() {
let result = run(&mut s, "solid.extrude", json!({"face":[5,5,20],"distance":10,"operation":"new","name":"Pad"}));
let pad = s.doc.feature(result["feature"].as_u64().unwrap()).unwrap();
let FeatureKind::Extrude { sketch, .. } = pad.kind else { panic!("extrude") };
let generated = s.doc.feature(sketch).unwrap();
let generated = s.doc.feature(sketch.0).unwrap();
let FeatureKind::Sketch { plane, sketch: profile } = &generated.kind else { panic!("sketch") };
let PlaneRef::Face { anchor: Some(anchor), .. } = plane else { panic!("the original Box cap remains exact after a hole cut") };
assert_eq!(anchor.target.origin_feature, base);
@@ -50,8 +50,8 @@ fn generated_face_profiles_keep_exact_links_live_after_edit_and_reopen() {
let edited = s.model.state();
assert!(edited.bodies.iter().any(|body| body.body.contains(Vec3::new(49.0, 5.0, 34.0))));
assert!(!edited.bodies.iter().any(|body| body.body.contains(Vec3::new(51.0, 5.0, 34.0))));
assert!(edited.sketch(sketch).unwrap().sketch.links.iter().all(|link| link.auto_face && !link.lost));
let solved = edited.sketch(sketch).unwrap().plane;
assert!(edited.sketch(sketch.0).unwrap().sketch.links.iter().all(|link| link.auto_face && !link.lost));
let solved = edited.sketch(sketch.0).unwrap().plane;
assert!((solved.origin.z - 25.0).abs() < 1e-6);
let before = s.model.state();
let before_volume: f64 = before.bodies.iter().map(|body| solidcraft_kernel::measure(&body.body).unwrap().volume).sum();
+263 -84
View File
@@ -2,8 +2,10 @@
use serde_json::{Value, json};
use solidcraft_doc::{
AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, SweepOrientation, ThinSide, ThinWall, expr::Kind, point_expr,
AxisRef, Direction, Expr, Extent, FeatureKind, Operation, PointExpr, ProfileSel, SweepOrientation, ThinSide, ThinWall, expr::Kind, point_expr,
};
use solidcraft_doc::{BodyName, FeatureName, SketchId};
use solidcraft_doc::{Dir, FacePick, Position};
use solidcraft_geom::Vec3;
use super::{CommandSpec, not_in_sketch};
@@ -15,7 +17,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("extrude")
.key("E")
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
@@ -39,16 +41,16 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
.at("SOLID", "CREATE")
.icon("pattern_rect")
.params("features: [names] | bodies: [names] | components: [names]; dir1: [x,y,z]; count1; spacing1; dir2?, count2?, spacing2?"),
.params("features: [names] | bodies: [names] | components: [names]; dir1: [x,y,z]; count1; spacing1; dir2?, count2?, spacing2?; alignment?, alignment2?: left|center|right (default right; counts include the original); reversed?, reversed2?: bool (Flip)"),
CommandSpec::new("solid.pattern.circular", "Circular Pattern", pattern_circ)
.at("SOLID", "CREATE")
.icon("pattern_circ")
.params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg)"),
.params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg; partial arcs include both ends); alignment?: left|center|right (default right; original stays fixed); reversed?: bool (Flip; selects the extra side for even centered counts)"),
CommandSpec::new("solid.pattern.path", "Pattern on Path", pattern_path)
.at("SOLID", "CREATE")
.icon("pattern_rect")
.params("features: [names] | bodies: [names]; path_sketch: sketch; path: [curve ids in order]; count; spacing (between instances) | distance (first to last); orientation?: identical|path; flip?: bool"),
CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ|construction plane name or id|{origin, normal}|{origin, x_dir, y_dir} (a construction plane keeps the mirror following it)"),
CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ|construction plane name or id|{origin, normal}|{origin, x_dir, y_dir} (origin coordinates may be length expressions; the mirror follows them, or a construction plane)"),
CommandSpec::new("solid.hole", "Hole", hole)
.at("SOLID", "CREATE")
.icon("hole")
@@ -103,7 +105,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.split_body", "Split Body", split_body)
.at("SOLID", "MODIFY")
.icon("split")
.params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended)"),
.params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id, line: curve id (a straight sketch line, along the sketch's normal)"),
CommandSpec::new("solid.press_pull", "Press Pull", press_pull)
.at("SOLID", "MODIFY")
.icon("presspull")
@@ -393,7 +395,7 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
if s.doc.units.trim() != "mm" {
let doc = s.doc.clone();
for (_, e, k) in kind.inputs_mut() {
*e = doc.with_design_unit(e, k);
*e = doc.with_design_unit(e, k).into();
}
}
// Profiles picked by index are kept by the curves around them: a recompute can renumber a
@@ -403,17 +405,20 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
let (refs, names) = match &kind {
FeatureKind::Fillet { edges, body, .. } | FeatureKind::Chamfer { edges, body, .. } => {
let st = s.model.state();
(solidcraft_doc::edge_refs(&st, body, edges), solidcraft_doc::edge_names_for(&st, body, edges))
(
solidcraft_doc::edge_refs(&st, body, &solidcraft_doc::plain(edges)),
solidcraft_doc::edge_names_for(&st, body, &solidcraft_doc::plain(edges)),
)
}
// Sheet metal edges are named by the sheet model.
FeatureKind::SheetFlange { edges, .. } => {
let points: Vec<_> = edges.iter().map(|e| e.point).collect();
(Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), &points))
(Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), &solidcraft_doc::plain(&points)))
}
FeatureKind::SheetHem { edges, .. } => (Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), edges)),
FeatureKind::SheetHem { edges, .. } => (Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), &solidcraft_doc::plain(edges))),
// A conversion's bend edges are edges of the body it converts.
FeatureKind::SheetConvert { body, bends, .. } if !bends.is_empty() => {
(Vec::new(), solidcraft_doc::sheet::bend_edge_names(&s.model.state(), body, bends))
(Vec::new(), solidcraft_doc::sheet::bend_edge_names(&s.model.state(), body, &solidcraft_doc::plain(bends)))
}
_ => (Vec::new(), Vec::new()),
};
@@ -424,7 +429,7 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
let body = state.body(&face.body).ok_or_else(|| EngineError::Other("selected cylinder body no longer exists".into()))?;
let frame = face.body_frame(&s.doc, body).map_err(|e| EngineError::Other(e.to_string()))?;
let inverse = solidcraft_doc::mat_inverse(&frame).ok_or_else(|| EngineError::Other("invalid cylinder placement".into()))?;
let at = solidcraft_doc::apply_point(&solidcraft_doc::mat_mul(&inverse, &s.active_component_frame()), face.at);
let at = solidcraft_doc::apply_point(&solidcraft_doc::mat_mul(&inverse, &s.active_component_frame()), face.at.0);
let mut scoped = solidcraft_doc::ModelState::default();
scoped.bodies.push(body.clone());
let names = solidcraft_doc::naming::face_names_at(&scoped, &[at]);
@@ -552,13 +557,16 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
};
let mut targets = string_list(p, "targets");
if operation == Operation::Join && targets.is_empty() && string_list(p, "participants").is_empty() {
// A face's outward extension joins that body. Assembly neighbours can touch only an
// edge of the tool: trying to join them first fails, or absorbs unrelated parts (#226).
// A face's outward extension joins that body, and so does a profile sketched on a body's
// face. Assembly neighbours can touch only an edge of the tool: trying to join them
// first fails, or absorbs unrelated parts (#226). A profile drawn past the face can lie
// on a neighbour's face too: a rib on a bracket's side welded in the motor the bracket
// holds, a join that ran for minutes and failed (#1349).
// Store the resolved target so recomputes and reopening keep the same scope.
targets.extend(face_body);
targets.extend(face_body.or_else(|| sketch_face_body(s, sketch)));
}
let kind = FeatureKind::Extrude {
sketch,
sketch: SketchId(sketch),
profiles: match face_profile {
Some(f) => f,
None if thin.is_some() => ProfileSel::All,
@@ -566,12 +574,23 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
},
extent,
operation,
targets,
targets: targets.into_iter().map(BodyName).collect(),
thin,
};
add_feature(s, p, kind)
}
/// The body whose face `sketch` is attached to (on the face itself, not offset from it), when
/// it is a body of the active component.
fn sketch_face_body(s: &Session, sketch: u64) -> Option<String> {
let FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch)?.kind else {
return None;
};
let st = s.model.state();
let body = st.body(&anchor.target.body)?;
(s.doc.model_body_component(body) == s.active_component).then(|| body.name.clone())
}
/// A thin extrude's wall from its parameters: the sketch's curves it follows (every drawn
/// curve when none are given), its thickness and which side of them it lies on.
fn thin_param(s: &Session, p: &Value, sketch: u64, cmd: &str) -> Result<ThinWall> {
@@ -579,7 +598,8 @@ fn thin_param(s: &Session, p: &Value, sketch: u64, cmd: &str) -> Result<ThinWall
let ss = st.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?;
let mut curves = string_list(p, "curves");
if curves.is_empty() {
curves = ss.sketch.curves.iter().filter(|c| !c.construction && !c.centerline && c.link.is_none()).map(|c| c.id.clone()).collect();
curves =
ss.sketch.curves.iter().filter(|c| !c.construction && !c.centerline && !c.infinite && c.link.is_none()).map(|c| c.id.clone()).collect();
}
if curves.is_empty() || curves.len() > 1000 {
return Err(bad(cmd, "a thin extrude needs 1…1000 sketch curves (`curves`)"));
@@ -670,9 +690,9 @@ pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result<Extent>
start_offset,
through_all,
taper,
to: p.get("to").and_then(vec3),
to: p.get("to").and_then(vec3).map(FacePick),
to_offset: expr(p, "to_offset"),
from: p.get("from").and_then(vec3),
from: p.get("from").and_then(vec3).map(FacePick),
})
}
@@ -709,16 +729,16 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`convention` must be sketch_normal or legacy_axis_start"))?,
};
let kind = FeatureKind::Revolve {
sketch,
sketch: SketchId(sketch),
profiles,
axis,
angle,
operation: operation(p, cmd)?,
targets: string_list(p, "targets"),
targets: string_list(p, "targets").into_iter().map(BodyName).collect(),
angle2,
symmetric,
convention,
to,
to: to.map(FacePick),
};
add_feature(s, p, kind)
}
@@ -729,7 +749,7 @@ pub(super) fn axis_param(p: &Value, cmd: &str) -> Result<AxisRef> {
if let Some(o) = p.get("axis").filter(|v| v.is_object()) {
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
let dir = o.get("dir").and_then(vec3).ok_or_else(|| bad(cmd, "axis needs `dir`"))?;
return Ok(AxisRef::Line { origin, dir });
return Ok(AxisRef::Line { origin: Position(origin), dir: Dir(dir) });
}
match str_(p, "axis") {
Some(a @ ("X" | "Y" | "Z")) => Ok(AxisRef::World { axis: a.into() }),
@@ -739,12 +759,12 @@ pub(super) fn axis_param(p: &Value, cmd: &str) -> Result<AxisRef> {
}
}
fn radius_expr(s: &Session, p: &Value, cmd: &str) -> Result<String> {
fn radius_expr(s: &Session, p: &Value, cmd: &str) -> Result<Expr> {
match (expr(p, "radius"), expr(p, "diameter")) {
(Some(r), _) => Ok(r),
(None, Some(d)) => {
let [d] = length_operands(s, [(&d, &typed_length_unit(s))]);
Ok(format!("({d}) / 2"))
Ok(format!("({d}) / 2").into())
}
_ => Err(bad(cmd, "needs `radius` or `diameter`")),
}
@@ -818,11 +838,13 @@ fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
(None, Some([cx, cy, cz])) => {
let unit = typed_length_unit(s);
let mm_per_unit = solidcraft_doc::expr::unit_info(&unit).map_or(1.0, |(_, k)| k);
let back = |c: String, size: &String| match (c.parse::<f64>(), size.parse::<f64>()) {
(Ok(a), Ok(b)) => format!("{}", a - b * mm_per_unit / 2.0),
_ => {
let [c, size] = length_operands(s, [(&c, "mm"), (size, &unit)]);
format!("({c}) - ({size}) / 2")
let back = |c: Expr, size: &Expr| -> Expr {
match (c.parse::<f64>(), size.parse::<f64>()) {
(Ok(a), Ok(b)) => format!("{}", a - b * mm_per_unit / 2.0).into(),
_ => {
let [c, size] = length_operands(s, [(&c, "mm"), (size, &unit)]);
format!("({c}) - ({size}) / 2").into()
}
}
};
[back(cx, &l), back(cy, &w), back(cz, &h)]
@@ -840,7 +862,7 @@ fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
check_expr(s, &height, Kind::Length, cmd, "height")?;
let base = point_param(s, p, "base", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
let axis = axis_dir_param(p, cmd)?;
add_feature(s, p, FeatureKind::Cylinder { base, axis, radius, height, operation: operation(p, cmd)? })
add_feature(s, p, FeatureKind::Cylinder { base, axis: Dir(axis), radius, height, operation: operation(p, cmd)? })
}
fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
@@ -953,8 +975,8 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
// Pushed in: the extrude cuts the other way by as much (a bare number stays one, so it keeps
// being read as a bare number typed into the extrude).
let flipped = match distance.trim().parse::<f64>() {
Ok(v) if solidcraft_doc::expr::is_bare_number(&distance) => format!("{}", -v),
_ => format!("-({distance})"),
Ok(v) if solidcraft_doc::expr::is_bare_number(&distance) => Expr::new(format!("{}", -v)),
_ => Expr::new(format!("-({distance})")),
};
let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (flipped, "negative", "cut") };
extrude(s, &json!({"face": face, "distance": dist, "direction": dir, "operation": op}))
@@ -988,10 +1010,10 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, "a `mid` radius is a number or an expression")),
};
check_expr(s, &r, Kind::Length, cmd, "mid radius")?;
mid.push((t, r));
mid.push((t, r.into()));
}
let smooth = p.get("smooth").and_then(Value::as_bool).unwrap_or(false);
solidcraft_doc::FilletStyle::Variable { radius2, start, mid, smooth }
solidcraft_doc::FilletStyle::Variable { radius2, start: Position(start), mid, smooth }
}
Some("spline") => {
let start = p
@@ -1007,7 +1029,7 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, "a control radius is a number or an expression")),
};
check_expr(s, &r, Kind::Length, cmd, "control radius")?;
radii.push(r);
radii.push(Expr::from(r));
}
// All control radii (`radius` and `radii`) plus the degree (1…3) plus one.
if radii.is_empty() || !(radii.len() + 3..=radii.len() + 5).contains(&knots.len()) {
@@ -1016,7 +1038,7 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
"a spline fillet needs `radii` (the control radii after `radius`) and `knots` (as many as all control radii, the degree 1…3 and one more)",
));
}
solidcraft_doc::FilletStyle::Spline { start, knots, radii }
solidcraft_doc::FilletStyle::Spline { start: Position(start), knots, radii }
}
Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord, variable, spline or curvature)"))),
};
@@ -1029,7 +1051,11 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
if !fit.is_exact() && !style.is_constant() {
return Err(bad(cmd, "`fit: reduce` and `fit: taper` take a constant radius"));
}
let mut out = add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string), style, fit })?;
let mut out = add_feature(
s,
p,
FeatureKind::Fillet { edges: edges.into_iter().map(Position).collect(), radius, body: str_(p, "body").map(BodyName::from), style, fit },
)?;
// The radii the fillet used, when `fit: reduce` reduced it.
if let Some(id) = out.get("feature").and_then(Value::as_u64)
&& let Some(state) = s.model.result(id).map(|res| &res.state)
@@ -1059,7 +1085,18 @@ fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "give `distance2` or `angle`, not both"));
}
let flip = bool_(p, "flip").unwrap_or(false);
add_feature(s, p, FeatureKind::Chamfer { edges, distance, distance2, angle, flip, body: str_(p, "body").map(str::to_string) })
add_feature(
s,
p,
FeatureKind::Chamfer {
edges: edges.into_iter().map(Position).collect(),
distance,
distance2,
angle,
flip,
body: str_(p, "body").map(BodyName::from),
},
)
}
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1076,7 +1113,12 @@ fn combine(s: &mut Session, p: &Value) -> Result<Value> {
let mut out = add_feature(
s,
p,
FeatureKind::Combine { target: target.clone(), tools, operation: op, keep_tools: bool_(p, "keep_tools").unwrap_or(false) },
FeatureKind::Combine {
target: target.clone().into(),
tools: tools.into_iter().map(BodyName).collect(),
operation: op,
keep_tools: bool_(p, "keep_tools").unwrap_or(false),
},
)?;
// New Component: the result goes into a component of its own.
if bool_(p, "new_component").unwrap_or(false) {
@@ -1096,7 +1138,7 @@ fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
}
let t = match p.get("translate") {
Some(Value::Array(a)) if a.len() == 3 => {
let mut out: [String; 3] = Default::default();
let mut out: [Expr; 3] = Default::default();
for (i, v) in a.iter().enumerate() {
let e = expr(&json!({"v": v}), "v").ok_or_else(|| bad(cmd, "translate entries must be numbers or expressions"))?;
check_expr(s, &e, Kind::Length, cmd, "translate")?;
@@ -1116,7 +1158,13 @@ fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(
s,
p,
FeatureKind::Move { bodies, translate: t, rotate_axis: p.get("axis").and_then(vec3), angle, copy: bool_(p, "copy").unwrap_or(false) },
FeatureKind::Move {
bodies: bodies.into_iter().map(BodyName).collect(),
translate: t,
rotate_axis: p.get("axis").and_then(vec3).map(Dir),
angle,
copy: bool_(p, "copy").unwrap_or(false),
},
)
}
@@ -1159,7 +1207,15 @@ fn axis_line(p: &Value, cmd: &str) -> Result<(Vec3, Vec3)> {
fn finish_pattern(s: &mut Session, p: &Value, features: Vec<String>, pattern: solidcraft_doc::PatternKind, bodies: Vec<String>) -> Result<Value> {
let comps = string_list(p, "components");
if comps.is_empty() {
return add_feature(s, p, FeatureKind::Pattern { features, pattern, bodies });
return add_feature(
s,
p,
FeatureKind::Pattern {
features: features.into_iter().map(FeatureName).collect(),
pattern,
bodies: bodies.into_iter().map(BodyName).collect(),
},
);
}
let cmd = "pattern (components)";
let mats = solidcraft_doc::pattern_matrices(&s.doc, &s.model.state(), &pattern)?;
@@ -1207,7 +1263,18 @@ fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(c) = &spacing2 {
check_expr(s, c, Kind::Length, cmd, "spacing2")?;
}
let pattern = solidcraft_doc::PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2 };
let pattern = solidcraft_doc::PatternKind::Rectangular {
dir1: Dir(dir1),
count1,
spacing1,
dir2: dir2.map(Dir),
count2,
spacing2,
alignment: super::alignment_param(p, "alignment", cmd)?,
alignment2: super::alignment_param(p, "alignment2", cmd)?,
reversed: super::reversed_param(p, "reversed", cmd)?,
reversed2: super::reversed_param(p, "reversed2", cmd)?,
};
finish_pattern(s, p, features, pattern, bodies)
}
@@ -1219,7 +1286,14 @@ fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
check_expr(s, &count, Kind::Unitless, cmd, "count")?;
let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into());
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
let pattern = solidcraft_doc::PatternKind::Circular { origin, axis, count, angle };
let pattern = solidcraft_doc::PatternKind::Circular {
origin: Position(origin),
axis: Dir(axis),
count,
angle,
alignment: super::alignment_param(p, "alignment", cmd)?,
reversed: super::reversed_param(p, "reversed", cmd)?,
};
finish_pattern(s, p, features, pattern, bodies)
}
@@ -1245,7 +1319,7 @@ fn pattern_path(s: &mut Session, p: &Value) -> Result<Value> {
Some(o) => return Err(bad(cmd, format!("unknown orientation `{o}` (identical or path)"))),
};
let flip = bool_(p, "flip").unwrap_or(false);
let pattern = solidcraft_doc::PatternKind::Path { path_sketch, path, count, spacing, extent, flip, orient };
let pattern = solidcraft_doc::PatternKind::Path { path_sketch: SketchId(path_sketch), path, count, spacing, extent, flip, orient };
finish_pattern(s, p, features, pattern, bodies)
}
@@ -1255,7 +1329,16 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() };
let plane = plane_param(s, p.get("plane"), cmd)?;
let combine = bool_(p, "combine").unwrap_or(false);
add_feature(s, p, FeatureKind::Mirror { features, plane, bodies, combine })
add_feature(
s,
p,
FeatureKind::Mirror {
features: features.into_iter().map(FeatureName).collect(),
plane,
bodies: bodies.into_iter().map(BodyName).collect(),
combine,
},
)
}
/// The other name of an origin plane (`Top` for `XY`, `XY` for `Top`...), if `name` is one.
@@ -1289,9 +1372,9 @@ pub(super) fn check_plane_name(name: &str, cmd: &str) -> Result<()> {
/// plane reads it here.
pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solidcraft_doc::PlaneRef> {
use solidcraft_doc::PlaneRef;
const WANT: &str = "XY, XZ, YZ, a construction plane name or id, or {origin, normal}";
const WANT: &str = "XY, XZ, YZ, a construction plane name or id, or {origin, normal} (origin coordinates may be length expressions)";
let construction = |r: &str| match s.doc.find_feature(r) {
Some(f) if matches!(f.kind, FeatureKind::ConstructionPlane { .. }) => Ok(PlaneRef::Construction { name: f.name.clone() }),
Some(f) if matches!(f.kind, FeatureKind::ConstructionPlane { .. }) => Ok(PlaneRef::Construction { name: f.name.clone().into() }),
Some(f) => Err(bad(cmd, format!("`{}` is not a construction plane (a plane is {WANT})", f.name))),
None => Err(bad(cmd, format!("no plane `{r}` (a plane is {WANT})"))),
};
@@ -1318,16 +1401,24 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
None => Err(bad(cmd, format!("`plane` must be {WANT}"))),
},
Some(o @ Value::Object(_)) => {
// The origin's coordinates are numbers or length expressions; expressions are kept,
// so the plane follows its parameters.
let origin = match o.get("origin") {
None => Vec3::ZERO,
Some(x) => vec3(x).ok_or_else(|| bad(cmd, "the plane's `origin` must be a point [x, y, z]"))?,
None => point_expr(Vec3::ZERO),
Some(_) => point_param(s, o, "origin", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO)),
};
let at = |e: &str| s.doc.eval(e, Kind::Length).unwrap_or(0.0);
let here = Vec3::new(at(&origin[0]), at(&origin[1]), at(&origin[2]));
let pl = match (o.get("x_dir").and_then(vec3), o.get("y_dir").and_then(vec3), o.get("normal").and_then(vec3)) {
(Some(x), Some(y), _) => solidcraft_geom::Plane::new(origin, x, y),
(_, _, Some(n)) => solidcraft_geom::Plane::from_normal(origin, n),
(Some(x), Some(y), _) => solidcraft_geom::Plane::new(here, x, y),
(_, _, Some(n)) => solidcraft_geom::Plane::from_normal(here, n),
_ => None,
};
Ok(PlaneRef::Custom { plane: pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))? })
let plane = pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))?;
if origin.iter().all(|e| solidcraft_doc::expr::is_literal(e)) {
return Ok(PlaneRef::Custom { plane });
}
Ok(PlaneRef::Placed { origin, x_dir: plane.x, y_dir: plane.y })
}
_ => Err(bad(cmd, format!("`plane` must be {WANT}"))),
}
@@ -1370,13 +1461,25 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
let kind = if let Some(t) = p.get("tool").filter(|t| !t.is_null()) {
let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`tool.body` must name a body"))?.to_string();
let point = t.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "`tool.point` must be [x, y, z] on the tool face"))?;
FeatureKind::SplitFace { faces, body, plane: xy(), tool: Some(solidcraft_doc::FaceAt { body: tb, point }), sketch: None }
FeatureKind::SplitFace {
faces: faces.into_iter().map(FacePick).collect(),
body: body.map(BodyName),
plane: xy(),
tool: Some(solidcraft_doc::FaceAt { body: BodyName(tb), point: Position(point) }),
sketch: None,
}
} else if p.get("sketch").is_some_and(|v| !v.is_null()) {
let sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?;
FeatureKind::SplitFace { faces, body, plane: xy(), tool: None, sketch: Some(sketch) }
FeatureKind::SplitFace {
faces: faces.into_iter().map(FacePick).collect(),
body: body.map(BodyName),
plane: xy(),
tool: None,
sketch: Some(SketchId(sketch)),
}
} else {
let plane = plane_param(s, p.get("plane"), cmd)?;
FeatureKind::SplitFace { faces, body, plane, tool: None, sketch: None }
FeatureKind::SplitFace { faces: faces.into_iter().map(FacePick).collect(), body: body.map(BodyName), plane, tool: None, sketch: None }
};
add_feature(s, p, kind)
}
@@ -1387,6 +1490,27 @@ fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
// A straight sketch line as the tool: `sketch` and `line` (#1552).
if p.get("sketch").is_some_and(|v| !v.is_null()) || p.get("line").is_some_and(|v| !v.is_null()) {
let sketch = p.get("sketch").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`sketch` must be a sketch feature id"))?;
let line = str_(p, "line").ok_or_else(|| bad(cmd, "`line` must name a line of the sketch"))?.to_string();
let st = s.model.state();
if st.sketch(sketch).is_none() {
return Err(bad(cmd, format!("no sketch {sketch}")));
}
solidcraft_doc::sketch_line_plane(&st, sketch, &line).map_err(|e| bad(cmd, e.to_string()))?;
return add_feature(
s,
p,
FeatureKind::Split {
body: BodyName(body),
plane: solidcraft_doc::PlaneRef::Origin { name: "XY".into() },
tool: None,
sketch: Some(solidcraft_doc::SketchId(sketch)),
line: Some(line),
},
);
}
// A face as the tool: `tool: {body, point}` (its surface extended); else a plane.
if let Some(t) = p.get("tool").filter(|t| !t.is_null()) {
let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`tool.body` must name a body"))?.to_string();
@@ -1400,14 +1524,16 @@ fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
s,
p,
FeatureKind::Split {
body,
body: BodyName(body),
plane: solidcraft_doc::PlaneRef::Origin { name: "XY".into() },
tool: Some(solidcraft_doc::FaceAt { body: tb, point }),
tool: Some(solidcraft_doc::FaceAt { body: BodyName(tb), point: Position(point) }),
sketch: None,
line: None,
},
);
}
let plane = plane_param(s, p.get("plane"), cmd)?;
add_feature(s, p, FeatureKind::Split { body, plane, tool: None })
add_feature(s, p, FeatureKind::Split { body: BodyName(body), plane, tool: None, sketch: None, line: None })
}
/// ISO 273 clearance hole diameters (close, normal, loose fit) for metric bolts.
@@ -1469,7 +1595,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
let first =
ids.first().and_then(|id| ss.sketch.points.iter().find(|q| &q.id == id)).map(|q| ss.plane.to_world(q.pos)).unwrap_or_default();
let flip = p.get("flip").and_then(Value::as_bool).unwrap_or(false);
Some((solidcraft_doc::SketchPoints { sketch, ids, flip }, first, -n))
Some((solidcraft_doc::SketchPoints { sketch: SketchId(sketch), ids, flip }, first, -n))
}
_ => None,
};
@@ -1503,11 +1629,11 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
(None, _, Some(c)) => {
let fit = str_(p, "fit").unwrap_or("normal").to_ascii_lowercase();
let d = clearance_diameter(c, &fit).ok_or_else(|| bad(cmd, format!("no clearance hole for `{c}` ({fit} fit): M1.6 to M64")))?;
format!("{d} mm")
Expr::new(format!("{d} mm"))
}
(None, Some(t), None) => {
let (major, pitch) = solidcraft_doc::parse_metric_thread(t).ok_or_else(|| bad(cmd, "thread"))?;
format!("{} mm", ((major - pitch) * 1000.0).round() / 1000.0)
Expr::new(format!("{} mm", ((major - pitch) * 1000.0).round() / 1000.0))
}
(None, None, None) => return Err(bad(cmd, "needs `diameter`, `thread` (a tapped hole) or `clearance` (a bolt size)")),
};
@@ -1516,7 +1642,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(d) = &depth {
check_expr(s, d, Kind::Length, cmd, "depth")?;
}
let get = |k: &str| -> Result<String> {
let get = |k: &str| -> Result<Expr> {
let e = req_expr(cmd, p, k)?;
Ok(e)
};
@@ -1547,7 +1673,20 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
{
p["participants"] = json!([body]);
}
add_feature(s, &p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread, to })
add_feature(
s,
&p,
FeatureKind::Hole {
position: FacePick(position),
direction: Dir(direction),
diameter,
depth,
hole: kind,
points: points.map(|x| x.0),
thread,
to: to.map(FacePick),
},
)
}
/// The body whose face is nearest `p` (within its pick tolerance), when it belongs to another
@@ -1604,9 +1743,9 @@ fn shell(s: &mut Session, p: &Value) -> Result<Value> {
s,
p,
FeatureKind::Shell {
faces,
faces: faces.into_iter().map(FacePick).collect(),
thickness,
body: str_(p, "body").map(str::to_string),
body: str_(p, "body").map(BodyName::from),
direction: if direction == "inside" { String::new() } else { direction },
tangent_chain: bool_(p, "tangent_chain").unwrap_or(false),
},
@@ -1622,7 +1761,17 @@ fn draft(s: &mut Session, p: &Value) -> Result<Value> {
let (vals, _) = s.doc.param_values();
let pl = s.doc.resolve_plane_in(&vals, &neutral, 0, Some(&s.model.state()))?;
let pull = p.get("pull").and_then(vec3).unwrap_or_else(|| pl.normal());
add_feature(s, p, FeatureKind::Draft { faces, angle, neutral, pull, body: str_(p, "body").map(str::to_string) })
add_feature(
s,
p,
FeatureKind::Draft {
faces: faces.into_iter().map(FacePick).collect(),
angle,
neutral,
pull: Dir(pull),
body: str_(p, "body").map(BodyName::from),
},
)
}
pub(super) fn sketch_id(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result<u64> {
@@ -1650,22 +1799,22 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
s,
p,
FeatureKind::Sweep {
sketch,
sketch: SketchId(sketch),
profiles: profiles(p, cmd)?,
path_sketch,
path_sketch: SketchId(path_sketch),
path,
orientation,
twist,
scale,
operation: operation(p, cmd)?,
targets: string_list(p, "targets"),
targets: string_list(p, "targets").into_iter().map(BodyName).collect(),
},
)
}
/// A sweep's `orientation`, `twist` and `scale` (solid.sweep and surface.sweep), checked: an
/// orientation the sweep knows, a twist within four turns, an end scale within 0.01…100.
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<String>, Option<String>)> {
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<Expr>, Option<Expr>)> {
let orientation = match p.get("orientation") {
None | Some(Value::Null) => SweepOrientation::Perpendicular,
Some(Value::String(o)) if o.eq_ignore_ascii_case("perpendicular") => SweepOrientation::Perpendicular,
@@ -1677,7 +1826,7 @@ pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepO
if !(d.len() > 1e-9) {
return Err(bad(cmd, "the `orientation` direction must not be zero"));
}
SweepOrientation::Direction(d)
SweepOrientation::Direction(Dir(d))
}
};
let twist = match p.get("twist") {
@@ -1721,12 +1870,20 @@ fn loft(s: &mut Session, p: &Value) -> Result<Value> {
if !st.sketch(sketch).is_some_and(|ss| ss.sketch.points.iter().any(|q| q.id == pt)) {
return Err(bad(cmd, format!("no point `{pt}` in the section's sketch")));
}
sections.push(solidcraft_doc::LoftSection { sketch, profiles: solidcraft_doc::ProfileSel::All, point: Some(pt.to_string()) });
sections.push(solidcraft_doc::LoftSection {
sketch: SketchId(sketch),
profiles: solidcraft_doc::ProfileSel::All,
point: Some(pt.to_string()),
});
continue;
}
sections.push(solidcraft_doc::LoftSection { sketch, profiles: profiles(sec, cmd)?, point: None });
sections.push(solidcraft_doc::LoftSection { sketch: SketchId(sketch), profiles: profiles(sec, cmd)?, point: None });
}
add_feature(s, p, FeatureKind::Loft { sections, operation: operation(p, cmd)?, targets: string_list(p, "targets") })
add_feature(
s,
p,
FeatureKind::Loft { sections, operation: operation(p, cmd)?, targets: string_list(p, "targets").into_iter().map(BodyName).collect() },
)
}
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1746,7 +1903,7 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
check_expr(s, l, Kind::Length, cmd, "length")?;
}
let modeled = p.get("modeled").and_then(Value::as_bool).unwrap_or(false);
let v = add_feature(s, p, FeatureKind::Thread { face, designation, length, modeled })?;
let v = add_feature(s, p, FeatureKind::Thread { face: FacePick(face), designation, length, modeled })?;
let st = s.model.state();
let t = v.get("feature").and_then(Value::as_u64).and_then(|id| st.threads.iter().find(|t| t.feature == id).cloned());
Ok(json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t}))
@@ -1765,14 +1922,25 @@ fn pipe(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(w) = &wall {
check_expr(s, w, Kind::Length, cmd, "wall")?;
}
add_feature(s, p, FeatureKind::Pipe { path_sketch, path, diameter, wall, operation: operation(p, cmd)?, targets: string_list(p, "targets") })
add_feature(
s,
p,
FeatureKind::Pipe {
path_sketch: SketchId(path_sketch),
path,
diameter,
wall,
operation: operation(p, cmd)?,
targets: string_list(p, "targets").into_iter().map(BodyName).collect(),
},
)
}
fn bounding_solid(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.bounding_solid";
let margin = expr(p, "margin").unwrap_or_else(|| "0".into());
check_expr(s, &margin, Kind::Length, cmd, "margin")?;
add_feature(s, p, FeatureKind::BoundingSolid { bodies: string_list(p, "bodies"), margin })
add_feature(s, p, FeatureKind::BoundingSolid { bodies: string_list(p, "bodies").into_iter().map(BodyName).collect(), margin })
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1787,7 +1955,7 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
for x in &e {
check_expr(s, x, Kind::Unitless, cmd, "factors")?;
}
Some([e[0].clone(), e[1].clone(), e[2].clone()])
Some([e[0].clone().into(), e[1].clone().into(), e[2].clone().into()])
}
Some(_) => return Err(bad(cmd, "`factors` must have three values")),
None => None,
@@ -1799,13 +1967,13 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
};
check_expr(s, &factor, Kind::Unitless, cmd, "factor")?;
let origin = p.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
add_feature(s, p, FeatureKind::Scale { bodies, origin, factor, factors })
add_feature(s, p, FeatureKind::Scale { bodies: bodies.into_iter().map(BodyName).collect(), origin: Position(origin), factor, factors })
}
fn delete_face(s: &mut Session, p: &Value) -> Result<Value> {
let faces = face_points(s, p, "face.delete")?;
let heal = bool_(p, "heal").unwrap_or(true);
add_feature(s, p, FeatureKind::DeleteFace { faces, body: str_(p, "body").map(str::to_string), heal })
add_feature(s, p, FeatureKind::DeleteFace { faces: faces.into_iter().map(FacePick).collect(), body: str_(p, "body").map(BodyName::from), heal })
}
fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1813,13 +1981,21 @@ fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
let faces = face_points(s, p, cmd)?;
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
add_feature(s, p, FeatureKind::OffsetFace { faces, distance, body: str_(p, "body").map(str::to_string) })
add_feature(
s,
p,
FeatureKind::OffsetFace { faces: faces.into_iter().map(FacePick).collect(), distance, body: str_(p, "body").map(BodyName::from) },
)
}
#[cfg(test)]
#[path = "patterns_tests.rs"]
mod patterns_tests;
#[cfg(test)]
#[path = "prism_local_tests.rs"]
mod prism_local_tests;
#[cfg(test)]
#[path = "revolve_convention_tests.rs"]
mod revolve_convention_tests;
@@ -1866,3 +2042,6 @@ mod thin_extrude_tests;
#[cfg(test)]
#[path = "extrude_convention_tests.rs"]
mod extrude_convention_tests;
#[cfg(test)]
#[path = "fillet_connected_rounds_tests.rs"]
mod fillet_connected_rounds_tests;
+39 -13
View File
@@ -3,6 +3,8 @@
use serde_json::Value;
use solidcraft_doc::FeatureKind;
use solidcraft_doc::expr::Kind;
use solidcraft_doc::{BodyName, SketchId};
use solidcraft_doc::{Dir, FacePick, Position};
use solidcraft_geom::Vec3;
use super::CommandSpec;
@@ -57,11 +59,11 @@ fn coil(s: &mut Session, p: &Value) -> Result<Value> {
(Some(n), _, Some(pt)) => (n, pt),
(Some(n), Some(h), None) => {
let [h] = length_operands(s, [(&h, &unit)]);
(n.clone(), format!("({h}) / ({n})"))
(n.clone(), format!("({h}) / ({n})").into())
}
(None, Some(h), Some(pt)) => {
let [h, by] = length_operands(s, [(&h, &unit), (&pt, &unit)]);
(format!("({h}) / ({by})"), pt)
(format!("({h}) / ({by})").into(), pt)
}
_ => return Err(bad(cmd, "give two of `revolutions`, `height` and `pitch`")),
};
@@ -91,7 +93,7 @@ fn coil(s: &mut Session, p: &Value) -> Result<Value> {
p,
FeatureKind::Coil {
base,
axis,
axis: Dir(axis),
diameter,
pitch,
turns,
@@ -101,7 +103,7 @@ fn coil(s: &mut Session, p: &Value) -> Result<Value> {
start_angle,
clockwise,
operation,
targets: string_list(p, "targets"),
targets: string_list(p, "targets").into_iter().map(BodyName).collect(),
},
)
}
@@ -131,7 +133,7 @@ fn rib_like(s: &mut Session, p: &Value, cmd: &str, web: bool) -> Result<Value> {
check_expr(s, d, Kind::Length, cmd, "depth")?;
}
let flip = bool_(p, "flip").unwrap_or(false);
add_feature(s, p, FeatureKind::Rib { sketch, curves, thickness, depth, flip, web })
add_feature(s, p, FeatureKind::Rib { sketch: SketchId(sketch), curves, thickness, depth, flip, web })
}
fn rib(s: &mut Session, p: &Value) -> Result<Value> {
@@ -212,11 +214,15 @@ fn emboss(s: &mut Session, p: &Value) -> Result<Value> {
} else {
let inferred = infer_emboss_selection(s, sketch, &profiles, &depth, deboss, &targets)?;
if let Some(face) = &inferred {
targets = vec![face.body.clone()];
targets = vec![face.body.0.clone()];
}
inferred
};
add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face })
add_feature(
s,
p,
FeatureKind::Emboss { sketch: SketchId(sketch), profiles, depth, deboss, targets: targets.into_iter().map(BodyName).collect(), face },
)
}
fn emboss_selection(s: &Session, selected: &solidcraft_doc::ModelBody, at: Vec3) -> Result<solidcraft_doc::EmbossFace> {
@@ -240,7 +246,7 @@ fn emboss_selection(s: &Session, selected: &solidcraft_doc::ModelBody, at: Vec3)
return Err(bad(cmd, "cylinder placement is too deep"));
}
let inverse = solidcraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid active frame"))?;
Ok(solidcraft_doc::EmbossFace { body: name.to_owned(), at: solidcraft_doc::apply_point(&inverse, at), path, author_path })
Ok(solidcraft_doc::EmbossFace { body: name.into(), at: FacePick(solidcraft_doc::apply_point(&inverse, at)), path, author_path })
}
fn check_emboss_positions(s: &Session, sketch: u64, body: &solidcraft_doc::ModelBody, face: &solidcraft_doc::EmbossFace) -> Result<()> {
@@ -307,7 +313,7 @@ fn infer_emboss_selection(
face.sketch_frame(&placed, s.active_component, sketch_owner, raw).map_err(|e| bad(cmd, e.to_string()))?;
check_emboss_positions(s, sketch, selected, &face)?;
let author_inverse = solidcraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid author frame"))?;
face.at = solidcraft_doc::apply_point(&solidcraft_doc::mat_mul(&author_inverse, &body_frame), at);
face.at = FacePick(solidcraft_doc::apply_point(&solidcraft_doc::mat_mul(&author_inverse, &body_frame), at));
if chosen.is_some() {
return Err(bad(cmd, "multiple tangent cylinders match the selected profiles; select a target face or body"));
}
@@ -341,7 +347,7 @@ fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
None => plane_param(s, p.get("target"), cmd)?,
};
let body = str_(p, "body").map(str::to_string);
add_feature(s, p, FeatureKind::ReplaceFace { faces, target, body })
add_feature(s, p, FeatureKind::ReplaceFace { faces: faces.into_iter().map(FacePick).collect(), target, body: body.map(BodyName) })
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
@@ -376,7 +382,18 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
_ => (None, None),
};
let flip = bool_(p, "flip").unwrap_or(false);
add_feature(s, p, FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip })
add_feature(
s,
p,
FeatureKind::Align {
bodies: bodies.into_iter().map(BodyName).collect(),
from: Position(from),
to: Position(to),
from_normal: from_normal.map(Dir),
to_normal: to_normal.map(Dir),
flip,
},
)
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
@@ -389,7 +406,7 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(b) = bodies.iter().find(|b| st.body(b).is_none()) {
return Err(bad(cmd, format!("no body `{b}`")));
}
add_feature(s, p, FeatureKind::Remove { bodies })
add_feature(s, p, FeatureKind::Remove { bodies: bodies.into_iter().map(BodyName).collect() })
}
#[cfg(test)]
@@ -443,5 +460,14 @@ fn boundary_fill(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "`cells` must list 1…1000 points [x, y, z], one inside each cell"));
}
let operation = super::features::operation(p, cmd)?;
add_feature(s, p, FeatureKind::BoundaryFill { tools, cells, operation, remove_tools: bool_(p, "remove_tools").unwrap_or(false) })
add_feature(
s,
p,
FeatureKind::BoundaryFill {
tools: tools.into_iter().map(BodyName).collect(),
cells: cells.into_iter().map(Position).collect(),
operation,
remove_tools: bool_(p, "remove_tools").unwrap_or(false),
},
)
}
Loaded 100 of 412 files, more files were not shown because too many files have changed in this diff. Show more