Compare commits

..

96 Commits

Author SHA1 Message Date
bherbruck f5ae72057d Release 0.8.0 (#648)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:33:35 -04:00
bherbruck 29258767ce fix: refuse Boolean retries that change the part away from the tool (#616) (#644)
Joining ribs flush with a lid's window left a zero-thickness slit far from the rib (the planar fallback glued back only one of the faces its pieces shared) and displaced the tool on the other joins. Gluing now joins every shared face pair, coincident-face separation checks there is material behind a pushed face for every join, and any join or cut that needed a retry must be closed, keep volume monotonic and leave the part unchanged outside the tool's box, or it is refused with an error. Feature command replies carry the feature's warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:10:29 -04:00
bherbruck 392fae1af4 fix: Keep the window draggable while a design opens (#643)
The loading box was a full-window modal whose backdrop covered the custom title bar. It is now a light overlay under the title bar, so dragging, snapping, resizing and the window buttons keep working during open, import and recovery. The timeline rebuild shows feature N of M, and Cancel or Esc stops it at the next feature (solidcraft_doc::progress). Recovery restore now runs off the window thread too. A control or MCP doc.open or import is answered once the file is in and doesn't block frames; edits sent meanwhile wait their turn. The web build still opens in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:59:20 -04:00
bherbruck 8be0f64f5a Fillet and chamfer of one edge ending on curved, slanted or several faces (#646)
A single edge's blend no longer needs a plane square to the edge at each end. Straight edges: each rail ends where it reaches the end face's surface, the side edges there are shortened or continued along their line or circle, and the blend ends where its cylinder or chamfer plane meets that surface (a fitted curve checked against both). A chamfer may end across two faces. Curved edges: at a slanted or curved end the rolling ball runs on past the end and the strip is cut back where it meets that face. A straight edge running smoothly into a bend or rounded corner is blended along its whole smooth chain. Rolling-ball fixes: smooth chains follow edges stored against their curve, the ball touches only the faces of the edges it rolls along, the seam normal at a joint comes from the face there, and each strip is fitted with neighbouring sections at its own spacing.

Action matrix: edge.fillet 83.6% -> 93.0%, edge.chamfer 82.6% -> 93.3% (697 new passes, no regressions); reference tests 76/76.

Fixes #556

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:43:13 -04:00
Alan16888 9f6fb735dd fix(ui): Select Other keeps its list readable on dense meshes (#636)
Select Other keeps at most 20 rows per kind, nearest first, so a dense spot (a modelled thread, an imported mesh) no longer lists thousands of rows. The title shows how many were under the cursor in all, and ui.selectOther reports it as total.
2026-10-13 06:41:18 -04:00
Jonas Wrede 46e21127dd feat: insert another design as a linked component (#596)
component.insert_design adds a component holding another design's solid bodies, linked to its file (stored relative to the assembly once saved). component.update_links reads the linked designs again, component.break_link keeps the bodies and forgets the file, and component.links lists them. Links are saved under format 16; an older build keeps the bodies and drops the link.

Fixes #533
2026-10-13 06:40:58 -04:00
NOWN-DEV 90869732a3 fix: edge flanges trim the base along their own edge and relieve bends that end beside it (#634)
An edge flange whose edge ends at an inside corner of the base now trims only the strip under its own edge, so legs and stems past that corner stay. Where the bend ends beside more of the base, the rule's bend relief (relief_width, relief_depth) is cut into the base, or a note says why it was left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:39:06 -04:00
Kevin Ravensberg 24cff445ad fix: fuse a loft standing on a face instead of returning touching shells (#577)
Fixes #396. A union that would come back as shells touching over an area is rejected and the face-in-face case is fused exactly; line and point contact stay as before.
2026-10-13 06:04:41 -04:00
bherbruck bb60a95289 Through-all holes beside walls: a long thin tool keeps its outside out (#629)
Body::new no longer turns a long thin solid (a through-all hole's tool) inside out on the strength of a coarse mesh volume: it checks again on a fine mesh first. 79 more face.hole actions pass in the action matrix; the remaining no-result cuts stay under #559.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 05:39:48 -04:00
selune 8d58e3b9f9 feat(mcp): support compact single execute replies (#591)
MCP execute accepts verbose: false too, using the same compact reply as batch (#599), and the MCP docs gain a section on compact replies.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
2026-10-13 05:36:49 -04:00
Lasse Lian f0d80a1d7e fix: filleting beside a modeled thread no longer freezes the app (#618)
Fixes #602. Collinear-edge merging, piece naming and the dialog preview each work in one pass instead of quadratic loops; the issue's example goes from 86 s to 5.1 s.
2026-10-13 05:36:22 -04:00
Irakli Kourtis 0f0bb09858 feat: fit reduce lowers the radius only where it doesn't fit (#496)
Fillet fit: reduce lowers the radius only where it doesn't fit: picked edges are grouped where they can limit each other (sharing a face or vertex), and each group gets the requested radius where it fits, otherwise the largest that does. Refs #293.

Co-authored-by: Iraklis Kourtis <jyviko@gmail.com>
2026-10-13 05:27:03 -04:00
bherbruck 6d5df4b9b3 Sketch on cut walls, chamfers, pattern copies, join-merged sides and split faces (#625)
A flat face that no construction certificate names on its own now attaches by its exact history name (a new lineage face certificate): cut walls and floors, chamfer faces, each pattern or mirror copy, side walls a Join merged, and the pieces a later feature split a face into. Construction certificates still come first; a seed keeps its certificate when it is patterned. Two flush extrudes of one outline joined end to end keep their exact history in the prism boolean. Face sketch 52% -> 71%, offset plane 52% -> 71%, press-pull/extrude from face 41% -> 60% in the action matrix; baseline blessed.

Fixes #491, fixes #492, fixes #493, fixes #557.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 05:26:29 -04:00
Jonas Wrede 27f20f97cb feat: view.apply restores a named view or camera; MCP screenshot takes a named view or camera (#528) (#595)
New view.apply command restores a named view, a standard view or any camera (view state, not an undo step); the desktop viewport follows it and the browser's Restore uses it. The MCP screenshot tool takes a named view or a camera. Un-ignores the #528 regression test. Fixes #528.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 05:22:52 -04:00
bherbruck e67cc0c747 Linux app id: ai.storyteller.solidcraft, with an end-of-life rebase from the old id (#619)
The Flatpak app id, desktop/metainfo/MIME file names, icon name and the window's Wayland app_id / X11 class are now ai.storyteller.solidcraft, the id the ArtCraft launcher expects.

Existing Flatpak installs move over: the Pages repo marks app/ai.storyteller.solvecraft end-of-life with a rebase to the new id, so flatpak update offers the switch and Flatpak carries ~/.var/app along. A new install next to the old one copies settings and autosaves on first launch through a read-only grant on the old data folder. The metainfo declares <replaces>/<provides>, the desktop entry X-Flatpak-RenamedFrom, and AppImages drop the menu entry an older AppImage installed. deb/rpm upgrades remove the old-named files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 05:20:58 -04:00
selune bcbfa50eac Add bounded portable Home thumbnails to design files (#518)
A save with cache: true also stores a small bounded PNG thumbnail (240 × 150, at most 256 KiB, keyed to the exact document) made from cached meshes only. Home uses it when its local picture is missing, without rebuilding the design; ordinary saves and downloads still omit it. Closes #303.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
2026-10-13 05:20:47 -04:00
NOWN-DEV 24974e6f37 fix: a dimension from linked geometry keeps its side when the source moves past it (#614)
A point dimensioned from linked geometry (projected, included or intersected) keeps its side when a parameter moves the link's source past it: the solve reads sides and turns from the stored state instead of from beside the moved line, both on recompute and when editing the sketch.

Co-authored-by: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com>
2026-10-13 05:15:15 -04:00
Lasse Lian bf962a9989 fix(ui): primitives go on the picked face or vertex (#580) (#598)
Box, Cylinder, Sphere, Torus and Coil go where a face or vertex was picked before starting them: centred on the point, standing on a planar face (a box on an axis-aligned face sits on it; cylinders and coils take the face normal as axis). The dialog shows the position with a button to place at the origin instead. Fixes #580.

Co-authored-by: lasselian <lassesorensen80@gmail.com>
2026-10-13 05:14:35 -04:00
Karl Von Richter 04cfb74c5e feat: Group sketch creation tools and remember variants per part (#589)
Sketch Create groups its drawing tools under Line, Rectangle, Circle, Arc, Slot, Polygon and Spline. Each family's main button starts the part's last-used variant and its caret lists the others; choices are per open part, kept across sketch sessions and tab switches, and never touch the design or undo history.

Co-authored-by: Karl Von Richter <karl@von-richter.net>
2026-10-13 04:49:28 -04:00
Jonas Wrede 14635e5d3a feat: user-defined physical materials stored in the design (#532) (#606)
A design can define its own physical materials (material.create, material.edit, material.delete): name, density and an optional look. They work everywhere built-in materials do: assignment, mass properties, colours and configuration cells. Stored in user_materials, an additive field under format 16. Un-ignores the #554 regression test. Fixes #532.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 04:47:23 -04:00
NOWN-DEV a2f31f683d feat: flanges on flanges follow their flange's end and close at corners (#601)
An edge flange on another flange's far edge (a return) runs along that edge from end to end, following the parent's closed or cut-back corner, and returns of one feature close toward each other at convex base corners, mitred the rule's gap apart. Bends stop square where the mitre allows. Fixes #593.

Co-authored-by: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com>
2026-10-13 04:45:19 -04:00
NOWN-DEV 9089edfb71 fix: the bend table and bend lines follow what cut-outs leave of a bend (#607)
The bend table, flat pattern and DXF list a bend only where its centre line has material: a bend that cut-outs (or pockets that together go through the sheet) shorten keeps what is left, and one they split gets a row per piece, all with the bend's number.

Co-authored-by: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com>
2026-10-13 04:32:45 -04:00
Jonas Wrede a4ca1b68db feat: compact MCP batch replies; feature replies name changed and removed bodies (#530) (#599)
MCP batch takes verbose: false for compact replies: feature steps leave out the full body list and sketch steps the ids they repeat outside created. Feature replies now also list changed_bodies and removed_bodies next to created_bodies, so a compact reply still shows every effect of a step. Un-ignores the #530 regression test. Fixes #530.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 04:32:39 -04:00
Jonas Wrede 94ab54d1f5 fix(sheet): a box or cylinder cut along the sheet normal is flat machining (#541) (#550)
A Box cut, or a Cylinder cut whose axis is the sheet's normal, machines a sheet metal body like the extrude cut of its footprint: through the sheet it's a cut-out (a cylinder an exact round one in the flat pattern and DXF), part way it's a pocket. Primitives at other angles or across a bend are still refused. Un-ignores the #541 regression test. Fixes #541.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 04:29:31 -04:00
Jonas Wrede 9e2a1eb993 fix: faces a sheet fold moves keep their names (#535) (#585)
Faces that a sheet fold or unfold moves keep their names: points on a moved panel are taken back through the panel they lie on to where the sheet held them before the feature, and named from the faces there. Un-ignores the #535 regression test. Fixes #535.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 04:28:27 -04:00
Jonas Wrede abff7a49bb fix: tapered extrude of profiles mixing lines and tangent arcs (#546) (#587)
Tapered extrudes accept profiles that mix lines and tangent arcs (slots, D-profiles, rounded rectangles): each arc keeps its centre and angles and its radius moves with the taper, so its side stays an exact cone; lines are offset and mitred. An arc meeting another edge at a corner is still refused. Un-ignores the #546 regression test. Fixes #546.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 04:28:21 -04:00
Jonas Wrede 651e8e0853 fix: draft a whole round wall from one pick (#547) (#603)
Draft extends the picked faces to every face on the same surface, so one pick on a cylinder's side (stored as two halves) drafts the whole round wall into a frustum. Un-ignores the #547 regression test and adds a kernel test for a round hole. Fixes #547.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 04:27:12 -04:00
Jonas Wrede 4a86ad5a7c fix: extend a cut or join tool only past an end that lies on a target face (#545, #544) (#609)
An extrude cut or join only extends its tool past an end that lies on a target face, and by less than half the way to the next face level, so a tool can no longer cut through a thin floor beyond an end that touches no face. Un-ignores the #545 and #544 regression tests. Fixes #545.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 04:26:07 -04:00
selune 5ad8b3966f fix(ui): preview actual polygon outlines while drawing (#583)
Inscribed, circumscribed and edge polygon tools preview their actual outline (side count and orientation) while drawing, with the current line type, instead of only the support circle. Preview work is bounded. Fixes #520.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
2026-10-13 04:26:00 -04:00
Jonas Wrede dc060f8b86 fix(sheet): refuse a fold's fixed point far above or below the sheet (#371) (#538)
sheet.fold tests its fixed point against the whole sheet body, so a point far above or below the sheet is refused atomically, while a point on any face (including a flange's leg) still works. A stored fold whose point ends up in the air recomputes, keeps its side and warns. Un-ignores the #371 regression test. Fixes #371.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 04:20:16 -04:00
Jonas Wrede 9c94e1b96d fix(sheet): flat pattern reports cut-outs exactly as circles, lines and arcs (#542) (#552)
sheet.flat_pattern reports every cut-out exactly in a new cutout_shapes list, parallel to cutouts: a round hole as a circle, other cut-outs as their lines, arcs and (for other curves) polylines. Pockets get the same. The DXF writer and the JSON share one circle test. Un-ignores the #542 regression test. Fixes #542.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 04:18:37 -04:00
selune 2aff1c5303 ci: scan dependency advisories nightly and update XML parser (#605)
A scheduled/manual CI job scans locked dependencies for advisories, bans and unknown sources with cargo-deny 0.20.2 (never on pull requests, contents: read, no secrets). The first scan flagged quick-xml 0.38.4 (RUSTSEC-2026-0194/0195), so the workspace moves to quick-xml 0.41. Fixes #468.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
2026-10-13 04:17:42 -04:00
Jonas Wrede 135a63f549 feat: save body and occurrence visibility with the design (#531) (#592)
Hidden bodies, occurrence placements, sketches and origin items are saved with the design and come back when it is reopened. Visibility is still not an undo step, but hiding or showing something now counts as an unsaved change. Additive field under format 16. Fixes #531.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 04:16:49 -04:00
bherbruck 2a434d59c3 Picks on a moved or pasted component build where it is shown (#558) (#610)
Construction axes, points and planes resolve their picks against the model as their component sees it (in the root, every placement, pasted instances too), and a Hole picked on another component's body cuts that body. components-arm 355 -> 502/594 and joints-hinge 304/390 -> 371/408 in the action matrix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:12:24 -04:00
selune 083ddf7722 fix(ui): preserve construction tools and dashed feedback (#600)
Construction (X) keeps the active sketch tool, its picked points and inline values while switching line type, and drawing previews and hover show the same dashed pattern as committed construction and centre lines. Fixes #584 and #581.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
2026-10-13 04:07:48 -04:00
Jonas Wrede db02a18627 fix(sheet): sheet.flange refuses rule values instead of ignoring them (#570) (#594)
sheet.flange refuses the sheet values its rule owns (thickness, K-factor, bend radius, reliefs, hem gap) instead of silently ignoring them, and says to use a rule. Un-ignores the #570 regression test. Fixes #570.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 04:06:59 -04:00
Jonas Wrede c727e2d0c2 fix(sheet): keep a stored fold working when its fixed point drifts off the sheet (#370) (#537)
A stored fold whose fixed point drifts off the sheet after a parameter change keeps the side the point lies on and shows a warning instead of breaking the timeline. A new fold still refuses a fixed point off the sheet, atomically. Un-ignores the #370 regression test. Fixes #370.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 04:05:01 -04:00
Jonas Wrede f9c19ade16 fix: raising a pattern's count after a rename numbers the new copies after the others (#372) (#540)
A copy added by raising a body pattern's count after a rename is numbered after the highest stored copy, so it no longer takes the number the rename freed. Un-ignores the #372 regression test and adds one for undo/redo and save/reopen. Fixes #372.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 04:04:44 -04:00
Paarangat Jain 711e5508b5 Add a security policy that points to private vulnerability reporting (#526)
Co-authored-by: Paarangat Jain <44281208+pjrus@users.noreply.github.com>
2026-10-13 03:59:52 -04:00
selune 913583bfee Show a bounded native-cache preview before authoritative reconstruction (#463)
Refs #303. file.save / file.save_as can store a warm native geometry cache (cache: true, off by default) keyed to the document hash, app version and adapter revision. Opening shows a bounded preview from it until the rebuilt session replaces it. Any cache failure counts as a miss.
2026-10-13 02:54:01 -04:00
bherbruck 1abd989a67 test: land the remaining ignored regression tests from #548-#573 (#582)
Ignored regression tests for #530, #531, #541, #542, #545, #547 and #570, from #548, #549, #550, #552, #555, #573 and #572.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 02:51:12 -04:00
Kevin Ravensberg b1740aeee5 ui.at picks an origin plane where a click selects it (#575)
ui.at {plane} now returns the middle of the origin plane's square, or else the middle of a quarter that a click actually picks, so scripted clicks on XY no longer start a sketch on XZ from iso-like views.
2026-10-13 02:42:45 -04:00
bherbruck 736d9277b8 Action matrix: every ordinary action on every face, edge, vertex and body of the design corpus (#553)
apps/solidcraft-cli/tests/action_matrix.rs tries sketch, offset/angle/mid planes, press pull, extrude from a face, hole, draft, shell, fillet, chamfer, axes, points, planes, projection, move, copy, mirror and pattern on the final bodies of 104 designs (the old design files, the examples, the 76 reference recipes, the enclosure demos, the Pi case built through the UI, eight typical parts), through the commands and picks the desktop sends, each design in its own process, with an edit round on a sample of passes. tests/action_matrix/baseline.json is a ratchet: an action that passed there and fails now fails the test; newly passing ones are reported for cargo xtask action-matrix --bless. Every CI run checks a fixed sample; the nightly Action matrix job runs the whole matrix in four parts. 30,567 of 35,576 actions pass (85.9%); results and causes in docs/action-matrix.md, one ignored reproduction per open cause (#556-#568).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:41:17 -04:00
Jonas Wrede 9283d2b5bb feat: feature replies list the bodies the feature created (#525)
Fixes #374. Feature replies gain created_bodies: the bodies the new feature made, apart from the ones it changed or that were already there. Documented in the execute tool description and docs/mcp.md.
2026-10-13 02:39:19 -04:00
selune c1cd4cf194 fix(ui): persist keyboard zoom and stabilize scale drags (#574)
Fixes #478. Desktop Ctrl/Cmd +/-/0 now update the saved UI scale instead of flashing back, and the Preferences scale slider applies the new layout on release (range 50-250%, matching the preference validation). Addresses the drag jitter in #272.
2026-10-13 02:33:45 -04:00
Jonas Wrede 1a6ff0954e fix: inspect.interference prunes by bounding-box sweep instead of refusing more than 200 bodies (#536)
Fixes #527. Bodies are paired by a sort-and-sweep over their bounding boxes, and the cap moves from 200 bodies to 20,000 overlapping pairs per call (reported as truncated / skipped_pairs).
2026-10-13 02:29:45 -04:00
Jonas Wrede 88e9661457 test: failing regression test for #546 (ignored until fixed): solid.extrude with taper refuses profiles of lines and arcs: "not supported yet: tapered profiles mixing lines and arcs" (#571)
Adds an ignored regression test for #546; it runs once #546 is fixed.
2026-10-13 02:28:16 -04:00
Jonas Wrede 7a8754bef7 test: failing regression test for #544 (ignored until fixed): Blind Ø2 hole into a long D-profile bar is refused ("exact fallback … takes planar faces only") although it stays clear of the round face (#551)
Adds an ignored regression test for #544; it runs once #544 is fixed.
2026-10-13 02:28:12 -04:00
Jonas Wrede 6441621b8b test: failing regression test for #528 (ignored until fixed): No way to apply a saved named view or a camera from scripts/MCP (view.save stores views, screenshot only takes standard views) (#543)
Adds an ignored regression test for #528; it runs once #528 is fixed.
2026-10-13 02:28:08 -04:00
Jonas Wrede d7797c3d38 test: failing regression test for #535 (ignored until fixed): Sheet metal: hole faces on a folded panel lose their sketch-curve names (side:c1#1 becomes F6:face:10) (#539)
Adds an ignored regression test for #535; it runs once #535 is fixed.
2026-10-13 02:28:05 -04:00
Jonas Wrede b11841b0bc test: failing regression test for #532 (ignored until fixed): Physical materials: no user-defined materials (library is fixed to 14 entries) (#554)
Adds an ignored regression test for #532; it runs once #532 is fixed.
2026-10-13 02:28:01 -04:00
Jonas Wrede a8121215bc test: failing regression test for #533 (ignored until fixed): Assemblies: no way to insert another design as a linked component (STEP insert drops sheet-metal history and never updates) (#534)
Adds an ignored regression test for #533; it runs once #533 is fixed.
2026-10-13 02:27:58 -04:00
NOWN-DEV a69c2928f0 feat: sketch text takes a font, an installed family or a font file (#516)
sketch.text and sketch.edit_text take an optional font: an installed family or a .ttf/.otf/.ttc file. Edit Text gets a Font field. The font is kept with the text under format 16; no fonts are bundled, and the font search is bounded.

Fixes #513

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:04:59 -04:00
William Dunn 7d00df0a3f Give solid.move the copy half of its Move/Copy label (#498)
solid.move takes copy: true to keep the source bodies and transform a new copy of each, with a Create Copy box in the Move dialog. Designs without it save and load exactly as before.
2026-10-13 01:54:01 -04:00
NOWN-DEV 69e54f85d2 fix: draw small radii on large bodies round (#515)
Display meshes also bound the angle one facet turns, so bends and small holes on a large body are drawn round instead of as chamfers or squares. Bounded per face, and only the display mesh changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:43:45 -04:00
William Dunn bf2a9f5541 Give the centerline tool a toolbar icon, and paint both line-type icons in construction orange (#427)
Centerline gets a place in SKETCH > CREATE with its own long-short icon, and both line-type icons now draw in the construction orange.
2026-10-13 01:26:47 -04:00
NOWN-DEV d6af554c6d feat: corner reliefs at sheet flange corners, chosen by the rule (#488)
Sheet Metal Rules get a Corner Relief Shape (notch, round or rectangular), sized by the rule's corner relief, cut where two bends meet at a corner of the base. Notch, the default, keeps existing designs as they were. File format 16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:25:50 -04:00
NOWN-DEV c2a9c71436 fix: text extrudes as letters with their counters, alone or beside drawn curves (#502)
Text outlines count as drawn geometry when picking profiles, and "all profiles" leaves out the counters of letters (the holes of A, B, 8), so text extrudes as letters with their holes whether or not other curves are drawn next to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:25:39 -04:00
Mqrius f93577ad70 fix(doc): pieces of a body cut apart again get names of their own (#512)
When a body that was already cut into pieces is cut again, the new pieces are numbered past the names already taken ("Bar (2)"), instead of reusing "Bar (1)".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:25:28 -04:00
selune 9f88537927 Define a kernel-neutral native B-rep format and guarded adapter (#461)
A SolidCraft-owned portable B-rep schema (geometry schema 2 with analytic curves and surfaces, explicit frames and ranges, fitted intersection curves) and a versioned body envelope (SCBREP03) with its own metadata records, a bounded binary codec, a validator with resource budgets, and an adapter that refuses what it can't convert exactly. Pinned by a box-and-fillet golden fixture. Not yet written by application saves. Refs #303.
2026-10-13 00:13:29 -04:00
Scott Bowden 3159fc0dd0 fix(kernel): a small overlap is not taken for bodies apart or inside (#484)
When sample points say two bodies are apart or one is inside the other, the facetings are now checked for surfaces passing through each other; if they do, the boolean is computed instead of shortcut. If it still can't be intersected, the sampled answer is kept as a flagged, unverified result, and Interference lists such pairs as "could not be checked" with the reason. Fixes #442 and #443.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:13:16 -04:00
NOWN-DEV 340ac0313a fix: a hole placed on a face follows the face along its axis only, and warns when off the face (#510)
A hole positioned on a face now follows that face along the hole's own axis instead of sliding to the face's nearest point. When the face no longer reaches the position, the hole stays where it was placed and the feature says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:13:04 -04:00
Mqrius bb431f7ac2 fix(sketch): merged adjacent profiles have whole edges where shared ones ended (#509)
When adjacent profiles are merged for one extrude, collinear lines on either side of where a shared boundary ended are joined back into one edge, so the body has no split faces or extra vertices for later booleans to trip on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:11:59 -04:00
NOWN-DEV 2f7046efac fix: lines lying on one another do not cross in planar booleans (#507)
Two line segments that lie on one another within the tolerance, a hair from parallel, no longer report a crossing somewhere along their overlap; the ends of the overlap split the edges instead. This stops booleans from leaving sub-tolerance edges or stray vertices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:11:55 -04:00
William Dunn aaeb7ceea7 Sketch Palette: a right-aligned control column, one Look At icon, a disclosure triangle, plain-noun labels (#497)
Every Options row puts its control in one right-aligned column, Look At is a single label with an icon button, Options folds with a disclosure triangle, and the toggles use plain nouns (with dedicated ids for Profile and Points so they don't collide with other labels).
2026-10-12 23:58:58 -04:00
William Dunn 10c6b865c7 Sketch Palette: Linetype tooltips say what the click does (#499)
The Linetype buttons now read "Draw as {linetype}" with nothing selected and "Convert the selection to {linetype}" with a selection, in all four locales.
2026-10-12 23:58:40 -04:00
bherbruck a9879fa668 Release 0.7.0 (#508)
Bump the workspace to 0.7.0 and write the 0.7.0 release notes. Minor bump: the design format moved to 15 (sketches on cylinder caps and sheet metal panels), so 0.6 can't open 0.7 saves, and new features landed (Select Other, per-edge flanges, sketches on caps and sheet metal faces).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:15:08 -04:00
bherbruck 45bc22fc30 fix: faces of an extrude drawn over a face's projected edges keep an exact source (#505)
A profile traced over a face sketch's projected boundary left the extrude's caps and sides without an exact planar source, so a sketch or offset plane on the new top face was refused. The drawn curve is now the edge's source where it covers a projection. Clearer fixed-plane message; regression tests in the engine, UI harness and design corpus, plus a corpus-wide flat-face attachment check (known gaps under #491-#495).

Fixes #489

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:01:35 -04:00
bherbruck 4aaa5457b3 feat(ui): Select Other — pick what lies under something else (#503)
Hold the left button still (long-press on touch) or right-click › Select Other… to list everything under the cursor: sketch points and curves ("Line 3", "Point (arc 2 centre)"), vertices, edges, faces, bodies, profiles, planes and axes, filtered like a click and by the open dialog's active input. Hovering a row lights it up; clicking selects it (Shift/Ctrl add) or feeds the dialog. A repeat click on the same spot with nothing changed selects the next thing there; a double-click keeps its own actions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:56:57 -04:00
bherbruck 1fd4a2f782 README demo for 0.6: a Raspberry Pi 4 case (#504)
Replace the 0.2.1 enclosure recording with a 40 s take of the current desktop UI building a Raspberry Pi 4 case: a sketch dimensioned with the board's parameters, extrude, fillets and shell, an M2.5 standoff patterned on the 58 x 49 mm hole rectangle, port cut-outs, floor vents, a colour, then height 24 -> 32 mm in Parameters. The demo script gains the pi story (now the default).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:56:53 -04:00
onenewcode 00baf1d883 fix: follow feature renames in references (#486)
Fixes #401
2026-10-12 22:39:13 -04:00
Mqrius 91de39d3fe fix(kernel): revolving adjacent profiles on the axis makes no zero-area faces (#490)
Profiles revolved together merge into one region that keeps a vertex where
each shared edge met the axis. With the axis edge in several pieces the loop
skipped the builders for a profile with an edge on the axis, and the generic
sweep turned each piece into a degenerate face that booleans failed on.
Runs of collinear axis lines are merged into one first.

Fixes #487

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:38:48 -04:00
bherbruck 219816d282 Name untitled designs after their file (#483)
* fix: name an untitled design after its file on save and open

Design tabs all read "Untitled" because a design's name only ever came
from file.new. Saving a design still under the default name now names it
after the file (bracket.solidcraft -> "bracket"), and opening a file whose
stored name is the default shows its file name. Names chosen by the user
are kept. The rename is part of the save, so it is not an undo step and
does not leave the design marked as changed.

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

* test: expect the file's name after saving an untitled design

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:19:26 -04:00
Mqrius 2880525e6b fix(ui): the Combine dialog previews without an error, so OK is enabled (#481)
The live preview also evaluates each command as a new body, to show the tool of a join or cut
(as_new_body sets every `operation` to "new"). A combine has no new-body form: its operation is
between bodies that already exist, and solid.combine rejects "new" ("operation must be join, cut
or intersect"). That error became the preview's error even when the combine itself worked, and
since OK is greyed while the preview has an error, a combine could not be applied from its
dialog at all. as_new_body now leaves solid.combine alone, so its preview shows the result.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:50:05 -04:00
Irakli Kourtis aa156f4a9f fix: report every fillet refusal of a radius too big as too large (#477)
`fit: reduce` acts only on refusals marked too large. Three kinds weren't:
the corner build's contact lines leaving their faces or running backwards,
and a curved chain's contact reaching past the end of the seam it trims. Two
more were hidden: a later edge's too-large refusal lost to an earlier edge's
other error, and the corner build's lost to the local blend's in `fillet`.
The messages are unchanged.

Refs #293

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:49:00 -04:00
Irakli Kourtis ccc36dc1f9 fix: shell a body whose curved edge is rounded smaller than the wall (#476)
Moved in by more than its radius, a round collapses and its neighbours meet
along a new edge. That edge was always built as a straight line keyed by its
ends, so on a disc whose circular edge is rounded the four arcs where the floor
meets the side became one chord used by four faces, and the shell failed with
"This shell is not oriented and closed" (#319).

The new edge now runs where the edge's face meets the face across the round
(the round's far side, crossed as a strip), through the edge's middle moved
onto both: a line or a circular arc, checked to lie on both moved faces, and
refused otherwise. Twins are the new edges with the same ends through the same
middle.

Fixes #319

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:48:27 -04:00
bherbruck 0fe032438f Note sheet metal panel anchors under format 15 (#482)
Claude-Session: https://claude.ai/code/session_01HWLPLy7ug7URSK5WybjRNa

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:41:58 -04:00
William Dunn 72c5d974b8 fix(ui): draw sketch constraint glyphs in red (#414)
* fix(ui): draw sketch constraint glyphs in red

Traditional CAD convention marks constraint glyphs in red. glyph_edge
and glyph_text in both themes now use red values. glyph_bg is
unchanged. The same glyph box switches to the error colour when a
constraint fails. The new reds are chosen to stay distinct from that
error colour. They are also distinct from the blue used for selection
and hover. See the pull request body for the before and after RGB
values and the contrast reasoning.

* fix(ui): match constraint toolbar icons to the red glyph colour

The CONSTRAINTS panel buttons and their drop-down rows drew their accent
strokes in the normal sketch tool green. They now use glyph_text, the same
red the matching constraint glyph box uses in the sketch view. A new
panel_accent helper in toolbar.rs picks the colour: the constraint red for
the CONSTRAINTS panel, the existing sketch green for every other sketch
tool, and the plain accent everywhere else. No other toolbar styling
changed.
2026-10-12 21:37:42 -04:00
Irakli Kourtis 201e9d5f9f Patch fills non-planar edge loops with one smooth face (#250) (#472)
* Add a one-surface fill of closed 3D loops to geom

A closed loop of curves that does not lie in a plane has no exact surface;
the usual answer is one smooth face fitted over it. fill_loop does that:

- the loop's best-fit plane (Newell normal), with a frame chosen from the
  loop itself so the result does not depend on where the loop starts, its
  direction, or where the part sits
- the loop seen along that normal must be simple and must not fold or run
  along the normal; otherwise it is refused ("not supported yet"), never
  filled with wrong geometry
- one non-rational bicubic B-spline height field over the loop's box,
  fitted by least squares to the loop's samples (The NURBS Book 9.4.1,
  9.4.3, eq. 9.62/9.65) with a thin-plate smoothness term
  lambda*A*int(h_uu^2 + 2 h_uv^2 + h_vv^2), A one grid cell's area, so the
  fit is the same at any scale; only planes are reproduced exactly
- span counts per direction (long narrow loops fit), refined until the
  loop is within tolerance, else refused with the deviation reached

bspline: bisected span search, basis functions on a stack array, one
clamping helper shared by curves and surfaces, one binomial helper.

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

* Patch fills non-planar loops of edges with one smooth face (#250)

surface.patch with edges refused any loop that is not planar ("not
supported yet: patching a loop that is not planar"). It now fills it:

- planar loops still get one exact planar face, but planarity is certified
  conservatively: lines by their ends, B-spline/NURBS edges by their control
  points, intersection curves by their samples lying on a support plane
  within tolerance; a loop that can't be certified is filled instead
- other loops get one face from geom's fill_loop, bounded by the body's own
  chained edges, whole (no split): its history is one generated face whose
  edges are the body's, and it stitches back into a closed solid
- intersection-curve edges are sampled by a seeded, guarded double
  projection shared by the planarity test and the fill (plane_fit)
- the face tolerance is 10x the planarity tolerance (documented: later
  operations take the face to contain its edges within it)
- edge picking runs inside the guard; the geom->truck conversion is shared
  with the T-spline sheets (spline_conv); the Newell plane fit is shared with
  STEP import

Tests: 3- to 8-sided, concave (dart, arrow, plus), flat-corner, two-edge and
imported rims fill as one face and stitch closed (volumes against an
independent integral); finite mesh normals, same result for any pick order
or placement; non-height-field loops and too-fine detail are refused.

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

* Test the Patch dialog filling a non-planar loop of picked edges

The Patch dialog's only test used a sketch profile. Picking the four
curved rim edges of a box cut by a sideways cylinder now makes one
surface.patch command whose result is a single smooth face over the rim.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:37:01 -04:00
NOWN-DEV af0440aaab feat: sketches on a sheet metal face attach to its panel (#474)
A sketch on a flat face of a sheet metal body was refused ("this face has no exact planar
source"), so the only way was a plane frozen in space. When the sheet changes (a flange's
angle, the thickness), the frozen sketch stays behind: a cut from it fails on the slanted
flange or leaves a blind pocket in the thicker sheet.

`sketch.create` with a face of a sheet metal body now attaches the sketch to the panel the
face belongs to. It keeps the face's name, `sheet:<body>:<panel>:<top|bottom>` (the panel is
the base or a flange's feature tag), and takes the panel's exact plane from its flat region
and fold each time the model is evaluated, so the sketch follows the sheet through parameter
changes, unfold and refold. Its coordinates are the flat pattern's (the bottom face's y
mirrored). A face that is gone is reported as a lost attachment, never replaced by the
nearest face. The attachment follows a renamed or copied body, shows as `sheet_face` in the
command result and as its own status in Redefine Sketch Plane. `fixed: true` still freezes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:36:58 -04:00
Irakli Kourtis 49956c2797 Sketch on a cylinder primitive's caps; format 15 (#460)
* Sketch on a cylinder primitive's caps (#420)

A solid.cylinder's caps carried no construction certificate, so a sketch
on its top or bottom was refused unless fixed:true (#420's crossed
cylinders, built from primitives).

- kernel: FaceSource::CylinderCap { producer, high, plane };
  Body::with_cylinder_certificate turns a cylinder's two extrude caps into
  certified caps. kernel::cylinder's output is unchanged for its other
  callers.
- doc: FaceCertificate::CylinderCap { high }, named F<id>:top / F<id>:bottom
  as the cylinder's face names already are; New Body and Join cylinders
  are certified, shells carry it as their planar source.
- format 15: designs can store the new certificate kind; version 14
  designs need no change, and older builds ask for an update instead of
  meeting an unknown kind.

Tests: top and bottom attachments follow a height and base edit and a
reopen; crossed joined cylinders keep both caps attachable; a shelled
cylinder's floor keeps its cap identity; malformed certificate JSON is
refused.

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

* Format 15 also covers sketch_axis links (#448)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:32:07 -04:00
selune b24e478bc1 Read design metadata and previews through a bounded seekable archive (#462) 2026-10-12 21:27:49 -04:00
NOWN-DEV 9ab25cfd4a fix: sketches on planes built from the model can project, intersect, include and hold text (#459)
Project, Intersect, Include 3D Geometry and Text resolved the active sketch's
plane without the model, so on a construction plane that is built from geometry
(perpendicular to a plane through a sketch line, through three points, midway
between two faces, tangent, along a path) they stopped with "this plane is built
from the model: evaluate first". Evaluation resolves the same plane against the
model before the sketch, in its component's frame; the sketch commands now do
the same, in one place, for adding a link, refreshing the links on an edit,
snapping a point onto an edge and the internal face profiles.

Tests: a sketch on a plane through a sloped sketch line projects a point and a
line of that sketch, follows a parameter that tilts the plane (also after reopen
and after another edit), holds text, intersects a body and includes an edge; the
same on a three-point plane and a midplane.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:27:28 -04:00
Irakli Kourtis 1ae6e9beb4 Ctrl+left drag on empty space orbits in every navigation preset (#471)
A left press with Ctrl (or Cmd) held, no Shift or Alt, on empty space orbits
for that press, unless the preset binds Ctrl+left itself. It only orbits
once the pointer moves: a Ctrl+click still adds to the selection, and
Ctrl+drag on sketch geometry still drags it without snapping. Works with a
tool active, without adding geometry. Ctrl+box selection from empty space
becomes the orbit; Shift+box selection still adds.

Preferences › Navigation and the UI tour mention it. Test drives every
preset through real pointer events.


Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:27:20 -04:00
Irakli Kourtis fc1d70cc3b docs: describe the sketch solver as it is (#475)
The README called the solver damped Gauss–Newton. It now places what the
constraints fix by construction (loci intersections and rigid-cluster
decomposition, after Owen 1991 and Fudos–Hoffmann 1997) and runs
Levenberg–Marquardt on the rest, with the rank of its Jacobian giving the
remaining degrees of freedom. The constraint list also gains the ones it was
missing (point on curve, curvature, point-to-line, arc sweep, arc length, slot
length), and live drags are described.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:26:54 -04:00
bherbruck dd67472252 Orbit from empty space turns around the visible geometry (#464)
With "Orbit around: Point under the cursor", a drag that starts over
empty space used to pivot on the point under the cursor at the target's
depth, so an orbit begun near the edge of the view swung the model
around an off-screen point. It now pivots on the centre of the visible
bodies and sketches (the same bounds Fit frames), or on the view centre
when nothing is visible. A drag that starts on the model is unchanged.


Claude-Session: https://claude.ai/code/session_01HWLPLy7ug7URSK5WybjRNa

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:01:49 -04:00
bherbruck 9062c739d8 docs: link preview cards for the docs site and web app (#457)
* docs: add OpenGraph and Twitter card tags to the book and web app

Links to the docs site and the browser app now unfurl with a preview card.
The book gets the tags through theme/head.hbs (og:title follows the page
title); the web app's index.html carries its own, pointing at /app/. Both
use the social preview image, now served from the book at /solidcraft/og.png.

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

* docs: list the social preview image in ATTRIBUTION.md

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:45:21 -04:00
NOWN-DEV fb4c1d74b9 feat: edge flanges with their own angle and height per edge (#447)
One edge flange feature gave all its flanges one height and one angle. A
part whose flanges lean differently took one feature per angle, and
flanges of separate features don't close toward each other, so its
corners stayed open.

- sheet.flange (edge): an entry of `edges` is a point [x, y, z] as before,
  or {point, angle?, height?} whose angle and height (expressions, checked
  like the feature's) replace the feature's for that edge. Bad input is
  refused with the edge named. Before, the object form was read for its
  point and the rest was ignored without a word.
- The feature stores its edges as FlangeEdge. An edge without values of
  its own is written as the plain point it always was, so designs without
  them are read and written as before; an unknown key in an edge is
  refused, not dropped.
- The edges' values are feature inputs of their own (Edge angle, Edge
  height, after the feature's), so parameters, renames and
  parameters.change reach them. timeline.edit names the edges again when
  it changes them, so each value stays with its edge.
- SheetBody::add_flanges_each trims every edge by its own setback and
  adds the flanges. The corners between them close as between equal
  flanges: the closing already works from the two flanges it joins (each
  one's fold and leg), so the flat pattern follows by hand from the
  angles, the heights and the rule (the tests derive it).
- The Flange dialog shows a row per picked edge with an angle and a
  height box (empty: the dialog's value) once more than one edge is
  picked, and brings them back on Edit Feature.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:33:49 -04:00
Irakli Kourtis 0dee9deec1 Sketch constraints accept x / y for the sketch's own axes (#394) (#448)
`sketch.constraint.symmetry` (line), `parallel`, `perpendicular`,
`collinear`, `tangent` and `coincident` (point on curve) now take `x` /
`y` for the sketch's own axes, matching what `solid.revolve` means by
lowercase x|y. A real curve with that id still wins.

An axis becomes a linked construction line on it: a new
`LinkSource::SketchAxis` resolved in sketch coordinates, so it stays on
the sketch's axis on any plane, is shared by every constraint on that
axis, is left out of profiles and is reported in `created`. Equal,
concentric, midpoint and horizontal/vertical keep refusing axes, since
the axis line's finite length or fixed direction would make them
meaningless.


Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:31:35 -04:00
Irakli Kourtis 722bd007c8 Join keeps exact face attachments on faces it leaves unchanged (#420) (#453)
A Union dropped every construction certificate, so after any join no face
could take a durable sketch: sketch.create failed with "this face has no
exact planar source" even on a face the join never touched (#420, and the
"CREATE SKETCH: operation failed" reports #344, #195).

- kernel: map planar sources through a Union from both operands, with the
  same exact-support rule Cut uses. Coplanar supports of the two operands
  carry distinct identities, so a face merged from both stays without one.
- doc: Join extrude and Join box tools are certified like new bodies; a
  cap moved off its designed height (into the target, or past a curved To
  face) is not certified. Join producers are accepted when certifying.

Tests: both operands' faces attach and follow an upstream edit and a
reopen; a merged coplanar face is refused and fixed:true works; a face a
join covers whole is lost without moving to the new roof. Tests that used
a joined face as their "uncertified" example now use a merged face; the
trimmed-face case in a_sketch_on_a_face_stays_on_that_face now stays
attached to the surviving piece.


Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:31:32 -04:00
NOWN-DEV 9e60080b10 fix: fit the plane of a thin sewn face about its own centre (#441)
kernel::sew refused a solid with a thin planar face away from the origin: a slab
30 x 2 mm and 0.00013 mm thick with a corner at (-300, -200, 5), turned 0.45 rad
about x, failed with "sew: face 1: cannot attach a plane to a wire that is not on
one plane", though its faces are flat to 1e-14 mm.

truck's plane fit sums the cross products of a boundary's control points about a
centre and takes the points' sum for that centre (it divides by the number of wires,
not of points): eight times as far from the origin as a rectangle of four lines. The
sum is the same about any centre, but its rounding grows with the square of the
centre's distance, and on a thin face, whose normal is short, it turns the normal far
enough for truck's own 1e-6 flatness test to fail: truck finds the slab's four thin
faces 3.0e-7 to 2.1e-6 mm out of the planes it fitted.

A planar face keeps truck's plane wherever truck gives one. Where truck refuses, the
same fit is made on a copy of the boundary moved so that the mean of its control
points is the origin, and the plane is moved back; a boundary that leaves its plane
or has no area is refused as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:27:13 -04:00
NOWN-DEV 455ca86a6c fix: write each lump of a body as its own STEP solid (#455)
A body of several pieces (a plate and the islands a cut leaves inside its
holes, such as letter counters) was written as one BREP_WITH_VOIDS whose
other shells were voids. Export now writes one solid per lump, each with
its face colours; a real void stays a void of its lump.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:27:09 -04:00
bherbruck 6fde5ff55b Update funding information in FUNDING.yml 2026-10-12 20:26:45 -04:00
Mqrius 7f92093cc8 fix(kernel): revolve with edges on the axis is built around the origin, then moved (#452)
A full revolve of a profile with more than one edge on the axis (a spherical shell's half ring:
both radial lines lie on it) took the generic sweep, built in place. Truck tessellates the
degenerate faces those edges sweep reliably only with the axis through the world origin, so on a
sketch plane off it the body measured about a third too large (a shell from r 18.5 to 40, 10 mm
above XY: 324 339 mm³ instead of 241 561), and booleans with it failed or removed the wrong side.

Such profiles are now swept about an axis through the origin and translated into place, as
revolve_touching_axis already does for profiles with one edge on the axis.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:25:46 -04:00
selune ddd528abaf fix(ui): accept toolbar drops across panel labels and gaps (#454) 2026-10-12 20:25:38 -04:00
selune 5c7149e57d fix(ui): put Point on the default sketch toolbar (#445) 2026-10-12 20:13:31 -04:00
268 changed files with 41519 additions and 1697 deletions
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: [bherbruck]
ko_fi: bherbruck
+71 -51
View File
@@ -5,58 +5,76 @@ parameter to watch the timeline rebuild. [Documentation](https://bherbruck.githu
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
**Why 0.6:** this is a minor bump because two things change the files you work with: the
rename to SolidCraft and the new design file format. Files from earlier builds open fine, but
builds 0.5 and older can't open designs saved by 0.6.
**Why 0.8:** this is a minor bump because the design file format moved to version 16, and
because it adds many new features.
**What's new**
- **New design file format.** Saves are a zip container by default; plain JSON is still one
click away (details below).
- **Sheet metal:** flange corners close at any angle, and cut-outs fold across bends at any
angle, including curved bends.
- **Crash reports:** after a crash, Help ▸ Crash Reports… shows what happened with a ready-to-file
issue draft. Reports stay on your computer; nothing is sent. `solidcraft-cli crashes` lists and
replays them.
- **Snap package** for x86_64 and aarch64 on every release, alongside the AppImage, `.deb`, `.rpm`
and Flatpak.
- **Trackpad navigation** (two-finger pan, pinch zoom), a fillet option that shrinks a radius too
big for its edges, cleaner dialogs and toolbar panels, and the unfinished Generative Design
workspace is gone.
- Many modelling fixes: extrude/revolve in moved components, To Object picks, very flat arcs,
touching lines and arcs in booleans, and `solidcraft-cli run` now fails when a feature fails.
- **Face actions work on far more faces.** Sketches, offset planes and press-pull now attach to
Cut, Hole and chamfer faces, each pattern or mirror copy, side walls a Join merged and the
pieces a later feature split a face into
([#625](https://github.com/bherbruck/solidcraft/pull/625)), to moved or pasted components
([#610](https://github.com/bherbruck/solidcraft/pull/610)) and beside long thin holes
([#629](https://github.com/bherbruck/solidcraft/pull/629)). Across the test models, flat-face
actions pass 70% of the time, up from 61% (sketch 52% → 71%, offset plane 52% → 71%,
press-pull 41% → 60%).
- **Fillet and chamfer** handle a single edge whose ends meet curved or slanted faces, and blend
smooth chains of edges ([#646](https://github.com/bherbruck/solidcraft/pull/646)): 83% → 93%
of edges.
- **New:** user-defined physical materials stored in the design, linked components (insert
another design), saved body visibility, named views through `view.apply`, sketch text fonts,
corner reliefs, flanges on flanges, edge flanges that trim the base along their edge, grouped
sketch tools, Home thumbnails, and an improved Select Other list.
- **Fixes:** filleting beside a modeled thread no longer freezes the app, a loft standing on a
face fuses with it, and many sheet metal, hole and sketch fixes.
- **Booleans are checked.** A Boolean retry that would change the part away from the tool is now
refused instead of returning wrong geometry, which fixes stray cuts
([#616](https://github.com/bherbruck/solidcraft/issues/616)). MCP feature replies include
warnings.
- **Opening a design** keeps the window draggable, shows progress, and can be cancelled with
Cancel or Esc.
- **Under the hood:** compact MCP replies, a kernel-neutral native B-rep format, and an opt-in
preview from the native cache while a design rebuilds.
- **Quality:** every ordinary action is run on every face, edge, vertex and body of the test
models; CI blocks regressions and the full matrix runs nightly.
- **Security:** private vulnerability reporting is on; see
[SECURITY.md](https://github.com/bherbruck/solidcraft/blob/main/SECURITY.md).
**New file format.** Save and Save As now write `.solidcraft` designs as a zip container
that stores imported STEP, mesh and image data as binary, once each, at full precision, so
designs with imports save and open faster. What that means for you:
- Your existing `.solidcraft` and `.solvecraft` files (plain JSON) still open as before; the
format is detected from the contents.
- Want plain JSON (for diffs or scripts)? Use **File ▸ Save As JSON…**, or pick a
`.json` name in Save As; later saves keep JSON. **File ▸ Export Design as JSON…** writes a
separate JSON copy. From the CLI, `solidcraft-cli run … --save design.json`; from MCP or
scripts, pass `"format": "json"` to `save` / `file.save`.
- SolidCraft 0.5 and older can't open designs saved by 0.6, as a container or as JSON (the
JSON format tag is now `solidcraft/14`). Keep a copy of a file you may still open in 0.5.
**File format 16.** Designs are now format 16 (`solidcraft/16`). SolidCraft 0.7 and older can't
open designs saved by 0.8, as a container or as JSON; keep a copy of a file you may still open
in 0.7. Files from every earlier version still open in 0.8.
**Renamed to SolidCraft.** Everything now uses the new name: the `solidcraft` and
`solidcraft-cli` programs, `.solidcraft` designs, `SOLIDCRAFT_*` environment variables,
settings folders, package names and the `solidcraft` MCP server. Upgrading from SolveCraft:
- `.solvecraft` designs still open (double-click, Open, drag-drop, the CLI and MCP); new saves
are `.solidcraft`.
- Settings, preferences and recovery files are copied to the new folders on first launch; the
old folders are left in place. `SOLVECRAFT_*` environment variables are still read.
- `solvecraft-cli` remains as a deprecated alias for one release; it forwards to
`solidcraft-cli` and prints a note. Agents: `claude mcp add solidcraft -- solidcraft-cli mcp`;
an existing `claude mcp add solvecraft -- solvecraft-cli mcp` setup keeps working.
- Windows: the `.msi` upgrades the existing installation in place (now in
`Program Files\SolidCraft`). Linux: the `solidcraft` `.deb`/`.rpm` replaces the `solvecraft`
package. The Flatpak app ID stays `ai.storyteller.solvecraft`, so `flatpak update` just works,
and existing AppImages find the update.
- macOS: quit the app, move the old `SolveCraft.app` to the Trash (your designs and settings
stay), then drag `SolidCraft.app` from the DMG onto Applications and pin it in the Dock.
- Details: [compatibility notes](https://github.com/bherbruck/solidcraft/blob/main/docs/compatibility.md).
**Linux app ID.** The Linux app ID is now `ai.storyteller.solidcraft` (it was
`ai.storyteller.solvecraft`). Flatpak users from the SolidCraft feed: `flatpak update` reports
the old app as end-of-life and asks to replace it; say yes, and your settings come along. The
`.deb`, `.rpm` and AppImage remove the old menu entry on upgrade or first launch.
Documentation, the browser app and the Flatpak feed use the SolidCraft project Pages URLs;
old-name Pages addresses are not permanent aliases.
**Known limitations**
- Some faces still can't take an attached sketch: after Revolve, Offset Face or Boss
([#494](https://github.com/bherbruck/solidcraft/issues/494)), sheet metal thickness faces
([#495](https://github.com/bherbruck/solidcraft/issues/495)), Loft, Sweep and Coil faces
([#561](https://github.com/bherbruck/solidcraft/issues/561)), faces after a Draft or a tapered
Extrude ([#563](https://github.com/bherbruck/solidcraft/issues/563)) and a Shell's rim
([#564](https://github.com/bherbruck/solidcraft/issues/564)).
- A Hole on a sheet metal face is refused
([#560](https://github.com/bherbruck/solidcraft/issues/560)); some through-all holes along a
wall or beside curved faces still fail
([#559](https://github.com/bherbruck/solidcraft/issues/559)).
- Some fillets are still refused, such as along an outline of lines and arcs
([#431](https://github.com/bherbruck/solidcraft/issues/431)).
- Cutting a coaxial cylinder from a thin disc can lose a little volume
([#500](https://github.com/bherbruck/solidcraft/issues/500)); an edge flange with different
per-edge angles leaves gaps at the corners
([#501](https://github.com/bherbruck/solidcraft/issues/501)).
- More in [ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
**Upgrading from 0.5 or earlier.** 0.6 renamed SolveCraft to SolidCraft and made the zip
container the default save format. `.solvecraft` designs, old settings folders and
`SOLVECRAFT_*` variables are still picked up, and `solvecraft-cli` still forwards to
`solidcraft-cli`. Want plain JSON? Use **File ▸ Save As JSON…** or `--save design.json`. On
macOS, replace `SolveCraft.app` with `SolidCraft.app`. Details:
[compatibility notes](https://github.com/bherbruck/solidcraft/blob/main/docs/compatibility.md).
Documentation, the browser app and the Flatpak feed use the SolidCraft project Pages URLs.
**Which file?**
- **Windows:** `SolidCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
@@ -78,10 +96,13 @@ installing SolidCraft; keep both remotes in the same user installation:
```sh
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak remote-add --user --if-not-exists solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo
flatpak install --user solidcraft ai.storyteller.solvecraft
flatpak run ai.storyteller.solvecraft
flatpak install --user solidcraft ai.storyteller.solidcraft
flatpak run ai.storyteller.solidcraft
```
Flatpak installs from 0.7 or earlier use the old ID `ai.storyteller.solvecraft`: `flatpak update --user`
offers to replace it with `ai.storyteller.solidcraft`, and settings move along.
Single-file `.flatpak` bundles include the runtime repository address; missing runtimes
still need an internet connection. [Linux installation details](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
@@ -89,6 +110,5 @@ The builds aren't signed yet. On macOS, right-click `SolidCraft.app` in Applicat
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolidCraft.app`).
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
Licences are built in (Help ▸ About ▸ Licences, `solidcraft-cli licences`); the packages also
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
[ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
install `THIRD-PARTY-LICENSES.txt`.
+42 -1
View File
@@ -29,6 +29,19 @@ env:
CARGO_TERM_COLOR: always
jobs:
dependency-advisories:
name: Dependency advisories
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- name: Install advisory scanner
run: cargo install cargo-deny --version 0.20.2 --locked
- name: Check locked dependencies
run: cargo deny --locked check --warn unmaintained advisories bans sources
changes:
name: Select checks
runs-on: ubuntu-24.04
@@ -172,6 +185,34 @@ jobs:
path: target/nextest/ci/junit.xml
retention-days: 7
action-matrix:
# Every ordinary action on every face, edge, vertex and body of the design corpus, against
# tests/action_matrix/baseline.json (docs/action-matrix.md). Every test run checks a sample;
# the whole matrix runs nightly (or by hand), a quarter per job.
name: Action matrix (${{ matrix.part }}/4)
needs: build-tests
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
part: [1, 2, 3, 4]
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: Run a quarter of the matrix
env:
PART: ${{ matrix.part }}
run: |
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --run-ignored only -E "binary(action_matrix) & test(=action_matrix_full_$PART)"
doc-tests:
name: Doc tests
needs: changes
@@ -315,4 +356,4 @@ jobs:
run: |
. "$HOME/.cargo/env"
export CRAFT_FONTS_DIR="$PWD/craft-fonts"
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
+8 -4
View File
@@ -53,9 +53,13 @@ jobs:
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/solidcraft.wxs packaging/windows/solidcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.solvecraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solvecraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solidcraft'"
desktop-file-validate packaging/linux/ai.storyteller.solidcraft.desktop
# The window's X11 class is its app id; the old id stays only as the rename shims.
grep -qx 'StartupWMClass=ai.storyteller.solidcraft' packaging/linux/ai.storyteller.solidcraft.desktop
grep -q '<id>ai.storyteller.solvecraft</id>' packaging/linux/ai.storyteller.solidcraft.metainfo.xml.in
grep -qF -e '--end-of-life-rebase=' packaging/flatpak-site.sh
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solidcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solidcraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solidcraft' and m['command'] == 'solidcraft'"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/snap/snapcraft.yaml')); assert m['name'] == 'solidcraft' and m['confinement'] == 'strict' and m['apps']['solidcraft']['command'] == 'usr/bin/solidcraft'"
python3 -m py_compile packaging/linux/snap-smoke.py
+4 -3
View File
@@ -37,8 +37,9 @@ Generated-in-code assets are original and have no file to list:
| assets/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
| assets/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/icon.png | 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/enclosure-demo-0.2.1.gif | SolidCraft contributors | original electronics enclosure screen recording of the actual SolidCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
| docs/screenshots/enclosure-demo-0.2.1.mp4 | SolidCraft contributors | original electronics enclosure screen recording of the actual SolidCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.6.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.6.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
| docs/book/src/og.png | SolidCraft contributors | original 1280 × 640 social preview card (the SolidCraft mark, name and tagline), served by the docs site for link previews | MIT OR Apache-2.0 |
| docs/book/src/generated/sample-plate.png | SolidCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/bracket.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/enclosure.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
@@ -93,7 +94,7 @@ Generated-in-code assets are original and have no file to list:
| Directory | Source | Licence | Changes |
|---|---|---|---|
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, 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 (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) |
+7
View File
@@ -100,6 +100,8 @@ The [README](README.md#architecture) has the layer table. The short version:
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).
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
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
`cargo xtask assets` enforces this.
@@ -166,6 +168,11 @@ Pull requests skip a few heavy tests (one to four minutes each), which run on ev
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
**The action matrix** tries every ordinary action on every face, edge, vertex and body of a
corpus of designs against a checked-in ratchet ([docs/action-matrix.md](docs/action-matrix.md)).
A pull request that makes an action pass should record it with `cargo xtask action-matrix --bless`;
one that makes a passing action fail fails the sample every CI run checks.
**The reference tests** are 76 parts in [`tests/oracle/`](tests/oracle/), each a `recipe.json`
in our own format with an `expected.json` beside it. SolidCraft rebuilds the recipe and compares
body count, volume, area, centre of mass and bounding box (and, where there is a `reference.stl`,
Generated
+17 -25
View File
@@ -2765,15 +2765,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -3290,14 +3281,14 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.6.0"
version = "0.8.0"
dependencies = [
"eframe",
"egui",
"embed-resource",
"hidapi",
"log",
"quick-xml 0.38.4",
"quick-xml",
"rfd",
"serde_json",
"solidcraft-engine",
@@ -3307,7 +3298,7 @@ dependencies = [
[[package]]
name = "solidcraft-cli"
version = "0.6.0"
version = "0.8.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3316,7 +3307,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.6.0"
version = "0.8.0"
dependencies = [
"log",
"serde",
@@ -3331,7 +3322,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.6.0"
version = "0.8.0"
dependencies = [
"log",
"serde",
@@ -3347,7 +3338,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.6.0"
version = "0.8.0"
dependencies = [
"serde",
"serde_json",
@@ -3356,9 +3347,9 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.6.0"
version = "0.8.0"
dependencies = [
"quick-xml 0.38.4",
"quick-xml",
"serde",
"serde_json",
"sha2",
@@ -3371,10 +3362,11 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.6.0"
version = "0.8.0"
dependencies = [
"log",
"serde",
"serde_json",
"solidcraft-geom",
"thiserror 2.0.21",
"truck-meshalgo",
@@ -3386,7 +3378,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.6.0"
version = "0.8.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3394,7 +3386,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.6.0"
version = "0.8.0"
dependencies = [
"image",
"serde",
@@ -3404,7 +3396,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.6.0"
version = "0.8.0"
dependencies = [
"serde",
"serde_json",
@@ -3415,7 +3407,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.6.0"
version = "0.8.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -3430,7 +3422,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.6.0"
version = "0.8.0"
dependencies = [
"eframe",
"egui",
@@ -4181,7 +4173,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.41.0",
"quick-xml",
"quote",
]
@@ -4853,7 +4845,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.6.0"
version = "0.8.0"
dependencies = [
"serde_json",
]
+2 -2
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.6.0"
version = "0.8.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -40,7 +40,7 @@ truck-topology = "0.6"
# packages (3MF)
zip = { version = "3", default-features = false, features = ["deflate"] }
quick-xml = "0.38"
quick-xml = "0.41"
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
+13 -8
View File
@@ -6,9 +6,9 @@ Sketch, constrain, and build solids with a parametric feature timeline. Change a
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
model through the built-in MCP server.
![SolidCraft: draw a rounded electronics enclosure, sketch its USB port directly on a face, add four mounting bosses and resize it through Parameters](docs/screenshots/enclosure-demo-0.2.1.gif)
![SolidCraft 0.6: model a Raspberry Pi 4 case from an empty design: a sketch dimensioned with parameters, extrude, fillets and shell, standoffs patterned on the board's mounting holes, port cut-outs, vent slots, a colour and a height change in Parameters](docs/screenshots/demo-0.6.gif)
*Start with an empty design and draw the original parametric electronics enclosure in the English desktop UI: rounded corners, a shell, a USB port sketched directly on the wall and a four-boss pattern. Change width from 80 → 100 mm and wall thickness from 2 → 3 mm in Parameters and watch it rebuild. Original animated pointer and click cues; 4× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/enclosure-demo-0.2.1.mp4), generated with the [demo script](examples/demo/record_ui.sh).*
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots and a colour, then height 24 → 32 mm in Parameters and it all rebuilds. 6× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.6.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
## Download
@@ -81,11 +81,15 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
the boundary and keeps existing extrusions usable.
Curve drawing and picking adapt together to zoom and display scale, including small circles
at Fit and sketches in placed components.
- **Constraints and dimensions** solved by our own solver (damped Gauss–Newton with
degree-of-freedom analysis): coincident, horizontal/vertical, parallel, perpendicular, tangent,
equal, concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
radius, diameter and angle dimensions driven by parameters. Over-constraining edits are
rejected; fully constrained geometry turns black.
- **Constraints and dimensions** solved by our own solver. Points the constraints fix relative
to known geometry are placed exactly by construction (loci intersections and rigid-cluster
decomposition, after Owen 1991 and Fudos–Hoffmann 1997); Levenberg–Marquardt solves the rest,
and the rank of its Jacobian gives the remaining degrees of freedom. Constraints: coincident,
point on curve, horizontal/vertical, parallel, perpendicular, tangent, curvature (G2), equal,
concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
point-to-line, radius, diameter, angle, arc sweep, arc length and slot length dimensions driven
by parameters. Over-constraining edits are rejected; fully constrained geometry turns black.
Dragging solves the sketch every frame and rebuilds the model once, when the drag ends.
Select a line or its two endpoints, type its dimension and press Enter to commit one undo
step. Fit keeps sketch geometry clear of the palette and navigation controls.
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
@@ -108,7 +112,8 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
separately from through-hole outlines in the flat preview and DXF.
- **Files**: design containers (`.solidcraft`) with lossless binary mesh payloads;
plain JSON remains supported for opening, Save As and export. Desktop Open can show a saved
experimental preview (explicit `preview: true` engine saves) while checking geometry before editing. Export to STEP (AP242 with body names, colours and
provisional picture from warm geometry caches while rebuilding before editing; caches never
replace the parametric design. Export to STEP (AP242 with body names, colours and
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;
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
+47 -7
View File
@@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M7 | 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 | 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, 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. 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 |
| 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 | in progress | 36 |
| 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) chosen by the rule | 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 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
@@ -61,6 +61,22 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Dimensioning and error reporting
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
`batch`: body changes and created sketch IDs stay, while repeated lists are omitted.
Both tools retain full replies by default.
- Sketch Create groups drawing variants under Line, Rectangle, Circle, Arc, Slot,
Polygon and Spline. Each open part remembers its last interactive choice per family
across sketch sessions and document-tab switches; closing the part resets these UI choices.
- Inscribed, circumscribed and edge polygon tools preview their actual outline before
placement, including orientation and the selected side count.
- Construction (X) preserves the active sketch drawing tool, picked anchors and inline
dimension values, so line type can change while a shape is in progress.
- Planar sketch construction previews and hover feedback keep the same dashed pattern as
committed geometry; centerlines retain their dash-dot pattern.
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
arguments reject such a name when an older design's construction plane already has it. Use
the other alias, the construction feature's numeric ID or an explicit plane; existing
@@ -145,6 +161,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## View navigation
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
its layout stable during a drag and applies the selected scale on release.
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
@@ -154,6 +173,11 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Known limitations
- Scheduled and manual CI scan locked dependency advisories, bans and sources with
cargo-deny. Vulnerabilities fail the scan; unmaintained crates warn. Local vendored
patches require separate upstream review because advisory databases cannot version-match
their path dependencies. Pull requests retain the existing licence checks.
- Associative shell-face sketches require a unique certified planar source. Nested shell
attachments and ambiguous coincident sources remain unsupported.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
@@ -285,6 +309,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
record their coordinate frame; legacy wires migrate from known provenance without changing
coordinates. Broken legacy links with missing provenance can require explicit refresh or relink.
See [occurrence workflows](docs/occurrence-workflows.md).
- Toolbar drops on panel labels, gaps and margins append commands with insertion feedback;
button drops keep insert-before ordering.
- Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel.
Parameters opens the user/model variable editor; Configurations opens the configuration table.
Configure remains available in the panel dropdown.
@@ -317,6 +343,12 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
exact holes and retained islands, including the union of multiple selected profiles, and
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
tapered or bend-crossing machining refuses atomically instead of restoring material later.
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
A flange on another flange's far edge (a return) follows that flange's closed end and keeps
clear of the flange beside it, and the returns of one feature are mitred toward each other
with the rule's gap where their flanges meet at a corner; returns of separate features, and
flanges on returns, are not closed toward each other.
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
@@ -350,13 +382,21 @@ and reference-object placement remain separate work, as do loft rails and center
- Design containers (#303, phase one) separate and deduplicate STEP, raw images and
lossless binary mesh arrays while keeping plain JSON as a supported Save As/export
format. Generated-mesh storage measurements reduce serialization and decoding work;
evaluated geometry caching and instant cold display remain separate follow-up work.
a seekable reader defers original blobs until after provisional display.
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
- Desktop Open can show an experimental, opt-in bounded saved display preview while rebuilding in the existing
file worker. Ordinary saves omit display caches; explicit `preview: true` saves cache
only already warm meshes. Document hashes and application versions reject
stale previews. Preview geometry cannot be edited or measured, and cancelled or stale
completions cannot replace a newer document. Evaluated B-rep restoration remains open.
- Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology
and geometry records, with a separate truck adapter and bounded binary reader. Exact
face-source certificates and operation history survive codec round trips. A provisional
picture can be generated before original blobs are read; editing and measurement wait
for authoritative reconstruction. Cached metadata binds preview and reconstruction to
the same document despite concurrent file writes. Default saves omit this cache and never force cold
evaluation or meshing; explicit `preview: true` remains a separate warm-mesh option.
Keys, resource budgets and document/revision guards reject incompatible, hostile or
stale results. A second modelling kernel still requires its own tested adapter.
- Warm saves with explicit `cache: true` can also carry a bounded PNG thumbnail from already cached meshes.
Home reads it without rebuilding the design when its local picture is missing;
mismatched or malformed thumbnails fall back to normal generation. The image is
kernel-independent and saving never forces cold meshing to produce it.
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
+32
View File
@@ -0,0 +1,32 @@
# Security policy
## Supported versions
SolidCraft is young and moves quickly, so only the latest release gets security fixes. Please
update to the [latest release](https://github.com/bherbruck/solidcraft/releases/latest) before
reporting, and check whether the problem is still on `main`.
| Version | Supported |
| --- | --- |
| Latest release | ✅ |
| Nightly builds | ✅ (fixes land here first) |
| Older releases | ❌ |
## Reporting a vulnerability
Please **don't open a public issue** for a security problem. Report it privately through GitHub:
open the repository's [Security tab](https://github.com/bherbruck/solidcraft/security) and choose
**Report a vulnerability**
([direct link](https://github.com/bherbruck/solidcraft/security/advisories/new)).
Please include what you found, how to reproduce it (a design file, command or MCP request), the
version and platform, and the impact you expect. I'll acknowledge the report as soon as I can,
keep you updated in the advisory, and credit you when the fix is published unless you'd rather
stay anonymous.
Areas where reports are especially useful:
- opening untrusted design, STEP, STL or 3MF files (crashes, excessive memory or CPU, path tricks)
- the MCP server and the control API (file access outside the allowed folders, command injection)
- the web app (anything reaching beyond the page's own storage)
- release packages and the update paths (AppImage, Flatpak, MSI, deb, rpm, snap)
+2 -3
View File
@@ -1,6 +1,6 @@
Third-party software in SolidCraft
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 454 crates below.
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 453 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
@@ -262,7 +262,6 @@ proc-macro2 1.0.107 (MIT OR Apache-2.0)
profiling 1.0.18 (MIT OR Apache-2.0)
pxfm 0.1.30 (BSD-3-Clause OR Apache-2.0)
quick-error 2.0.1 (MIT/Apache-2.0)
quick-xml 0.38.4 (MIT)
quick-xml 0.41.0 (MIT)
quote 1.0.47 (MIT OR Apache-2.0)
r-efi 5.3.0 (MIT OR Apache-2.0 OR LGPL-2.1-or-later)
@@ -8709,7 +8708,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
quick-xml 0.38.4, quick-xml 0.41.0
quick-xml 0.41.0
================================================================================
The MIT License (MIT)
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -16,7 +16,13 @@ hostile variants of it never panic.
| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) |
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
| `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) |
| `move-bodies` | 00baf1d | `solid.move` before it could copy: two bodies, one translated in place, one translated and turned 90 deg about Z (no `copy` key anywhere in the file) |
| `split-body-pieces` | v0.3.2 (07e263d) | a slot along X cuts a block into two bodies; a fillet, a chamfer and an appearance refer to the pieces by body name (`Body1`, `Body1 (1)`) |
| `issue-489-stacked-join` | v0.6.0 (809ecf7), desktop | a 70 × 84 extrude, then a 70 × 62 strip joined on its top face, sketched partly over the face's projected edges (issue #489; expected values from main at 2880525) |
| `issue-616-lid-ribs` | v0.7.0 (a9879fa), CLI | a rounded lid with a skirt ring, a window and a slot cut through, then four ribs joined on the plate over the skirt, flush with the window's sides; v0.7.0 displaced the ribs and left a slit through the skirt and plate (issue #616; script `examples/regressions/lid_ribs_flush_with_window.json`, expected values from the fix) |
The scripts that made them are in `scripts/`. Never regenerate a file with a newer build: the
point is that it was written by the old one. Add new files for new eras instead.
The scripts that made them are in `scripts/` (`issue-489-stacked-join` was drawn in the desktop
app and has none; `issue-616-lid-ribs` is made by its regression script). Every flat face of every final body must also take an attached sketch and an
offset plane; the faces that can't yet are listed with their issues in `design_corpus.rs`.
Never regenerate a file with a newer build: the point is that it was written by the old one. Add new files for new eras instead.
@@ -0,0 +1,157 @@
{
"bodies": [
{
"area_mm2": 25751.999999999945,
"bbox": {
"max": [
35.0,
42.0,
48.00000000000001
],
"min": [
-35.0,
-42.000000000000014,
0.0
]
},
"center_of_mass": [
5.524909928104648e-14,
-1.0945382096961234e-12,
22.366336633663238
],
"density_g_cm3": 1.29,
"edges": 24,
"face_types": {
"plane": 10
},
"faces": 10,
"kernel": {
"edges": 24,
"faces": 10,
"shells": 1,
"vertices": 16
},
"mass_g": 328.33079999999734,
"material": "Default",
"name": "Body1",
"vertices": 16,
"volume_mm3": 254519.9999999979
}
],
"body_count": 1,
"params": [
{
"comment": "",
"error": null,
"expression": "84",
"model": true,
"name": "d1",
"unit": "mm",
"value": 84.0
},
{
"comment": "",
"error": null,
"expression": "70",
"model": true,
"name": "d2",
"unit": "mm",
"value": 70.0
},
{
"comment": "",
"error": null,
"expression": "62",
"model": true,
"name": "d4",
"unit": "mm",
"value": 62.0
}
],
"sketches": [
{
"curves": 6,
"dof": 0,
"id": 1,
"name": "Sketch1",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 10,
"dof": 1,
"id": 3,
"name": "Sketch2",
"profiles": 3,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 1,
"ms": 0.131943,
"name": "Sketch1",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 2,
"ms": 1.0812709999999999,
"name": "Extrude1",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 3,
"ms": 0.450002,
"name": "Sketch2",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 4,
"ms": 5.191514000000001,
"name": "Extrude2",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
}
],
"total": {
"area_mm2": 25751.999999999945,
"edges": 24,
"faces": 10,
"mass_g": 328.33079999999734,
"vertices": 16,
"volume_mm3": 254519.9999999979
}
}
@@ -0,0 +1,539 @@
{
"features": [
{
"id": 1,
"name": "Sketch1",
"plane": {
"name": "XY",
"type": "origin"
},
"sketch": {
"constraints": [
{
"id": "k1",
"l": 0,
"type": "horizontal"
},
{
"id": "k2",
"l": 2,
"type": "horizontal"
},
{
"id": "k3",
"l": 1,
"type": "vertical"
},
{
"id": "k4",
"l": 3,
"type": "vertical"
},
{
"id": "k5",
"l": 4,
"p": 5,
"type": "midpoint"
},
{
"id": "k6",
"p": 0,
"q": 5,
"type": "coincident"
},
{
"id": "k7",
"l": 1,
"param": "d1",
"type": "length",
"value": 84.0
},
{
"id": "k8",
"l": 0,
"param": "d2",
"type": "length",
"value": 70.0
}
],
"counters": {
"k": 8,
"l": 6,
"p": 5
},
"curves": [
{
"a": 1,
"b": 2,
"construction": false,
"id": "l1",
"type": "line"
},
{
"a": 2,
"b": 3,
"construction": false,
"id": "l2",
"type": "line"
},
{
"a": 3,
"b": 4,
"construction": false,
"id": "l3",
"type": "line"
},
{
"a": 4,
"b": 1,
"construction": false,
"id": "l4",
"type": "line"
},
{
"a": 1,
"b": 3,
"construction": true,
"id": "l5",
"type": "line"
},
{
"a": 2,
"b": 4,
"construction": true,
"id": "l6",
"type": "line"
}
],
"points": [
{
"fixed": true,
"id": "origin",
"pos": [
0.0,
0.0
]
},
{
"fixed": false,
"id": "p1",
"pos": [
-35.0,
-42.0
]
},
{
"fixed": false,
"id": "p2",
"pos": [
35.0,
-42.0
]
},
{
"fixed": false,
"id": "p3",
"pos": [
35.0,
42.0
]
},
{
"fixed": false,
"id": "p4",
"pos": [
-35.0,
42.0
]
},
{
"fixed": false,
"id": "p5",
"pos": [
0.0,
0.0
]
}
]
},
"suppressed": false,
"type": "sketch"
},
{
"extent": {
"direction": "positive",
"distance": "30"
},
"id": 2,
"name": "Extrude1",
"operation": "new_body",
"param_names": [
"d3"
],
"profiles": {
"loops": [
[
"l1",
"l2",
"l3",
"l4"
]
],
"type": "curves"
},
"sketch": 1,
"suppressed": false,
"type": "extrude"
},
{
"id": 3,
"name": "Sketch2",
"plane": {
"anchor": {
"author_path": [],
"component": 0,
"coordinates": [
0.0,
0.0
],
"path": [],
"target": {
"body": "Body1",
"certificate": {
"high": true,
"holes": [],
"kind": "extrude_cap",
"outer": [
"l1",
"l2",
"l3",
"l4"
],
"sketch": 1
},
"name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
"origin_feature": 2,
"point": [
0.0,
0.0,
30.0
]
},
"x_axis": [
1.0,
0.0
]
},
"at": [
2.012802880253332,
7.4635742926698185,
30.000000000000114
],
"name": "F2:top",
"plane": {
"origin": [
0.0,
0.0,
30.0
],
"x_dir": [
1.0,
0.0,
0.0
],
"y_dir": [
0.0,
1.0,
0.0
]
},
"type": "face"
},
"sketch": {
"constraints": [
{
"id": "k1",
"l": 4,
"type": "horizontal"
},
{
"id": "k2",
"l": 6,
"type": "horizontal"
},
{
"id": "k3",
"l": 5,
"type": "vertical"
},
{
"id": "k4",
"l": 7,
"type": "vertical"
},
{
"id": "k5",
"l": 8,
"p": 9,
"type": "midpoint"
},
{
"id": "k6",
"p": 0,
"q": 9,
"type": "coincident"
},
{
"id": "k7",
"p": 7,
"param": "d4",
"q": 6,
"text": [
-0.2,
9.200000000000003
],
"type": "distance",
"value": 62.0
}
],
"counters": {
"j": 1,
"k": 7,
"l": 10,
"p": 9
},
"curves": [
{
"a": 1,
"b": 2,
"construction": false,
"id": "l1",
"link": "j1",
"type": "line"
},
{
"a": 3,
"b": 1,
"construction": false,
"id": "l2",
"link": "j1",
"type": "line"
},
{
"a": 2,
"b": 4,
"construction": false,
"id": "l3",
"link": "j1",
"type": "line"
},
{
"a": 4,
"b": 3,
"construction": false,
"id": "l4",
"link": "j1",
"type": "line"
},
{
"a": 5,
"b": 6,
"construction": false,
"id": "l5",
"type": "line"
},
{
"a": 6,
"b": 7,
"construction": false,
"id": "l6",
"type": "line"
},
{
"a": 7,
"b": 8,
"construction": false,
"id": "l7",
"type": "line"
},
{
"a": 8,
"b": 5,
"construction": false,
"id": "l8",
"type": "line"
},
{
"a": 5,
"b": 7,
"construction": true,
"id": "l9",
"type": "line"
},
{
"a": 6,
"b": 8,
"construction": true,
"id": "l10",
"type": "line"
}
],
"links": [
{
"auto_face": true,
"face_name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
"id": "j1",
"kind": "project",
"source": {
"at": [
11.666666666666666,
-13.999999999999995,
30.0
],
"body": "Body1",
"type": "face"
}
}
],
"points": [
{
"fixed": true,
"id": "origin",
"pos": [
0.0,
0.0
]
},
{
"fixed": true,
"id": "p1",
"link": "j1",
"pos": [
-35.0,
-42.0
]
},
{
"fixed": true,
"id": "p2",
"link": "j1",
"pos": [
-35.0,
42.0
]
},
{
"fixed": true,
"id": "p3",
"link": "j1",
"pos": [
35.0,
-42.0
]
},
{
"fixed": true,
"id": "p4",
"link": "j1",
"pos": [
35.0,
42.0
]
},
{
"fixed": false,
"id": "p5",
"pos": [
-35.0,
-31.000000000002185
]
},
{
"fixed": false,
"id": "p6",
"pos": [
35.0,
-31.00000000000349
]
},
{
"fixed": false,
"id": "p7",
"pos": [
35.0,
30.99999999999603
]
},
{
"fixed": false,
"id": "p8",
"pos": [
-35.0,
30.999999999995318
]
},
{
"fixed": false,
"id": "p9",
"pos": [
0.0,
0.0
]
}
]
},
"suppressed": false,
"type": "sketch"
},
{
"extent": {
"direction": "positive",
"distance": "18"
},
"id": 4,
"name": "Extrude2",
"operation": "join",
"param_names": [
"d5"
],
"profiles": {
"loops": [
[
"l1",
"l5",
"l4",
"l7"
]
],
"type": "curves"
},
"sketch": 3,
"suppressed": false,
"type": "extrude"
}
],
"format": "solidcraft/14",
"name": "Untitled",
"next_id": 6,
"params": [
{
"expr": "84",
"model": true,
"name": "d1",
"unit": "mm"
},
{
"expr": "70",
"model": true,
"name": "d2",
"unit": "mm"
},
{
"expr": "62",
"model": true,
"name": "d4",
"unit": "mm"
}
],
"units": "mm"
}
@@ -0,0 +1,227 @@
{
"bodies": [
{
"area_mm2": 8430.29544246263,
"bbox": {
"max": [
176.8000000000002,
89.30000000000001,
14.100000000000001
],
"min": [
99.99999999999991,
-1.820765760385257e-14,
0.0
]
},
"center_of_mass": [
138.3999999716193,
51.04392591016203,
2.0990634391586322
],
"density_g_cm3": 1.29,
"edges": 156,
"face_types": {
"cylinder": 4,
"plane": 50
},
"faces": 54,
"kernel": {
"edges": 156,
"faces": 54,
"shells": 1,
"vertices": 104
},
"mass_g": 9.101726874183207,
"material": "Default",
"name": "Lid",
"vertices": 104,
"volume_mm3": 7055.602228048997
}
],
"body_count": 1,
"params": [],
"sketches": [
{
"curves": 8,
"dof": 8,
"id": 3,
"name": "Skirt",
"profiles": 2,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 1,
"ms": 1.178957,
"name": "Box1",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 2,
"ms": 83.24342399999999,
"name": "Fillet1",
"rolled_back": false,
"suppressed": false,
"type": "FilletFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 3,
"ms": 0.150889,
"name": "Skirt",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 4,
"ms": 94.465481,
"name": "Extrude1",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 5,
"ms": 50.426736,
"name": "Box2",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 6,
"ms": 40.383005,
"name": "Box3",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 7,
"ms": 59.041461000000005,
"name": "Box4",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [
{
"face_shift_mm": 0.0,
"rotation_rad": 0.0,
"strategy": "planar_fallback",
"tolerance_mm": 0.0,
"translation_mm": 0.0
}
],
"truncated": false
},
"error": null,
"id": 8,
"ms": 562.5477970000001,
"name": "Rib1",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": "Boolean retry: exact planar fallback (no displacement)"
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 9,
"ms": 89.56076,
"name": "Rib2",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 10,
"ms": 65.330634,
"name": "Rib3",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 11,
"ms": 97.229403,
"name": "Rib4",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
}
],
"total": {
"area_mm2": 8430.29544246263,
"edges": 156,
"faces": 54,
"mass_g": 9.101726874183207,
"vertices": 104,
"volume_mm3": 7055.602228048997
}
}
@@ -0,0 +1,507 @@
{
"features": [
{
"body_names": [
"Lid"
],
"corner": [
100.0,
0.0,
0.0
],
"height": "2.4",
"id": 1,
"length": "76.8",
"name": "Box1",
"operation": "new_body",
"param_names": [
"d1",
"d2",
"d3"
],
"suppressed": false,
"type": "box",
"width": "89.3"
},
{
"edge_names": [
"F1:-x|F1:-y",
"F1:+x|F1:-y",
"F1:+y|F1:-x",
"F1:+x|F1:+y"
],
"edge_refs": [
{
"dir": [
0.0,
0.0,
-1.0
],
"max": [
176.8,
89.3,
2.4
],
"min": [
100.0,
0.0,
0.0
]
},
{
"dir": [
0.0,
0.0,
1.0
],
"max": [
176.8,
89.3,
2.4
],
"min": [
100.0,
0.0,
0.0
]
},
{
"dir": [
0.0,
0.0,
1.0
],
"max": [
176.8,
89.3,
2.4
],
"min": [
100.0,
0.0,
0.0
]
},
{
"dir": [
0.0,
0.0,
1.0
],
"max": [
176.8,
89.3,
2.4
],
"min": [
100.0,
0.0,
0.0
]
}
],
"edges": [
[
100.0,
0.0,
1.2
],
[
176.8,
0.0,
1.2
],
[
100.0,
89.3,
1.2
],
[
176.8,
89.3,
1.2
]
],
"id": 2,
"name": "Fillet1",
"param_names": [
"d4"
],
"radius": "3",
"suppressed": false,
"type": "fillet"
},
{
"id": 3,
"name": "Skirt",
"param_names": [
"d5"
],
"plane": {
"base": {
"name": "XY",
"type": "origin"
},
"distance": "2.4",
"type": "offset"
},
"sketch": {
"constraints": [
{
"id": "k1",
"l": 0,
"type": "horizontal"
},
{
"id": "k2",
"l": 2,
"type": "horizontal"
},
{
"id": "k3",
"l": 1,
"type": "vertical"
},
{
"id": "k4",
"l": 3,
"type": "vertical"
},
{
"id": "k5",
"l": 4,
"type": "horizontal"
},
{
"id": "k6",
"l": 6,
"type": "horizontal"
},
{
"id": "k7",
"l": 5,
"type": "vertical"
},
{
"id": "k8",
"l": 7,
"type": "vertical"
}
],
"counters": {
"k": 8,
"l": 8,
"p": 8
},
"curves": [
{
"a": 1,
"b": 2,
"construction": false,
"id": "l1",
"type": "line"
},
{
"a": 2,
"b": 3,
"construction": false,
"id": "l2",
"type": "line"
},
{
"a": 3,
"b": 4,
"construction": false,
"id": "l3",
"type": "line"
},
{
"a": 4,
"b": 1,
"construction": false,
"id": "l4",
"type": "line"
},
{
"a": 5,
"b": 6,
"construction": false,
"id": "l5",
"type": "line"
},
{
"a": 6,
"b": 7,
"construction": false,
"id": "l6",
"type": "line"
},
{
"a": 7,
"b": 8,
"construction": false,
"id": "l7",
"type": "line"
},
{
"a": 8,
"b": 5,
"construction": false,
"id": "l8",
"type": "line"
}
],
"points": [
{
"fixed": true,
"id": "origin",
"pos": [
0.0,
0.0
]
},
{
"fixed": false,
"id": "p1",
"pos": [
102.65,
2.65
]
},
{
"fixed": false,
"id": "p2",
"pos": [
174.15,
2.65
]
},
{
"fixed": false,
"id": "p3",
"pos": [
174.15,
86.65
]
},
{
"fixed": false,
"id": "p4",
"pos": [
102.65,
86.65
]
},
{
"fixed": false,
"id": "p5",
"pos": [
104.15,
4.15
]
},
{
"fixed": false,
"id": "p6",
"pos": [
172.65,
4.15
]
},
{
"fixed": false,
"id": "p7",
"pos": [
172.65,
85.15
]
},
{
"fixed": false,
"id": "p8",
"pos": [
104.15,
85.15
]
}
]
},
"suppressed": false,
"type": "sketch"
},
{
"extent": {
"direction": "positive",
"distance": "3"
},
"id": 4,
"name": "Extrude1",
"operation": "join",
"param_names": [
"d6"
],
"profiles": {
"loops": [
[
"l1",
"l2",
"l3",
"l4"
]
],
"type": "curves"
},
"sketch": 3,
"suppressed": false,
"targets": [
"Lid"
],
"type": "extrude"
},
{
"corner": [
104.4,
5.4,
-1.0
],
"height": "8",
"id": 5,
"length": "68",
"name": "Box2",
"operation": "cut",
"param_names": [
"d7",
"d8",
"d9"
],
"suppressed": false,
"type": "box",
"width": "61"
},
{
"corner": [
116.4,
72.0,
-1.0
],
"height": "8",
"id": 6,
"length": "44",
"name": "Box3",
"operation": "cut",
"param_names": [
"d10",
"d11",
"d12"
],
"suppressed": false,
"type": "box",
"width": "10"
},
{
"corner": [
127.4,
84.0,
2.4
],
"height": "4",
"id": 7,
"length": "22",
"name": "Box4",
"operation": "cut",
"param_names": [
"d13",
"d14",
"d15"
],
"suppressed": false,
"type": "box",
"width": "6"
},
{
"corner": [
102.8,
14.0,
2.4
],
"height": "11.7",
"id": 8,
"length": "1.6",
"name": "Rib1",
"operation": "join",
"param_names": [
"d16",
"d17",
"d18"
],
"suppressed": false,
"type": "box",
"width": "6"
},
{
"corner": [
102.8,
48.0,
2.4
],
"height": "11.7",
"id": 9,
"length": "1.6",
"name": "Rib2",
"operation": "join",
"param_names": [
"d19",
"d20",
"d21"
],
"suppressed": false,
"type": "box",
"width": "6"
},
{
"corner": [
172.4,
14.0,
2.4
],
"height": "11.7",
"id": 10,
"length": "1.6",
"name": "Rib3",
"operation": "join",
"param_names": [
"d22",
"d23",
"d24"
],
"suppressed": false,
"type": "box",
"width": "6"
},
{
"corner": [
172.4,
48.0,
2.4
],
"height": "11.7",
"id": 11,
"length": "1.6",
"name": "Rib4",
"operation": "join",
"param_names": [
"d25",
"d26",
"d27"
],
"suppressed": false,
"type": "box",
"width": "6"
}
],
"format": "solidcraft/15",
"name": "issue-616-lid-ribs",
"next_id": 12,
"params": [],
"units": "mm"
}
@@ -0,0 +1,194 @@
{
"bodies": [
{
"area_mm2": 2400.0,
"bbox": {
"max": [
50.0,
20.0,
20.0
],
"min": [
30.0,
0.0,
0.0
]
},
"center_of_mass": [
40.0,
10.0,
10.0
],
"density_g_cm3": 1.29,
"edges": 12,
"face_types": {
"plane": 6
},
"faces": 6,
"kernel": {
"edges": 12,
"faces": 6,
"shells": 1,
"vertices": 8
},
"mass_g": 10.32,
"material": "Default",
"name": "Block",
"vertices": 8,
"volume_mm3": 8000.0
},
{
"area_mm2": 600.0,
"bbox": {
"max": [
3.552713678800501e-15,
90.0,
10.0
],
"min": [
-9.999999999999998,
80.0,
0.0
]
},
"center_of_mass": [
-4.999999999999999,
85.0,
5.000000000000001
],
"density_g_cm3": 1.29,
"edges": 12,
"face_types": {
"plane": 6
},
"faces": 6,
"kernel": {
"edges": 12,
"faces": 6,
"shells": 1,
"vertices": 8
},
"mass_g": 1.29,
"material": "Default",
"name": "Peg",
"vertices": 8,
"volume_mm3": 1000.0
}
],
"body_count": 2,
"params": [],
"sketches": [
{
"curves": 4,
"dof": 2,
"id": 1,
"name": "Base",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 4,
"dof": 4,
"id": 3,
"name": "Peg",
"profiles": 1,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 1,
"ms": 0.204585,
"name": "Base",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 2,
"ms": 2.318662,
"name": "Extrude1",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 3,
"ms": 0.109196,
"name": "Peg",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 4,
"ms": 0.338859,
"name": "Extrude2",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 5,
"ms": 0.312259,
"name": "Move1",
"rolled_back": false,
"suppressed": false,
"type": "MoveFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 6,
"ms": 0.23144699999999999,
"name": "Move2",
"rolled_back": false,
"suppressed": false,
"type": "MoveFeature",
"warning": null
}
],
"total": {
"area_mm2": 3000.0,
"edges": 24,
"faces": 12,
"mass_g": 11.61,
"vertices": 16,
"volume_mm3": 9000.0
}
}
@@ -0,0 +1 @@
{"features":[{"id":1,"name":"Base","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"},{"id":"k5","p":0,"q":1,"type":"coincident"}],"counters":{"k":5,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[0.0,0.0]},{"fixed":false,"id":"p2","pos":[20.0,0.0]},{"fixed":false,"id":"p3","pos":[20.0,20.0]},{"fixed":false,"id":"p4","pos":[0.0,20.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Block"],"extent":{"direction":"positive","distance":"20"},"id":2,"name":"Extrude1","operation":"new_body","param_names":["d1"],"profiles":{"type":"all"},"sketch":1,"suppressed":false,"type":"extrude"},{"id":3,"name":"Peg","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"}],"counters":{"k":4,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[40.0,0.0]},{"fixed":false,"id":"p2","pos":[50.0,0.0]},{"fixed":false,"id":"p3","pos":[50.0,10.0]},{"fixed":false,"id":"p4","pos":[40.0,10.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Peg"],"extent":{"direction":"positive","distance":"10"},"id":4,"name":"Extrude2","operation":"new_body","param_names":["d2"],"profiles":{"type":"all"},"sketch":3,"suppressed":false,"type":"extrude"},{"bodies":["Block"],"id":5,"name":"Move1","param_names":["d3","d4","d5"],"suppressed":false,"translate":["30","0","0"],"type":"move"},{"angle":"90 deg","bodies":["Peg"],"id":6,"name":"Move2","param_names":["d6","d7","d8","d9"],"rotate_axis":[0.0,0.0,1.0],"suppressed":false,"translate":["0","40","0"],"type":"move"}],"format":"solidcraft/15","name":"old-move-bodies","next_id":7,"params":[],"units":"mm"}
@@ -0,0 +1,14 @@
{
"commands": [
{"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [20, 20]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 20, "body_name": "Block"}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "Peg"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [40, 0], "p1": [50, 10]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 10, "operation": "new", "body_name": "Peg"}},
{"command": "solid.move", "params": {"bodies": ["Block"], "translate": [30, 0, 0]}},
{"command": "solid.move", "params": {"bodies": ["Peg"], "translate": [0, 40, 0], "axis": [0, 0, 1], "angle": "90 deg"}}
]
}
+149
View File
@@ -131,6 +131,155 @@ fn body_names_saved_before_exact_history_keep_their_pieces() {
assert!(s.doc.appearances.bodies.contains_key("Body1 (1)"));
}
/// Flat faces that can't take an attached sketch yet, by cause: (design, body, the faces'
/// planes as outward normal and distance from the origin, or every flat face of the body,
/// issue). Face indices are not used: they can change between builds. Each issue has an
/// ignored test below that checks its faces; a listed plane whose face attaches fails the main
/// check until it is removed from here.
type Planes = &'static [([f64; 3], f64)];
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 5] = [
("m0-revolve-primitives", "Body1", None, 494),
("naming-split-pieces", "Body1", None, 494),
("modern-plastic", "Body1", None, 494),
("modern-sheet-metal", "Body1", None, 495),
("issue-616-lid-ribs", "Lid", None, 628),
];
/// The issue a face is listed under, and whether it is listed by its plane (not as every face).
fn known_without_attachment(design: &str, body: &str, normal: [f64; 3], distance: f64) -> Option<(u32, bool)> {
let same = |(n, d): &([f64; 3], f64)| (0..3).all(|i| (n[i] - normal[i]).abs() < 1e-6) && (d - distance).abs() < 1e-4;
NO_ATTACHMENT_YET.iter().find_map(|(d, b, planes, issue)| {
if *d != design || *b != body {
return None;
}
match planes {
None => Some((*issue, false)),
Some(planes) => planes.iter().any(same).then_some((*issue, true)),
}
})
}
fn undo_to(s: &mut Session, features: usize) {
for _ in 0..4 {
if s.doc.features.len() <= features {
return;
}
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(s.doc.features.len(), features, "undo did not remove the added feature");
}
/// Try a sketch and an offset plane on every flat face of every final body of every corpus
/// design, through the same face pick the desktop sends (body, face index, occurrence path, a
/// point on it), without `fixed`. Undo leaves the design as it was for the next face. Returns
/// (design, body, face, issue it is known under, what went wrong or None).
fn flat_face_attachments() -> Vec<(String, String, usize, Option<(u32, bool)>, Option<String>)> {
use solidcraft_engine::doc::{FaceAttachmentStatus, apply_point};
let mut out = Vec::new();
for p in corpus() {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let mut s = open(&p);
let state = s.model.state();
let placed = s.placed_document();
let mut picks = Vec::new();
for (world, definition, component, path, frame) in placed.body_frames_with_source(&state) {
let Some(body) = state.bodies.iter().find(|b| b.definition_name() == definition && placed.model_body_component(b) == component) else {
continue;
};
let mesh = body.body.tessellate(0.01).unwrap();
for index in 0..body.body.face_count() {
if body.body.planar_face(index).is_none() {
continue;
}
// The middle of the face's largest triangle: well inside the face.
let point = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, f)| **f as usize == index)
.filter_map(|(t, _)| mesh.tri(t))
.max_by(|[a, b, c], [d, e, f]| (*b - *a).cross(*c - *a).len().total_cmp(&(*e - *d).cross(*f - *d).len()))
.map(|[a, b, c]| (a + b + c) * (1.0 / 3.0));
let Some(point) = point else { continue };
let point = apply_point(&frame, point);
let normal =
body.body.planar_face(index).map(|plane| solidcraft_engine::doc::apply_vector(&frame, plane.normal())).unwrap_or_default();
let key = ([normal.x, normal.y, normal.z], normal.dot(point));
picks.push((world.clone(), index, key, json!({"face": point, "body": world, "index": index, "path": path})));
}
}
let before = s.doc.features.len();
for (body, index, (normal, distance), plane) in picks {
let mut problem = None;
match s.execute("sketch.create", &json!({"plane": plane})) {
Ok(created) => {
let id = created["sketch"].as_u64().unwrap_or(0);
match s.placed_document().sketch_face_attachment(id, &s.model) {
FaceAttachmentStatus::Healthy { .. } | FaceAttachmentStatus::Sheet { .. } => {}
other => problem = Some(format!("sketch attachment {other:?}")),
}
s.execute("sketch.finish", &json!({})).unwrap();
undo_to(&mut s, before);
}
Err(e) => problem = Some(format!("sketch.create: {e}")),
}
match s.execute("construct.plane.offset", &json!({"base": plane, "offset": 5})) {
Ok(_) => undo_to(&mut s, before),
Err(e) => problem = Some(problem.map_or(String::new(), |p| p + "; ") + &format!("construct.plane.offset: {e}")),
}
let known = known_without_attachment(&name, &body, normal, distance);
out.push((name.clone(), body, index, known, problem));
}
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{name}: left with errors after undo");
}
out
}
/// Every flat face of every corpus design takes a durable attachment (issue #489: the top face
/// of an extrude sketched over a face's projected boundary had "no exact planar source"),
/// except the faces listed in [`NO_ATTACHMENT_YET`] under their open issues.
#[test]
fn every_flat_face_of_every_corpus_design_takes_an_attached_sketch() {
let all = flat_face_attachments();
assert!(all.len() > 50, "only {} faces checked", all.len());
let problems: Vec<String> = all
.iter()
.filter_map(|(design, body, face, known, problem)| match (known, problem) {
(None, Some(problem)) => Some(format!("{design}: {body} face {face}: {problem}")),
(Some((issue, true)), None) => Some(format!("{design}: {body} face {face} attaches now: remove it from NO_ATTACHMENT_YET (#{issue})")),
_ => None,
})
.collect();
assert!(problems.is_empty(), "{} of {} faces:\n{}", problems.len(), all.len(), problems.join("\n"));
}
fn known_faces_attach(issue: u32) {
let problems: Vec<String> = flat_face_attachments()
.into_iter()
.filter(|(.., known, problem)| known.is_some_and(|(known, _)| known == issue) && problem.is_some())
.map(|(design, body, face, _, problem)| format!("{design}: {body} face {face}: {}", problem.unwrap_or_default()))
.collect();
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
#[test]
#[ignore = "fails until #494 is fixed"]
fn faces_after_revolve_offset_face_and_boss_take_an_attached_sketch() {
known_faces_attach(494);
}
#[test]
#[ignore = "fails until #495 is fixed"]
fn sheet_metal_thickness_faces_take_an_attached_sketch() {
known_faces_attach(495);
}
#[test]
#[ignore = "fails until #628 is fixed"]
fn faces_of_a_lid_with_joined_ribs_take_an_attached_sketch() {
known_faces_attach(628);
}
/// A small deterministic generator (xorshift).
struct Rng(u64);
impl Rng {
+15
View File
@@ -4,6 +4,21 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>SolidCraft</title>
<meta name="description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="SolidCraft">
<meta property="og:title" content="SolidCraft: try it in your browser">
<meta property="og:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta property="og:url" content="https://bherbruck.github.io/solidcraft/app/">
<meta property="og:image" content="https://bherbruck.github.io/solidcraft/og.png">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="640">
<meta property="og:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="SolidCraft: try it in your browser">
<meta name="twitter:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta name="twitter:image" content="https://bherbruck.github.io/solidcraft/og.png">
<meta name="twitter:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
<link data-trunk rel="rust" data-bin="solidcraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="copy-file" href="fonts/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="fonts/OFL.txt" data-target-path="fonts" />
+5 -2
View File
@@ -110,7 +110,7 @@ mod web {
let changed = s.is_dirty() && self.autosaved != Some(s.revision);
let wait = AUTOSAVE_S - (now - self.autosaved_at);
if changed && wait <= 0.0 {
files::autosave(&self.files, Some((&s.doc, s.path.as_deref())));
files::autosave(&self.files, Some((&s.design(), s.path.as_deref())));
self.autosaved = Some(s.revision);
self.autosaved_at = now;
} else if changed {
@@ -251,7 +251,10 @@ mod web {
},
"download_open" => {
let d = &self.app.session.doc;
let result = solidcraft_engine::io::write_design_container(d)
let result = self
.app
.session
.design_container(solidcraft_engine::io::DesignCompression::Fast, false, false)
.map_err(|e| e.to_string())
.and_then(|bytes| files::download(&format!("{}.{}", d.name, solidcraft_engine::io::DESIGN_EXT), &bytes));
if let Err(e) = result {
+19 -9
View File
@@ -141,17 +141,28 @@ fn graphics_path() -> Option<std::path::PathBuf> {
/// Compatibility shim (docs/compatibility.md): on the first launch after the rename, copy the
/// settings of earlier releases into the new folder. They kept `graphics.json` (and, on Linux
/// and macOS, the recovery folder) under the old product name, and eframe's `app.ron` under the
/// window's app id. The old folders are never changed.
/// window's old app id. In the Flatpak sandbox, releases before 0.8 kept everything under the
/// old app id's `~/.var/app` folder; that copy comes first, as it is the newest. The old folders
/// are never changed.
fn adopt_legacy_settings() {
let (Some(current), Some(legacy)) = (settings_dir(), eframe::storage_dir("SolveCraft")) else { return };
let legacy_eframe = eframe::storage_dir(APP_ID);
if let Err(e) = adopt_settings(&current, &legacy, legacy_eframe.as_deref()) {
let legacy_eframe = eframe::storage_dir(solidcraft_engine::io::LEGACY_APP_ID);
let old_flatpak = solidcraft_engine::io::legacy_flatpak_path(&current);
if let Err(e) = adopt_settings(&current, old_flatpak.as_deref(), &legacy, legacy_eframe.as_deref()) {
log::warn!("settings: could not copy the previous release's settings into {}: {e}", current.display());
}
}
fn adopt_settings(current: &std::path::Path, legacy: &std::path::Path, legacy_eframe: Option<&std::path::Path>) -> std::io::Result<()> {
fn adopt_settings(
current: &std::path::Path,
old_flatpak: Option<&std::path::Path>,
legacy: &std::path::Path,
legacy_eframe: Option<&std::path::Path>,
) -> std::io::Result<()> {
let fresh = !current.exists();
if let Some(old) = old_flatpak {
solidcraft_engine::io::adopt_legacy_dir(current, old)?;
}
solidcraft_engine::io::adopt_legacy_dir(current, legacy)?;
if let Some(old) = legacy_eframe.map(|d| d.join("app.ron")).filter(|p| fresh && p.is_file()) {
std::fs::create_dir_all(current)?;
@@ -203,9 +214,8 @@ fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
.with_app_id(APP_ID))
}
/// The window's app id: it names the desktop entry, so it stays the packages' reverse-DNS id,
/// which is kept from before the rename (compatibility shim, docs/compatibility.md).
const APP_ID: &str = "ai.storyteller.solvecraft";
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
const APP_ID: &str = solidcraft_engine::io::APP_ID;
fn main() -> eframe::Result {
adopt_legacy_settings();
@@ -408,14 +418,14 @@ mod msaa_tests {
std::fs::create_dir_all(&legacy_eframe).unwrap();
std::fs::write(legacy.join("graphics.json"), r#"{"msaa":8}"#).unwrap();
std::fs::write(legacy_eframe.join("app.ron"), "(prefs)").unwrap();
super::adopt_settings(&current, &legacy, Some(&legacy_eframe)).unwrap();
super::adopt_settings(&current, None, &legacy, Some(&legacy_eframe)).unwrap();
assert_eq!(std::fs::read_to_string(current.join("graphics.json")).unwrap(), r#"{"msaa":8}"#);
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(prefs)");
assert!(legacy.join("graphics.json").is_file() && legacy_eframe.join("app.ron").is_file());
// Later launches leave the current settings alone.
std::fs::write(current.join("app.ron"), "(mine)").unwrap();
std::fs::remove_file(current.join("graphics.json")).unwrap();
super::adopt_settings(&current, &legacy, Some(&legacy_eframe)).unwrap();
super::adopt_settings(&current, None, &legacy, Some(&legacy_eframe)).unwrap();
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(mine)");
assert!(!current.join("graphics.json").exists());
let _ = std::fs::remove_dir_all(&d);
+2 -2
View File
@@ -147,7 +147,7 @@ impl Document {
.get(name)
.cloned()
.or_else(|| self.appearances.components.get(&self.model_body_component(body)).cloned())
.or_else(|| self.materials.get(name).and_then(|material| material_look(material)))
.or_else(|| self.materials.get(name).and_then(|material| self.material_look(material)))
}
/// Effective body opacity, including paint carried from an imported body.
pub fn model_body_opacity(&self, body: &crate::ModelBody) -> f64 {
@@ -168,7 +168,7 @@ impl Document {
let c = self.body_component(name, feature);
(c != 0).then(|| self.appearances.components.get(&c).cloned()).flatten()
})
.or_else(|| self.materials.get(name).and_then(|m| material_look(m)))
.or_else(|| self.materials.get(name).and_then(|m| self.material_look(m)))
}
/// Face appearances on a body: (point on the face, look).
+2 -1
View File
@@ -263,7 +263,8 @@ impl FeatureKind {
dir(direction);
}
FeatureKind::Thread { face, .. } => pt(face),
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::SheetFlange { edges, .. } => edges.iter_mut().for_each(|e| pt(&mut e.point)),
FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::SheetConvert { face, .. } => pt(face),
FeatureKind::SheetFold { a, b, fixed, .. } => {
pt(a);
+12
View File
@@ -84,6 +84,10 @@ pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<St
if let Some(old) = crate::naming::legacy_name(name) {
return format!("{}{}", crate::naming::LEGACY_MARK, remap_topology(old, ids, bodies));
}
// A flat face of a sheet: `sheet:<body>:<panel>:<side>`, the panel a flange's feature token.
if let Some((body, face)) = crate::sheet::split_sheet_face_name(name) {
return crate::sheet::sheet_face_name(bodies.get(body).map_or(body, String::as_str), &remap_topology(face, ids, bodies));
}
// Imported names belong to the complete canonical body prefix, even when that prefix
// happens to contain text resembling a feature token.
if let Some((body, suffix)) = name.rsplit_once(":face:")
@@ -464,6 +468,14 @@ mod clone_tests {
assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label");
}
#[test]
fn a_sheet_face_follows_its_body_and_its_flange() {
let ids = BTreeMap::from([(1, 2), (10, 20)]);
let bodies = BTreeMap::from([("Body1".into(), "Body1 (copy)".into())]);
assert_eq!(remap_topology("sheet:Body1:F1.0:top", &ids, &bodies), "sheet:Body1 (copy):F2.0:top");
assert_eq!(remap_topology("sheet:Body1:base:bottom", &ids, &bodies), "sheet:Body1 (copy):base:bottom");
assert_eq!(remap_topology("sheet:Other:F10.1:top", &ids, &bodies), "sheet:Other:F20.1:top");
}
#[test]
fn parameter_remap_is_simultaneous_and_token_scoped() {
let map = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]);
assert_eq!(clone_expression("d1 + d2 + d10 + 2 mm", &map), "d2 + d3 + d10 + 2 mm");
+9 -12
View File
@@ -91,17 +91,14 @@ pub fn appearance_cell(value: &str) -> Result<Option<crate::appearance::Look>> {
Ok(Some(look))
}
/// Parse a physical material cell, returning None for the default material.
pub fn material_cell(value: &str) -> Result<Option<String>> {
let name = crate::MATERIALS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(value.trim()))
.map(|(n, _)| *n)
.ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
Ok((name != "Default").then(|| name.to_string()))
}
impl Document {
/// Parse a physical material cell (a built-in material or one defined in this design),
/// returning None for the default material.
pub fn material_cell(&self, value: &str) -> Result<Option<String>> {
let name = self.material_name(value).ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
Ok((name != "Default").then_some(name))
}
/// Freeze design-unit-sensitive configuration literals when changing display units.
/// Explicit units and nonliteral expressions retain their source text.
pub fn preserve_configuration_units(&mut self, previous_unit: &str, kept_parameters: &[String]) {
@@ -199,7 +196,7 @@ impl Document {
let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?;
f.suppressed = on;
}
Column::Material(body) => match material_cell(v)? {
Column::Material(body) => match self.material_cell(v)? {
Some(material) => {
next.materials.insert(body.clone(), material);
}
@@ -274,7 +271,7 @@ impl Document {
self.config_current(c)
.is_some_and(|current| current.chars().filter(|c| !c.is_whitespace()).eq(expected.chars().filter(|c| !c.is_whitespace())))
}
Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
Column::Material(body) => self.material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()),
Column::ComponentAppearance(id) => appearance_cell(v).is_ok_and(|look| self.appearances.components.get(id) == look.as_ref()),
Column::FaceAppearance(target) => appearance_cell(v).is_ok_and(|look| self.config_face_look(target) == look),
+281 -16
View File
@@ -151,11 +151,41 @@ pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solidcraft_k
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FaceCertificate {
Box { axis: u8, high: bool },
ExtrudeCap { sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>> },
ExtrudeSide { sketch: u64, curve: String },
Imported { payload_sha256: String, item: u64, face: u64 },
ShellPlanar { source_producer: u64, source: Box<FaceCertificate>, inner: bool },
Box {
axis: u8,
high: bool,
},
/// A cylinder primitive's cap: `high` is its `top` (the far end), else its `bottom`.
CylinderCap {
high: bool,
},
ExtrudeCap {
sketch: u64,
high: bool,
outer: Vec<String>,
holes: Vec<Vec<String>>,
},
ExtrudeSide {
sketch: u64,
curve: String,
},
Imported {
payload_sha256: String,
item: u64,
face: u64,
},
ShellPlanar {
source_producer: u64,
source: Box<FaceCertificate>,
inner: bool,
},
/// A flat face known by its exact history name (the target's `name`, without piece
/// numbers): what the kernel's face history says it came from, followed through every
/// feature since (a cut wall `F5:side:l1`, a chamfer `F7:blend:…`, a pattern copy
/// `F4:top@F6.2`, a face a join merged from two features). Used where no construction
/// certificate above names the face uniquely. Its plane is the face's exact current plane in
/// the frame [`lineage_frame`] gives it. See docs/face-attachments.md.
Lineage,
}
impl FaceCertificate {
@@ -177,6 +207,7 @@ impl FaceCertificate {
pub fn validate(&self) -> Result<()> {
let valid = match self {
Self::Box { axis, .. } => *axis <= 2,
Self::CylinderCap { .. } => true,
Self::ExtrudeCap { sketch, outer, holes, .. } => *sketch != 0 && valid_signature(outer, holes),
Self::ExtrudeSide { sketch, curve } => *sketch != 0 && !curve.is_empty() && curve.len() <= 128,
Self::Imported { payload_sha256, item, face } => {
@@ -186,8 +217,9 @@ impl FaceCertificate {
&& payload_sha256.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
Self::ShellPlanar { source_producer, source, .. } => {
*source_producer != 0 && !matches!(source.as_ref(), Self::ShellPlanar { .. }) && source.validate().is_ok()
*source_producer != 0 && !matches!(source.as_ref(), Self::ShellPlanar { .. } | Self::Lineage) && source.validate().is_ok()
}
Self::Lineage => true,
};
if valid { Ok(()) } else { Err(DocError::Invalid("invalid face source certificate".into())) }
}
@@ -206,6 +238,11 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()>
&& fields.get("axis").and_then(serde_json::Value::as_u64).is_some_and(|axis| axis <= 2)
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
}
Some("cylinder_cap") => {
fields.len() == 2
&& fields.keys().all(|key| ["kind", "high"].contains(&key.as_str()))
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
}
Some("extrude_cap") => {
fields.len() == 5
&& fields.keys().all(|key| ["kind", "sketch", "high", "outer", "holes"].contains(&key.as_str()))
@@ -229,13 +266,16 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()>
.and_then(serde_json::Value::as_str)
.is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
}
Some("lineage") => fields.len() == 1,
Some("shell_planar") => {
fields.len() == 4
&& fields.keys().all(|key| ["kind", "source_producer", "source", "inner"].contains(&key.as_str()))
&& nonzero("source_producer")
&& fields.get("inner").and_then(serde_json::Value::as_bool).is_some()
&& fields.get("source").is_some_and(|source| {
source.as_object().is_some_and(|source| source.get("kind").and_then(serde_json::Value::as_str) != Some("shell_planar"))
source
.as_object()
.is_some_and(|source| !matches!(source.get("kind").and_then(serde_json::Value::as_str), Some("shell_planar" | "lineage")))
&& validate_certificate_json(source).is_ok()
})
}
@@ -252,18 +292,26 @@ pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> R
let axis = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid shell source axis".into()))?;
format!("box:{}{axis}", if *high { "+" } else { "-" })
}
FaceCertificate::CylinderCap { high } => format!("cylinder:{}", cylinder_cap_role(*high)),
FaceCertificate::ExtrudeSide { curve, .. } => format!("side:{curve}"),
FaceCertificate::ExtrudeCap { high, outer, holes, .. } => format!("cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes)),
FaceCertificate::Imported { payload_sha256, item, face } => format!("import:{payload_sha256}:{item}:{face}"),
FaceCertificate::ShellPlanar { .. } => return Err(DocError::Invalid("nested shell source certificates are unsupported".into())),
FaceCertificate::Lineage => return Err(DocError::Invalid("a shell's source needs a construction certificate".into())),
};
Ok(format!("shell:{}:{role}", if inner { "inner" } else { "outer" }))
}
/// A cylinder cap's role, as the cylinder's face names call it.
pub(crate) fn cylinder_cap_role(high: bool) -> &'static str {
if high { "top" } else { "bottom" }
}
pub(crate) fn flat_source_certificate(source: &solidcraft_kernel::FaceSource) -> Option<(u64, FaceCertificate)> {
use solidcraft_kernel::FaceSource;
match source {
FaceSource::Box { producer: Some(producer), axis, high, .. } => Some((*producer, FaceCertificate::Box { axis: *axis, high: *high })),
FaceSource::CylinderCap { producer, high, .. } => Some((*producer, FaceCertificate::CylinderCap { high: *high })),
FaceSource::CertifiedExtrudeSide { producer, sketch, curve, .. } => {
Some((*producer, FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() }))
}
@@ -283,6 +331,9 @@ pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solidc
(FaceCertificate::Box { axis, high }, FaceSource::Box { producer: Some(current), axis: current_axis, high: current_high, .. }) => {
producer == *current && axis == current_axis && high == current_high
}
(FaceCertificate::CylinderCap { high }, FaceSource::CylinderCap { producer: current, high: current_high, .. }) => {
producer == *current && high == current_high
}
(
FaceCertificate::ExtrudeSide { sketch, curve },
FaceSource::CertifiedExtrudeSide { producer: current, sketch: current_sketch, curve: current_curve, .. },
@@ -309,7 +360,9 @@ pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solidc
/// Match a generated segment to one exact analytic sketch curve. Coincident
/// candidates are ambiguous and never resolved by distance or candidate order.
pub(crate) struct ExactSketchCurves<'a> {
segments: Vec<(&'a str, Vec<Seg2>)>,
/// Each analytic curve's id, whether it is projected (linked) reference geometry, and its
/// segments.
segments: Vec<(&'a str, bool, Vec<Seg2>)>,
}
impl<'a> ExactSketchCurves<'a> {
@@ -340,22 +393,33 @@ impl<'a> ExactSketchCurves<'a> {
} else {
sketch.sketch.segs(index)
};
segments.push((curve.id.as_str(), originals));
segments.push((curve.id.as_str(), curve.link.is_some(), originals));
}
Self { segments }
}
/// The one sketch curve a generated segment lies on. A curve drawn over projected
/// reference geometry (a profile traced over a face's live boundary) is that segment's
/// source: the projection only marks where it was drawn. Two drawn curves, or two
/// projections, over the same segment stay ambiguous.
pub(crate) fn find(&self, generated: &Seg2) -> Option<String> {
let mut found = None;
for (id, originals) in &self.segments {
let mut drawn = None;
let mut projected = None;
for (id, linked, originals) in &self.segments {
if originals.iter().any(|original| exact_segment_contains(original, generated)) {
if found.is_some() {
return None;
let slot = if *linked { &mut projected } else { &mut drawn };
if slot.is_some() {
*slot = Some(None);
} else {
*slot = Some(Some(*id));
}
found = Some(*id);
}
}
found.map(str::to_owned)
match (drawn, projected) {
(Some(id), _) => id.map(str::to_owned),
(None, Some(id)) => id.map(str::to_owned),
(None, None) => None,
}
}
}
@@ -411,7 +475,8 @@ impl Document {
| Some(solidcraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. })
| Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. })
| Some(solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), .. })
| Some(solidcraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer,
| Some(solidcraft_kernel::FaceSource::Box { producer: Some(producer), .. })
| Some(solidcraft_kernel::FaceSource::CylinderCap { producer, .. }) => *producer,
_ => name
.strip_prefix('F')
.and_then(|name| name.split_once(':'))
@@ -481,6 +546,11 @@ impl Document {
target.name = format!("F{producer}:{}{axis_name}", if *high { "+" } else { "-" });
target.certificate = Some(FaceCertificate::Box { axis: *axis, high: *high });
}
Some(solidcraft_kernel::FaceSource::CylinderCap { producer, high, .. }) => {
target.origin_feature = *producer;
target.name = format!("F{producer}:{}", cylinder_cap_role(*high));
target.certificate = Some(FaceCertificate::CylinderCap { high: *high });
}
Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. }) => {
target.origin_feature = *producer;
target.name = format!("F{producer}:cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes));
@@ -493,6 +563,153 @@ impl Document {
}
}
/// The frame a [`FaceCertificate::Lineage`] face's plane is given, from the plane alone (never
/// from how a kernel parametrizes the surface, so it survives rebuilds and kernel changes): its
/// origin where the body's definition origin projects onto the plane, its x axis the first of
/// the definition's X, Y and Z axes that is not steep to the plane (`|cos| < 0.6`; one always
/// is), projected onto it. A face that moves along its normal keeps the frame where it was;
/// one that moves within its plane (a Move feature) keeps it too, as a sketch on a face does
/// in the definition's coordinates.
pub fn lineage_frame(body: &ModelBody, plane: &solidcraft_geom::Plane) -> Option<solidcraft_geom::Plane> {
use solidcraft_geom::Vec3;
let n = plane.normal();
let m = &body.source_transform;
let origin = crate::apply_point(m, Vec3::ZERO);
let axis = [Vec3::X, Vec3::Y, Vec3::Z].into_iter().filter_map(|a| crate::apply_vector(m, a).normalized()).find(|a| a.dot(n).abs() < 0.6)?;
let x = axis - n * axis.dot(n);
let origin = origin - n * (origin - plane.origin).dot(n);
solidcraft_geom::Plane::new(origin, x, n.cross(x))
}
/// Whether two exact planes are one oriented plane (pieces of one face).
fn same_oriented_plane(a: &solidcraft_geom::Plane, b: &solidcraft_geom::Plane, scale: f64) -> bool {
a.normal().dot(b.normal()) >= 1. - 1e-8 && (b.origin - a.origin).dot(a.normal()).abs() <= 1e-7 * (1. + scale)
}
impl Document {
/// The exact plane an anchor on face `index` of `body` is measured in: the construction
/// certificate's plane, or for a [`FaceCertificate::Lineage`] face its current exact plane in
/// the [`lineage_frame`].
pub fn anchor_face_plane(&self, body: &ModelBody, index: usize, certificate: Option<&FaceCertificate>) -> Option<solidcraft_geom::Plane> {
match certificate {
Some(FaceCertificate::Lineage) => body.body.planar_face(index).and_then(|plane| lineage_frame(body, &plane)),
_ => body.body.face_plane(index),
}
}
/// A target for flat face `index` of `body` by its exact history name
/// ([`FaceCertificate::Lineage`]), when that name follows exact history, names a feature of
/// the design and finds this face (with its other pieces, all on one oriented plane).
pub(crate) fn lineage_face_target(
&self,
body: &ModelBody,
index: usize,
point: solidcraft_geom::Vec3,
) -> Option<crate::config::ConfigFaceTarget> {
let names = crate::naming::names(body);
if !names.verified(index) {
return None;
}
let name = crate::naming::strip_piece(names.faces.get(index)?).to_string();
let origin = name.strip_prefix('F')?.split_once(':')?.0.parse::<u64>().ok()?;
let inverse = crate::mat_inverse(&body.source_transform)?;
let target = crate::config::ConfigFaceTarget {
body: body.definition_name().into(),
origin_feature: origin,
name,
point: crate::apply_point(&inverse, point),
certificate: Some(FaceCertificate::Lineage),
};
self.configuration_face_indices(body, &target).ok()?.contains(&index).then_some(target)
}
/// The faces of `body` an exact history name finds: those named `name` or a piece of it, else
/// (a later join merged it into another face) the face that answers to it. Every one must
/// follow exact history and lie on one oriented plane.
pub(crate) fn lineage_face_indices(&self, body: &ModelBody, name: &str) -> Result<Vec<usize>> {
let names = crate::naming::names(body);
let mut indices: Vec<usize> =
names.faces.iter().enumerate().filter(|(_, face)| crate::naming::strip_piece(face) == name).map(|(index, _)| index).collect();
if indices.is_empty() {
indices =
names.info.iter().enumerate().filter(|(_, info)| info.aliases.iter().any(|alias| alias == name)).map(|(index, _)| index).collect();
}
if indices.is_empty() {
return Err(DocError::Unknown(format!("face `{name}` on `{}` no longer exists", body.name)));
}
self.coplanar_pieces(body, &indices, |index| names.verified(index))?;
Ok(indices)
}
/// Check that `indices` are pieces of one flat face: each `exact` and planar, all on one
/// oriented plane, and not split on purpose (Split Face: its pieces are separate faces, so
/// a pick on one never stands for the whole; docs/face-attachments.md).
pub(crate) fn coplanar_pieces(&self, body: &ModelBody, indices: &[usize], exact: impl Fn(usize) -> bool) -> Result<()> {
let scale = body.body.size();
let mut first = None;
for index in indices {
if !exact(*index) {
return Err(DocError::Invalid("this face's history is not exact".into()));
}
let plane = body.body.planar_face(*index).ok_or_else(|| DocError::Invalid("attached face is not flat".into()))?;
match &first {
None => first = Some(plane),
Some(first) if same_oriented_plane(first, &plane, scale) => {}
Some(_) => return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into())),
}
}
if let (Some(plane), true) = (first, indices.len() > 1) {
let tolerance = 1e-6 * (1. + scale);
let on_plane = |line: &Vec<solidcraft_geom::Vec3>| {
!line.is_empty() && line.iter().all(|p| (*p - plane.origin).dot(plane.normal()).abs() <= tolerance)
};
if body.body.split_lines().is_some_and(|lines| lines.iter().any(on_plane)) {
return Err(DocError::Invalid(
"this face was split on purpose (Split Face): pick an exact face piece's own plane with fixed:true".into(),
));
}
}
Ok(())
}
/// Of several faces carrying one construction certificate of feature `origin`, the pieces of
/// that feature's own face: copies a pattern or mirror made carry their seed's certificate
/// but are named as copies (`…@F<pattern>.<instance>`, docs/face-attachments.md), and pieces
/// a later feature split the face into share its exact name and plane.
pub(crate) fn certified_pieces(&self, body: &ModelBody, indices: Vec<usize>, origin: u64) -> Result<Vec<usize>> {
if indices.len() < 2 {
return Ok(indices);
}
let names = crate::naming::names(body);
let own = format!("F{origin}:");
let stem = |index: usize| names.faces.get(index).map(|name| crate::naming::strip_piece(name));
let seed: Vec<usize> =
indices.into_iter().filter(|index| stem(*index).is_some_and(|name| name.starts_with(&own) && !name.contains('@'))).collect();
let ambiguous = || DocError::Unknown("attached planar face certificate is missing or ambiguous".into());
let first = seed.first().and_then(|index| stem(*index)).ok_or_else(ambiguous)?;
if seed.iter().any(|index| stem(*index) != Some(first)) {
return Err(ambiguous());
}
// The certificate is exact; the names only tell the seed from its copies here.
self.coplanar_pieces(body, &seed, |_| true).map_err(|_| ambiguous())?;
Ok(seed)
}
/// Of several faces carrying one certificate of feature `origin`, those that are not a
/// pattern's or mirror's copies (named `…@F<pattern>.<instance>`), when any is not.
pub(crate) fn without_copies(&self, body: &ModelBody, indices: Vec<usize>) -> Vec<usize> {
if indices.len() < 2 {
return indices;
}
let names = crate::naming::names(body);
let copy = |index: &usize| names.faces.get(*index).is_some_and(|name| crate::naming::strip_piece(name).contains('@'));
if indices.iter().all(copy) {
return indices;
}
indices.into_iter().filter(|index| !copy(index)).collect()
}
}
pub(crate) fn payload_digest(payload: &str) -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(payload.as_bytes()))
@@ -512,6 +729,31 @@ mod tests {
SolvedSketch { feature: 1, name: "sources".into(), plane: solidcraft_geom::Plane::XY, sketch, report, profiles: Vec::new() }
}
#[test]
fn cylinder_cap_certificate_json_is_exact() {
for high in [true, false] {
let valid = json!({"kind":"cylinder_cap","high":high});
assert!(validate_certificate_json(&valid).is_ok());
let certificate: FaceCertificate = serde_json::from_value(valid.clone()).unwrap();
assert_eq!(certificate, FaceCertificate::CylinderCap { high });
assert_eq!(serde_json::to_value(&certificate).unwrap(), valid);
}
let shelled = json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"cylinder_cap","high":false},"inner":true});
assert!(validate_certificate_json(&shelled).is_ok());
let certificate: FaceCertificate = serde_json::from_value(shelled).unwrap();
assert_eq!(shell_certificate_role(&FaceCertificate::CylinderCap { high: false }, true).unwrap(), "shell:inner:cylinder:bottom");
assert!(certificate.validate().is_ok());
for bad in [
json!({"kind":"cylinder_cap"}),
json!({"kind":"cylinder_cap","high":1}),
json!({"kind":"cylinder_cap","high":null}),
json!({"kind":"cylinder_cap","high":true,"axis":2}),
json!({"kind":"cylinder_cap","high":true,"extra":[[[]]]}),
] {
assert!(validate_certificate_json(&bad).is_err(), "{bad}");
}
}
#[test]
fn prepared_sources_keep_exact_identity_and_reject_ambiguity() {
use solidcraft_geom::Vec2;
@@ -537,6 +779,29 @@ mod tests {
assert_eq!(ExactSketchCurves::new(&moved).find(&partial), None);
}
#[test]
fn a_curve_drawn_over_a_projected_boundary_is_the_source() {
use solidcraft_geom::Vec2;
// A face sketch's live boundary, with a profile edge traced over it (issue #489).
let mut sketch = solidcraft_sketch::Sketch::new();
let projected = sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("edge")).unwrap();
sketch.curves[projected].link = Some("boundary".into());
let sketch = solved(sketch);
let edge = Seg2::Line { a: Vec2::new(10., 0.), b: Vec2::ZERO };
assert_eq!(ExactSketchCurves::new(&sketch).find(&edge).as_deref(), Some("edge"), "a lone projection is its own source");
let mut traced = sketch.clone();
traced.sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("drawn")).unwrap();
assert_eq!(ExactSketchCurves::new(&traced).find(&edge).as_deref(), Some("drawn"));
// Two drawn curves over it, or two projections, stay ambiguous.
let mut twice = traced.clone();
twice.sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("again")).unwrap();
assert_eq!(ExactSketchCurves::new(&twice).find(&edge), None);
let mut projections = sketch.clone();
let other = projections.sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("other")).unwrap();
projections.sketch.curves[other].link = Some("edge link".into());
assert_eq!(ExactSketchCurves::new(&projections).find(&edge), None);
}
#[test]
#[ignore = "manual optimized certification microbenchmark; no timing assertion"]
fn benchmark_prepared_extrude_sources() {
+12 -1
View File
@@ -253,6 +253,7 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
before,
depth: 1,
mats: vec![],
sheets: vec![],
})));
let captured = crate::naming::face_name_at_body(&old_body, Vec3::new(20., 6., 1.)).unwrap();
assert_eq!(crate::naming::parent_piece(&captured), Some(stem.as_str()), "actual strip capture records a piece name");
@@ -376,6 +377,7 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
before,
depth: 1,
mats: vec![],
sheets: vec![],
})));
let t = std::f64::consts::FRAC_1_SQRT_2;
let at = Vec3::new(34. + 6. * t, 24. + 6. * t, 1.);
@@ -515,6 +517,7 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
before,
depth: 1,
mats: vec![],
sheets: vec![],
})));
let at = Vec3::new(0., 15., 1.);
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
@@ -529,7 +532,15 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
let stem = crate::naming::strip_piece(&former);
let current_names = crate::naming::face_names(current);
let merged = current_names.iter().position(|name| name == stem).unwrap();
assert!(current.body.face_was_coalesced(merged), "new exact generated-wall construction supplies the loss-only witness");
// The Join leaves Case's -x wall on its own support, so the wall keeps its exact box
// identity through the Join and the Cut: one face again, no longer the saved piece.
assert!(
matches!(
current.body.face_sources().and_then(|sources| sources.get(merged)).cloned().flatten(),
Some(solidcraft_kernel::FaceSource::Box { axis: 0, high: false, .. })
),
"the merged wall carries its exact construction identity"
);
assert!(!current_names.contains(&former));
let mut sketch = Sketch::new();
sketch.add_link(LinkKind::Project, LinkSource::Face { body: "Case".into(), at }, &resolved.geom).unwrap();
+26 -11
View File
@@ -108,9 +108,10 @@ impl Document {
use super::FaceCertificate;
let Some(feature) = self.feature(producer) else { return false };
match (certificate, &feature.kind) {
(FaceCertificate::Box { .. }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => true,
(FaceCertificate::ExtrudeCap { sketch, .. }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => sketch == current && extent.taper.is_none(),
(FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => {
(FaceCertificate::Box { .. }, FeatureKind::Box { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => true,
(FaceCertificate::CylinderCap { .. }, FeatureKind::Cylinder { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => true,
(FaceCertificate::ExtrudeCap { sketch, .. }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => sketch == current && extent.taper.is_none(),
(FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
sketch == current && extent.taper.is_none()
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Circle { .. } | solidcraft_sketch::CurveKind::Arc { .. }))))
}
@@ -129,18 +130,22 @@ impl Document {
let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?;
if let Some(certificate) = &target.certificate {
let valid = match (certificate, &feature.kind) {
(super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => {
(super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
["x", "y", "z"].get(*axis as usize).is_some_and(|axis| role == format!("{}{axis}", if *high { "+" } else { "-" }))
}
(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
(super::FaceCertificate::CylinderCap { high }, FeatureKind::Cylinder { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
role == super::cylinder_cap_role(*high)
}
(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
sketch == producer_sketch && extent.taper.is_none()
&& role == format!("cap:{}:{}", if *high { "high" } else { "low" }, super::cap_key(outer, holes))
}
(super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
(super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
sketch == producer_sketch && extent.taper.is_none() && role == format!("side:{curve}")
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Circle { .. } | solidcraft_sketch::CurveKind::Arc { .. }))))
}
(super::FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => role.strip_prefix("face:").is_some_and(|index| index.parse::<usize>().is_ok()),
(super::FaceCertificate::Lineage, _) => !role.is_empty(),
(super::FaceCertificate::ShellPlanar { source_producer, source, inner }, FeatureKind::Shell { .. }) => {
*source_producer != target.origin_feature
&& self.flat_certificate_producer_matches(*source_producer, source)
@@ -181,6 +186,9 @@ impl Document {
if body.body.is_mesh() || body.definition_name() != target.body {
return Err(DocError::Invalid("configured face definition does not match".into()));
}
if let Some(super::FaceCertificate::Lineage) = &target.certificate {
return self.lineage_face_indices(body, &target.name);
}
if let Some(super::FaceCertificate::ShellPlanar { source_producer, source, inner }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, current)| {
matches!(current, Some(solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), source: current_source, inner: current_inner, .. }) if *producer == target.origin_feature && inner == current_inner && super::flat_source_matches(source, current_source, *source_producer)).then_some(index)
@@ -192,7 +200,10 @@ impl Document {
}
return Ok(indices);
}
if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) {
if matches!(
target.certificate,
Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::CylinderCap { .. } | super::FaceCertificate::ExtrudeCap { .. })
) {
let indices: Vec<_> = body
.body
.face_sources()
@@ -205,6 +216,10 @@ impl Document {
Some(super::FaceCertificate::Box { axis, high }),
Some(solidcraft_kernel::FaceSource::Box { producer: Some(producer), axis: current_axis, high: current_high, .. }),
) => *producer == target.origin_feature && axis == current_axis && high == current_high,
(
Some(super::FaceCertificate::CylinderCap { high }),
Some(solidcraft_kernel::FaceSource::CylinderCap { producer, high: current_high, .. }),
) => *producer == target.origin_feature && high == current_high,
(
Some(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }),
Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap {
@@ -227,10 +242,10 @@ impl Document {
matched.then_some(index)
})
.collect();
if indices.len() != 1 {
if indices.is_empty() {
return Err(DocError::Unknown("attached planar face certificate is missing or ambiguous".into()));
}
return Ok(indices);
return self.certified_pieces(body, indices, target.origin_feature);
}
if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
@@ -239,7 +254,7 @@ impl Document {
if indices.is_empty() {
return Err(DocError::Unknown("constructed side certificate no longer exists".into()));
}
return Ok(indices);
return Ok(self.without_copies(body, indices));
}
if let Some(super::FaceCertificate::Imported { payload_sha256, item, face }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
@@ -248,7 +263,7 @@ impl Document {
if indices.is_empty() {
return Err(DocError::Unknown("immutable import face certificate no longer exists".into()));
}
return Ok(indices);
return Ok(self.without_copies(body, indices));
}
let names = crate::naming::face_names(body);
let indices: Vec<usize> =
+200 -6
View File
@@ -269,6 +269,73 @@ impl EmbossFace {
}
}
/// One edge of an edge flange feature: a point on the sheet's edge and, where given, this
/// edge's own angle and height (expressions) in place of the feature's. In a design file an
/// edge with neither is the point `[x, y, z]`, as every edge was before; one with its own
/// values is `{"point": [x, y, z], "angle": "…", "height": "…"}`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FlangeEdge {
pub point: Vec3,
pub angle: Option<String>,
pub height: Option<String>,
}
impl FlangeEdge {
/// An edge that takes the feature's angle and height.
pub fn at(point: Vec3) -> Self {
Self { point, angle: None, height: None }
}
}
impl Serialize for FlangeEdge {
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
if self.angle.is_none() && self.height.is_none() {
return self.point.serialize(s);
}
let mut m = s.serialize_map(None)?;
m.serialize_entry("point", &self.point)?;
if let Some(a) = &self.angle {
m.serialize_entry("angle", a)?;
}
if let Some(h) = &self.height {
m.serialize_entry("height", h)?;
}
m.end()
}
}
impl<'de> Deserialize<'de> for FlangeEdge {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
use serde::de::value::{MapAccessDeserializer, SeqAccessDeserializer};
/// The object form; a key this build doesn't know is refused, not dropped.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Own {
point: Vec3,
#[serde(default)]
angle: Option<String>,
#[serde(default)]
height: Option<String>,
}
struct Edge;
impl<'de> serde::de::Visitor<'de> for Edge {
type Value = FlangeEdge;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a point [x, y, z] or an edge {point, angle?, height?}")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, seq: A) -> std::result::Result<FlangeEdge, A::Error> {
Vec3::deserialize(SeqAccessDeserializer::new(seq)).map(FlangeEdge::at)
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> std::result::Result<FlangeEdge, A::Error> {
let o = Own::deserialize(MapAccessDeserializer::new(map))?;
Ok(FlangeEdge { point: o.point, angle: o.angle, height: o.height })
}
}
d.deserialize_any(Edge)
}
}
/// Feature definitions.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -659,9 +726,10 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
reverse: bool,
},
/// Edge flanges on sheet edges (points on the top or bottom edges).
/// Edge flanges on sheet edges (points on the top or bottom edges). `height` and `angle`
/// hold for every edge that doesn't carry its own (see [`FlangeEdge`]).
SheetFlange {
edges: Vec<Vec3>,
edges: Vec<FlangeEdge>,
height: String,
angle: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -874,6 +942,10 @@ pub enum FeatureKind {
rotate_axis: Option<Vec3>,
#[serde(default, skip_serializing_if = "Option::is_none")]
angle: Option<String>,
/// When set, the original bodies are kept and the transform is applied to a new copy
/// of each instead of moving them in place.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
copy: bool,
},
/// A non-parametric base feature: bodies imported from a STEP file. The STEP text is kept
/// in the design (it is the B-rep), so the design reopens without the original file.
@@ -1521,9 +1593,12 @@ impl FeatureKind {
v.extend([length, draft, thickness].into_iter().flatten().map(String::as_str));
}
FeatureKind::SheetContour { distance, .. } => v.push(distance),
FeatureKind::SheetFlange { height, angle, radius, .. } => {
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
v.extend([height.as_str(), angle]);
v.extend(radius.iter().map(String::as_str));
for e in edges {
v.extend([&e.angle, &e.height].into_iter().flatten().map(String::as_str));
}
}
FeatureKind::SheetFold { angle, radius, .. } => {
v.push(angle);
@@ -1623,6 +1698,18 @@ pub struct StandardPart {
pub length: Option<f64>,
}
/// A component whose contents come from another design file (Insert Design): where that file
/// is, and the Import feature in the component that holds its bodies (Update Links replaces
/// them; Break Link keeps them and forgets the file).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DesignLink {
/// The design file: relative to this design's folder once this design is saved, else as
/// given (`/` separates folders).
pub path: String,
/// The Import feature holding the linked design's bodies.
pub feature: u64,
}
/// The document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
@@ -1656,6 +1743,9 @@ pub struct Document {
/// Components that are standard parts (by component id): what to rebuild them from.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub parts: std::collections::BTreeMap<u64, StandardPart>,
/// Components linked to another design file (by component id).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub links: std::collections::BTreeMap<u64, DesignLink>,
/// Configurations (rows of parameter values and suppressions).
#[serde(default, skip_serializing_if = "crate::config::ConfigTable::is_empty")]
pub configs: crate::config::ConfigTable,
@@ -1671,6 +1761,9 @@ pub struct Document {
/// Physical material per body (by body name); others use the default.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub materials: std::collections::BTreeMap<String, String>,
/// Physical materials defined in this design (`material.create`), next to the built-in ones.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub user_materials: Vec<UserMaterial>,
/// Appearance colour per body (by body name, sRGB); it overrides the material's look.
#[serde(default, skip_serializing_if = "crate::appearance::Appearances::is_empty")]
pub appearances: crate::appearance::Appearances,
@@ -1690,6 +1783,38 @@ pub struct Document {
/// Named views: saved cameras.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub named_views: Vec<NamedView>,
/// What was hidden when the design was saved. A session takes it from here when the design
/// opens and keeps the live state itself (`browser.visibility`: saved, not an undo step), so
/// a document in a session leaves this empty; the session puts it back when it saves.
#[serde(default, skip_serializing_if = "Visibility::is_empty")]
pub visibility: Visibility,
}
/// Items hidden or shown one by one. Saved with the design, but not an undo step.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Visibility {
/// Bodies by name.
pub hidden_bodies: Vec<String>,
/// Exact occurrence placements hidden with all descendants.
pub hidden_occurrences: Vec<Vec<u64>>,
/// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction geometry, by name.
pub hidden_origin: Vec<String>,
/// Sketches by feature id.
pub hidden_sketches: Vec<u64>,
/// Finished sketches shown although a feature uses them.
pub shown_sketches: Vec<u64>,
}
impl Visibility {
/// Nothing hidden or shown one by one (the default view).
pub fn is_empty(&self) -> bool {
self.hidden_bodies.is_empty()
&& self.hidden_occurrences.is_empty()
&& self.hidden_origin.is_empty()
&& self.hidden_sketches.is_empty()
&& self.shown_sketches.is_empty()
}
}
/// A saved camera (orbit camera: target, yaw, pitch, distance; `fov` 0 for orthographic).
@@ -1748,9 +1873,11 @@ impl Document {
plastic: Default::default(),
configs: Default::default(),
parts: Default::default(),
links: Default::default(),
body_components: Default::default(),
body_offsets: Default::default(),
materials: Default::default(),
user_materials: Vec::new(),
appearances: Default::default(),
favorites: Default::default(),
canvases: Vec::new(),
@@ -1758,6 +1885,7 @@ impl Document {
browser_groups: Vec::new(),
browser_order: Default::default(),
named_views: Vec::new(),
visibility: Visibility::default(),
}
}
@@ -1790,7 +1918,46 @@ impl Document {
/// Density of a body's material (g/cm³).
pub fn density(&self, body: &str) -> f64 {
self.materials.get(body).and_then(|m| material_density(m)).unwrap_or(DEFAULT_DENSITY)
self.materials.get(body).and_then(|m| self.material_density(m)).unwrap_or(DEFAULT_DENSITY)
}
/// A material defined in this design, by name (case-insensitive).
pub fn user_material(&self, name: &str) -> Option<&UserMaterial> {
let name = name.trim();
self.user_materials.iter().find(|m| m.name.eq_ignore_ascii_case(name))
}
/// A physical material's name as the library or this design spells it (case-insensitive
/// lookup): built-in materials first, then the design's own.
pub fn material_name(&self, name: &str) -> Option<String> {
MATERIALS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(name.trim()))
.map(|(n, _)| n.to_string())
.or_else(|| self.user_material(name).map(|m| m.name.clone()))
}
/// Density of a physical material (g/cm³): built-in or defined in this design. A design
/// material with an unusable density (a damaged file) has none.
pub fn material_density(&self, name: &str) -> Option<f64> {
material_density(name).or_else(|| self.user_material(name).map(|m| m.density).filter(|d| valid_density(*d)))
}
/// The look a physical material gives a body without an appearance of its own.
pub fn material_look(&self, name: &str) -> Option<crate::appearance::Look> {
crate::appearance::material_look(name).or_else(|| {
let mut look = self.user_material(name)?.appearance.clone()?;
// A damaged file's opacity is shown opaque rather than trusted.
if !(look.opacity.is_finite() && (0.0..=1.0).contains(&look.opacity)) {
look.opacity = 1.0;
}
Some(look)
})
}
/// Every physical material's name: the built-in library, then the design's own.
pub fn material_names(&self) -> Vec<String> {
MATERIALS.iter().map(|(n, _)| n.to_string()).chain(self.user_materials.iter().map(|m| m.name.clone())).collect()
}
/// The component a body belongs to: where it was moved, else its feature's.
@@ -2210,18 +2377,25 @@ impl Document {
/// the sketch over is refused. Offsets whose sources moved are refreshed and solved again:
/// from the stored state again after a refused or failed first solve (the geometry is still
/// the stored one), from the solved one otherwise.
///
/// `moved_from`: the stored sketch, when `sk` is that sketch with its links refreshed. A
/// link's points are where their source is now; the sides and turns the solve keeps are
/// those of the stored state ([`Sketch::positions_in`]), not the ones read beside a line
/// that has moved past a point.
pub fn solve_sketch(
&self,
vals: &std::collections::BTreeMap<String, expr::Value>,
sk: &mut Sketch,
stored: &solidcraft_sketch::DrivingValues,
moved_from: Option<&Sketch>,
) -> Result<solidcraft_sketch::SolveReport> {
self.apply_dimension_values(vals, sk)?;
let mut report = solidcraft_sketch::solve_after_value_edit(sk, stored);
let was = |sk: &Sketch| moved_from.and_then(|from| sk.positions_in(from));
let mut report = solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref());
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
// Source dimensions may have moved curves during the solve.
sk.refresh_offsets()?;
report = if report.ok() { solidcraft_sketch::solve(sk) } else { solidcraft_sketch::solve_after_value_edit(sk, stored) };
report = if report.ok() { solidcraft_sketch::solve(sk) } else { solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref()) };
}
Ok(report)
}
@@ -2268,6 +2442,26 @@ pub fn material_color(name: &str) -> Option<[u8; 3]> {
})
}
/// A physical material defined in a design (`material.create`): its name, its density and,
/// optionally, the look it gives bodies without an appearance of their own.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UserMaterial {
pub name: String,
/// Density (g/cm³).
pub density: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub appearance: Option<crate::appearance::Look>,
}
/// Largest density a material may have (g/cm³); the densest elements are about 22.6.
pub const MAX_DENSITY: f64 = 1000.0;
/// A density a material can have: finite, above zero and at most [`MAX_DENSITY`] g/cm³.
pub fn valid_density(d: f64) -> bool {
d.is_finite() && d > 0.0 && d <= MAX_DENSITY
}
/// Density of a built-in physical material (g/cm³).
pub fn material_density(name: &str) -> Option<f64> {
MATERIALS.iter().find(|(n, _)| n.eq_ignore_ascii_case(name.trim())).map(|(_, d)| *d)
}
+262 -66
View File
@@ -62,12 +62,14 @@ impl ModelBody {
pub fn definition_name(&self) -> &str {
self.source_name.as_deref().unwrap_or(&self.name)
}
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
/// Display mesh, computed once: chord tolerance 1/1000 of the body size, and no facet of a
/// curved face or edge turning more than [`kernel::DISPLAY_ANGLE`], so that a bend or a
/// hole that is small beside the body is still drawn round.
pub fn mesh(&self) -> Arc<Mesh> {
self.mesh
.get_or_init(|| {
let tol = (self.body.size() * 1e-3).max(1e-3);
Arc::new(self.body.display_mesh(tol).unwrap_or_default())
Arc::new(self.body.display_mesh_angular(tol, kernel::DISPLAY_ANGLE).unwrap_or_default())
})
.clone()
}
@@ -262,23 +264,38 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
}
/// A constant fillet of `edges`; with [`FilletFit::Reduce`](crate::FilletFit::Reduce), a radius too
/// large for the geometry around them is reduced to the largest that works for all of them, which
/// is returned.
/// large for the geometry around them is reduced where it doesn't fit, to the largest that works
/// for each group of edges sharing a face; the radius per edge is returned.
fn fillet_fitted(
body: &kernel::Body,
edges: &[Vec3],
r: f64,
fit: crate::FilletFit,
) -> std::result::Result<(kernel::Body, Option<f64>), kernel::KernelError> {
) -> std::result::Result<(kernel::Body, Option<Vec<f64>>), kernel::KernelError> {
match kernel::fillet(body, edges, r) {
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max(body, edges, r) {
Some((used, b)) => Ok((b, Some(used))),
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max_each(body, edges, r) {
// Built at the radius asked for after all (the groups apart): nothing reduced.
Some((used, b)) => Ok((b, used.iter().any(|u| *u < r).then_some(used))),
None => Err(kernel::KernelError::TooLarge(why)),
},
other => other.map(|b| (b, None)),
}
}
/// The warning of a fillet whose radius `asked` was reduced to `used` (per edge).
fn reduced_note(used: &[f64], asked: f64) -> String {
let mut values: Vec<f64> = used.iter().copied().filter(|r| *r < asked).collect();
values.sort_by(f64::total_cmp);
values.dedup();
let shown = values.iter().map(|v| show_mm(*v)).collect::<Vec<_>>().join(", ");
let n = used.iter().filter(|r| **r < asked).count();
if n == used.len() && values.len() == 1 {
format!("the radius is reduced to {shown} mm, the largest that fits these edges")
} else {
format!("the radius is reduced to {shown} mm on {n} of {} edges, the largest that fits them", used.len())
}
}
/// Add `note` to a feature's warning.
fn append_warning(warning: &mut Option<String>, note: String) {
*warning = Some(match warning.take() {
@@ -294,9 +311,23 @@ fn show_mm(v: f64) -> String {
}
impl ModelState {
/// The radius fillet `feature` used when it reduced a radius too large for its edges.
/// The smallest radius fillet `feature` used when it reduced a radius too large for its edges.
pub fn fitted_radius(&self, feature: u64) -> Option<f64> {
self.fitted.get(&feature).copied()
self.fitted_range(feature).map(|(lo, _)| lo)
}
/// The smallest and largest radius fillet `feature` used when it reduced a radius too large
/// for some of its edges.
pub fn fitted_range(&self, feature: u64) -> Option<(f64, f64)> {
self.fitted.get(&feature)?.iter().fold(None, |acc, r| Some(acc.map_or((*r, *r), |(lo, hi): (f64, f64)| (lo.min(*r), hi.max(*r)))))
}
/// Each edge fillet `feature` blended (a point on it) with the radius it used, when it
/// reduced a radius too large for some of its edges.
pub fn fitted_edges(&self, feature: u64) -> Option<Vec<(Vec3, f64)>> {
let radii = self.fitted.get(&feature)?;
let edges = self.blend_edges.get(&feature)?;
Some(edges.iter().copied().zip(radii.iter().copied()).collect())
}
}
@@ -319,9 +350,9 @@ pub struct ModelState {
/// a pattern of the feature repeats these, not the points picked when it was made, which a
/// parameter change may have moved off the edges.
blend_edges: BTreeMap<u64, Vec<Vec3>>,
/// The radius a fillet used when it reduced a radius too large for its edges (by feature id,
/// see `FilletFit::Reduce`).
fitted: BTreeMap<u64, f64>,
/// The radius per edge a fillet used when it reduced a radius too large for some of its edges
/// (by feature id, see `FilletFit::Reduce`).
fitted: BTreeMap<u64, Vec<f64>>,
}
impl ModelState {
@@ -347,9 +378,11 @@ impl ModelState {
[p.x, p.y, p.z].map(f64::to_bits).hash(&mut h);
}
}
for (feature, r) in &self.fitted {
for (feature, radii) in &self.fitted {
feature.hash(&mut h);
r.to_bits().hash(&mut h);
for r in radii {
r.to_bits().hash(&mut h);
}
}
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
@@ -362,7 +395,8 @@ impl ModelState {
fn stamp(&mut self, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
let feature = f.id;
let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1;
let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats });
let sheets = if f.kind.is_sheet() { self.sheets.clone() } else { Vec::new() };
let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats, sheets });
// Pieces a cut left are named after their faces, not the kernel's shell order (unless
// the feature was saved when they were named in that order).
if !f.shell_piece_order {
@@ -854,8 +888,11 @@ impl Model {
let mut restored = 0;
let marker = doc.marker.unwrap_or(usize::MAX);
let ctx = context_digest(doc, &vals);
let watched = crate::progress::begin(doc.features.len());
for (i, f) in doc.features.iter().enumerate() {
let rolled_back = i >= marker;
// Opening was cancelled: the rest is skipped and the result discarded.
let cancelled = watched.step(i);
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back);
prev_fp = fp;
if reuse
@@ -871,7 +908,7 @@ impl Model {
continue;
}
reuse = false;
if f.suppressed || rolled_back {
if f.suppressed || rolled_back || cancelled {
out.push(FeatureResult {
id: f.id,
name: f.name.clone(),
@@ -924,7 +961,14 @@ impl Model {
let f = if !explicit_emboss && f.face_names.iter().any(|n| !n.is_empty()) {
let mut g = f.clone();
let mut notes = Vec::new();
for (p, n) in g.kind.face_points_mut().into_iter().zip(&f.face_names) {
// A hole's position is a place on its face, not a pick of the face: it follows
// the face along the hole's axis and never slides sideways to the face's nearest
// point when the face no longer reaches it.
let hole_axis = match &f.kind {
FeatureKind::Hole { direction, points: None, .. } => direction.normalized(),
_ => None,
};
for (k, (p, n)) in g.kind.face_points_mut().into_iter().zip(&f.face_names).enumerate() {
if n.is_empty() {
continue;
}
@@ -933,7 +977,18 @@ impl Model {
if let Some(note) = hit.note(n) {
notes.push(note);
}
*p = hit.point;
match (k, hole_axis) {
(0, Some(axis)) => match hit.along(&state, *p, axis) {
Some(q) => *p = q,
// The place still lies on the face (the axis may run along it).
None if hit.on_point => {}
None => notes.push(format!(
"the hole's position [{}, {}, {}] is no longer on face `{n}`: it stays where it was placed",
p.x, p.y, p.z
)),
},
_ => *p = hit.point,
}
}
None => notes.push(format!("face `{n}` no longer exists; using the face at the picked point")),
}
@@ -1045,10 +1100,12 @@ pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region
}
let out: Vec<Region2> = match sel {
// "All" means what was drawn: projected reference geometry (a face's auto-projected
// edges) only counts when the drawn curves alone make no profile.
// edges) only counts when the drawn curves alone make no profile. Text is drawn, and
// its letters keep their counters as holes (the counters are not filled in).
ProfileSel::All => {
let drawn = solidcraft_sketch::find_drawn_profiles(&ss.sketch);
if drawn.is_empty() { ps.iter().map(|p| p.region.clone()).collect() } else { drawn.into_iter().map(|p| p.region).collect() }
let chosen = if drawn.is_empty() { ps.clone() } else { drawn };
solidcraft_sketch::without_text_counters(&ss.sketch, chosen).into_iter().map(|p| p.region).collect()
}
ProfileSel::Indices { indices } => {
let mut v = Vec::new();
@@ -1121,6 +1178,25 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String {
}
}
/// The k-th body of a body pattern: its stored name, else a copy of `source`. Once `body.rename`
/// has stored this feature's names, a copy beyond them (the count was raised) is numbered after
/// the highest stored `source (n)`, so it never takes a number freed by a rename (#372).
/// Features without stored names keep the first free `source (n)`.
fn pattern_copy_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> String {
if f.body_names.get(k).is_some_and(|name| !name.trim().is_empty()) {
return copy_body_name(state, f, k, source);
}
let base = if source.trim().is_empty() { "Body" } else { source.trim() };
let prefix = format!("{base} (");
let highest = f.body_names.iter().filter_map(|n| n.trim().strip_prefix(prefix.as_str())?.strip_suffix(')')?.parse::<u32>().ok()).max();
match highest {
Some(h) => {
(u64::from(h) + 1..).map(|i| format!("{base} ({i})")).find(|n| state.body(n).is_none()).unwrap_or_else(|| unique_body_name(state, base))
}
None => unique_body_name(state, base),
}
}
fn copy_body_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> String {
let base = f.body_names.get(k).filter(|name| !name.trim().is_empty()).map(String::as_str).unwrap_or(source);
unique_body_name(state, base)
@@ -1176,6 +1252,25 @@ fn intersection_tool_union(tools: Vec<Traced>) -> Result<Vec<Traced>> {
Ok(groups)
}
/// After a cut handled some sheet bodies as flat machining, apply it to the other targets.
fn apply_beside_sheet_cut(st: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Operation, targets: &[String], handled: &[String]) -> Result<()> {
let effective = if targets.is_empty() { &f.participants } else { targets };
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
let rest: Vec<String> = st
.bodies
.iter()
.filter(|b| {
!handled.contains(&b.name)
&& (all || if effective.is_empty() { b.component == f.component || b.feature == f.id } else { effective.contains(&b.name) })
})
.map(|b| b.name.clone())
.collect();
if rest.is_empty() {
return Ok(());
}
apply_traced(st, f, tools, op, &rest)
}
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
apply_traced(state, f, tools.into_iter().map(Traced::unnamed).collect(), op, targets)
}
@@ -1305,7 +1400,9 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
});
let live: std::collections::BTreeSet<&str> = state.bodies.iter().map(|b| b.name.as_str()).collect();
state.threads.retain(|t| live.contains(t.body.as_str()));
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)").
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)"),
// numbered on past names already taken (a body cut in pieces before).
let mut taken: std::collections::BTreeSet<String> = state.bodies.iter().map(|b| b.name.clone()).collect();
let mut split: Vec<ModelBody> = Vec::new();
for mb in std::mem::take(&mut state.bodies) {
if !affected.contains(&mb.name) || state.sheets.iter().any(|s| s.body == mb.name) {
@@ -1321,7 +1418,12 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
// (so pieces of one face in different bodies are told apart).
let lineages = crate::lineage::Lineage::lumps(&mb.body, mb.lineage().as_deref(), &pieces);
for (k, (p, lineage)) in pieces.into_iter().zip(lineages).enumerate() {
let name = if k == 0 { mb.name.clone() } else { format!("{} ({k})", mb.name) };
let name = if k == 0 {
mb.name.clone()
} else {
(k..=k + taken.len()).map(|j| format!("{} ({j})", mb.name)).find(|n| !taken.contains(n)).unwrap_or_default()
};
taken.insert(name.clone());
split.push(ModelBody::new(name, p, mb.feature).with_lineage(lineage));
}
}
@@ -1446,7 +1548,10 @@ fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, target
/// Booleans struggle with coincident faces (a cut starting on the face it cuts, a boss
/// starting on the face it joins). Extending the tool past such an end changes nothing when the
/// space beyond is empty (cut) or already material (join), so do exactly that.
/// space beyond is empty (cut) or already material (join), so do exactly that. Only an end
/// that lies on a target face parallel to it moves, and by less than half the way to the next
/// such face level: the samples can miss material beyond the end (a thin floor between a recess
/// above and a ring pocket below), which a longer tool would cut through.
fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi: f64, op: Operation, targets: &[String]) -> (f64, f64) {
if !matches!(op, Operation::Cut | Operation::Join) || st.bodies.is_empty() {
return (lo, hi);
@@ -1461,18 +1566,40 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi:
let samples = region_samples(r);
let n = plane.normal();
let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p));
let ok_beyond = |h: f64, dir: f64| {
// Heights of the targets' faces parallel to the profile plane.
let tol = (size * 1e-7).max(1e-9);
let levels: Vec<f64> = meshes
.iter()
.flat_map(|m| {
m.triangles.iter().filter_map(|t| {
let [a, b, c] = m.tri(t)?;
let normal = (b - a).cross(c - a).normalized()?;
(normal.dot(n).abs() > 1.0 - 1e-9).then(|| (a - plane.origin).dot(n))
})
})
.collect();
// How far the end at `h` may move in `dir`: zero unless a face lies on it, then short of
// half the gap to the next face level beyond it.
let room = |h: f64, dir: f64| {
if !levels.iter().any(|z| (z - h).abs() < tol) {
return 0.0;
}
let gap = levels.iter().map(|z| (z - h) * dir).filter(|g| *g > tol).fold(f64::INFINITY, f64::min);
delta.min(gap * 0.5)
};
let ok_beyond = |h: f64, dir: f64, step: f64| {
samples.iter().all(|s| {
let base = plane.to_world(*s);
[0.5, 1.0].iter().all(|k| {
let p = base + n * (h + dir * delta * k);
let p = base + n * (h + dir * step * k);
let inside = inside_any(p);
if op == Operation::Cut { !inside } else { inside }
})
})
};
let lo2 = if ok_beyond(lo, -1.0) { lo - delta } else { lo };
let hi2 = if ok_beyond(hi, 1.0) { hi + delta } else { hi };
let (down, up) = (room(lo, -1.0), room(hi, 1.0));
let lo2 = if down > tol && ok_beyond(lo, -1.0, down) { lo - down } else { lo };
let hi2 = if up > tol && ok_beyond(hi, 1.0, up) { hi + up } else { hi };
(lo2, hi2)
}
@@ -2108,7 +2235,9 @@ fn feature_tools_with_constructor(
if untrimmed {
*constructor = Some(ExtrudeConstructor { plane: ss.plane, interval: (lo + off, hi + off) });
}
if *operation == Operation::NewBody && extent.taper.is_none() {
// A Join tool's faces outlive the boolean where they stay on the result, so they are
// 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 source_curves = cap_bindings.as_ref().map(|_| crate::config::ExactSketchCurves::new(&ss));
@@ -2127,8 +2256,20 @@ fn feature_tools_with_constructor(
_ => None,
})
.collect();
let caps =
let mut caps =
cap_bindings.as_ref().and_then(|bindings| bindings.get(body_index)).cloned().unwrap_or_else(|| vec![None; curves.len()]);
// A cap moved off its designed height (into a Join target, or past a
// curved To face) is not the feature's start or end face.
let nominal = |plane: &solidcraft_geom::Plane| {
let height = (plane.origin - ss.plane.origin).dot(n);
let tolerance = 1e-9 * (1.0 + height.abs() + (lo + off).abs() + (hi + off).abs());
(height - (lo + off)).abs() <= tolerance || (height - (hi + off)).abs() <= tolerance
};
for (cap, source) in caps.iter_mut().zip(body.face_sources().into_iter().flatten()) {
if matches!(source, Some(kernel::FaceSource::ExtrudeCap { plane, .. }) if !nominal(plane)) {
*cap = None;
}
}
// 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)?;
Ok(Traced { body, lineage })
@@ -2848,8 +2989,10 @@ fn replay(
let nb = match &src.kind {
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &moved, r, distance2, angle, *flip)?,
// A copy is congruent to the original: the radius it was reduced to first.
FeatureKind::Fillet { fit, .. } => match st.fitted.get(&src.id).copied() {
Some(used) => kernel::fillet(&mb.body, &moved, used).or_else(|_| fillet_fitted(&mb.body, &moved, r, *fit).map(|x| x.0))?,
FeatureKind::Fillet { fit, .. } => match st.fitted.get(&src.id) {
Some(used) => {
kernel::fillet_each(&mb.body, &moved, used).or_else(|_| fillet_fitted(&mb.body, &moved, r, *fit).map(|x| x.0))?
}
None => fillet_fitted(&mb.body, &moved, r, *fit)?.0,
},
_ => kernel::fillet(&mb.body, &moved, r)?,
@@ -3060,6 +3203,38 @@ fn smooth_chain(b: &ModelBody, picks: &[Vec3]) -> Vec<Vec3> {
out
}
/// `plane` with the sheet face it names (see `sheet::plane_sheet_face`) replaced by that face's current
/// plane in the frame of component `comp`, or an error when the face is gone: a lost
/// attachment is reported, never moved to the nearest face.
pub(crate) fn sheet_face_plane_in_frame(doc: &Document, st: &ModelState, comp: u64, plane: &PlaneRef) -> Result<PlaneRef> {
Ok(match plane {
PlaneRef::Face { name: Some(name), anchor: None, .. } if crate::sheet::plane_sheet_face(plane).is_some() => {
let lost = || DocError::Invalid(format!("lost face attachment: the sheet face `{name}` no longer exists; reattach the sketch to a face"));
let (body, face) = crate::sheet::sheet_face_plane(st, name).ok_or_else(lost)?;
if doc.occurrences.is_empty() && doc.body_offsets.is_empty() {
return Ok(PlaneRef::Custom { plane: face });
}
// The body's own coordinates into the component's frame, as `state_in_frame` places its bodies.
let source = st.body(&body).ok_or_else(lost)?;
let inverse = crate::mat_inverse(&doc.component_transform(comp)).ok_or_else(lost)?;
let m = crate::mat_mul(&inverse, &doc.component_transform(doc.model_body_component(source)));
let m = doc.model_body_offset(source).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m);
let placed = Plane::new(crate::apply_point(&m, face.origin), crate::apply_vector(&m, face.x), crate::apply_vector(&m, face.y));
PlaneRef::Custom { plane: placed.ok_or_else(lost)? }
}
PlaneRef::Offset { base, distance } => {
PlaneRef::Offset { base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?), distance: distance.clone() }
}
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => PlaneRef::AtAngle {
base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?),
axis_origin: *axis_origin,
axis_dir: *axis_dir,
angle: angle.clone(),
},
other => other.clone(),
})
}
/// The plane of the body face a sketch sits on, found again: among planar faces with the
/// picked plane's normal, the one nearest `at`. The picked frame moves along its normal onto it.
fn face_plane(st: &ModelState, picked: &Plane, at: Vec3, name: Option<&str>) -> (Plane, Option<String>) {
@@ -3191,6 +3366,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
};
let (plane, face_warning) = match (anchor, plane) {
(Some(_), plane) => (doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?, None),
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
}
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref()),
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
@@ -3204,9 +3383,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
if let Some(w) = face_warning {
warns.insert(0, w);
}
// The stored sketch is solved for its stored values.
// The stored sketch is solved for its stored values, with its links where they
// were stored: a link that moved must not change the side a dimension from it is on.
let stored = sk.driving_values();
let report = doc.solve_sketch(vals, &mut sk, &stored)?;
let report = doc.solve_sketch(vals, &mut sk, &stored, Some(sketch))?;
if !report.keeps_orientation() {
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
}
@@ -3232,26 +3412,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref())?;
if !handled.is_empty() {
let effective = if targets.is_empty() { &f.participants } else { targets };
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
let rest: Vec<String> = st
.bodies
.iter()
.filter(|b| {
!handled.contains(&b.name)
&& (all
|| if effective.is_empty() {
b.component == f.component || b.feature == f.id
} else {
effective.contains(&b.name)
})
})
.map(|b| b.name.clone())
.collect();
if rest.is_empty() {
return Ok(());
}
return apply_traced(st, f, tools, *operation, &rest);
return apply_beside_sheet_cut(st, f, tools, *operation, targets, &handled);
}
}
apply_traced(st, f, tools, *operation, targets)
@@ -3355,13 +3516,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let fst = crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?;
doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?
}
None => doc.resolve_plane_in(vals, plane, 0, Some(st))?,
// Picks on bodies of other (moved) components are where those bodies are shown.
None => doc.resolve_plane_in(vals, plane, 0, Some(&construction_state(doc, st, f.component)))?,
};
st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Plane(pl) });
Ok(())
}
FeatureKind::ConstructionAxis { def } => {
let (origin, dir) = crate::construct::resolve_axis(&crate::construct::Ctx { doc, vals, st, depth: 0 }, def)?;
let (origin, dir) =
crate::construct::resolve_axis(&crate::construct::Ctx { doc, vals, st: &construction_state(doc, st, f.component), depth: 0 }, def)?;
st.construct.push(crate::construct::Construct {
feature: f.id,
name: f.name.clone(),
@@ -3370,7 +3533,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
Ok(())
}
FeatureKind::ConstructionPoint { def } => {
let p = crate::construct::resolve_point(&crate::construct::Ctx { doc, vals, st, depth: 0 }, def)?;
let p =
crate::construct::resolve_point(&crate::construct::Ctx { doc, vals, st: &construction_state(doc, st, f.component), depth: 0 }, def)?;
st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Point(p) });
Ok(())
}
@@ -3491,7 +3655,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let mb = st.body(n).cloned().ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
for (k, m) in mats.iter().enumerate() {
let copy = moved_copy(&mb, kernel::transform_matrix(&mb.body, *m)?, k + 1);
let name = copy_body_name(st, f, output, &mb.name);
let name = pattern_copy_name(st, f, output, &mb.name);
st.bodies.push(ModelBody::traced_new(name, copy, f.id));
output += 1;
}
@@ -3603,8 +3767,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
FeatureKind::Fillet { fit, .. } => {
let (b, used) = fillet_fitted(&mb.body, &edges, r, *fit)?;
if let Some(used) = used {
append_warning(warning, reduced_note(&used, r));
st.fitted.insert(f.id, used);
append_warning(warning, format!("the radius is reduced to {} mm, the largest that fits these edges", show_mm(used)));
}
(b, input)
}
@@ -3624,12 +3788,25 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
let corner = point(vals, corner)?;
let b = kernel::box_solid(corner, corner + s)?;
let b = if *operation == Operation::NewBody { b.with_box_certificate(f.id)? } else { b };
apply_traced(st, f, vec![crate::lineage::box_tool(b)], *operation, &[])
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b };
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation)?;
let tools = vec![crate::lineage::box_tool(b)];
if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
}
apply_traced(st, f, tools, *operation, &[])
}
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
let b = kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
apply_traced(st, f, vec![crate::lineage::cylinder_tool(b)], *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 = 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 handled = more::sheet_primitive_cut(f, st, &b, prism, *operation)?;
let tools = vec![crate::lineage::cylinder_tool(b)];
if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
}
apply_traced(st, f, tools, *operation, &[])
}
FeatureKind::Sphere { center, radius, operation } => {
let b = kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?;
@@ -3728,7 +3905,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
st.bodies.push(ModelBody::traced_new(name, b, f.id));
Ok(())
}
FeatureKind::Move { bodies, translate, rotate_axis, angle } => {
FeatureKind::Move { bodies, translate, rotate_axis, angle, copy } => {
let t =
Vec3::new(val(vals, &translate[0], Kind::Length)?, val(vals, &translate[1], Kind::Length)?, val(vals, &translate[2], Kind::Length)?);
let ang = match angle {
@@ -3736,11 +3913,20 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
None => 0.0,
};
let axis = rotate_axis.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;
for n in bodies {
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
if let Some(mb) = st.bodies.get_mut(i) {
let nb = Traced::step(kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?, &[&mb.traced()]);
*mb = ModelBody::traced_new(mb.name.clone(), nb, mb.feature);
let Some(mb) = st.bodies.get(i).cloned() else { continue };
let nb = kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?;
if *copy {
let traced = moved_copy(&mb, nb, 1);
let name = copy_body_name(st, f, output, &mb.name);
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
output += 1;
} else if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::traced_new(mb.name.clone(), Traced::step(nb, &[&mb.traced()]), mb.feature);
}
}
Ok(())
@@ -3817,6 +4003,16 @@ fn unique_body_name(st: &ModelState, base: &str) -> String {
/// it are moved into its frame (through both placements), so a sketch on another component's
/// face finds the face where it is shown and projects its edges there. The model itself when
/// every placement agrees (the usual case).
/// What construction geometry of component `comp` is built on: the model as that component sees
/// it, other components' bodies where they are placed. In the root, every placement of a
/// component (its pasted instances too) is there, as the viewport shows it.
fn construction_state<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
if comp == 0 && !doc.occurrences.is_empty() {
return std::borrow::Cow::Owned(world_state(doc, st));
}
state_in_frame(doc, st, comp)
}
pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
state_in_frame_at(doc, st, comp, doc.component_transform(comp))
}
+168 -34
View File
@@ -325,6 +325,21 @@ fn sheet_for(st: &ModelState, body: &Option<String>, pick: Option<Vec3>) -> Resu
.ok_or_else(|| DocError::Invalid("there is no sheet metal body".into()))
}
/// Whether a point touches sheet `i`'s material: inside it, on one of its faces, or within its
/// display tolerance of one (picks land on the display mesh). A sheet with no mesh to test
/// against counts as touched.
fn on_sheet_material(st: &ModelState, i: usize, p: Vec3) -> bool {
let Some(mb) = st.sheets.get(i).and_then(|sh| st.body(&sh.body)) else { return true };
let mesh = mb.mesh();
if mesh.triangles.is_empty() {
return true;
}
// Twice the display mesh's chord tolerance (see `ModelBody::mesh`).
let step = 2.0 * (mb.body.size() * 1e-3).max(1e-3);
let offsets = [-step, 0.0, step];
offsets.iter().any(|&dx| offsets.iter().any(|&dy| offsets.iter().any(|&dz| mesh.contains(p + Vec3::new(dx, dy, dz)))))
}
/// A region mirrored in the sketch x axis (y → −y); curves other than lines and arcs as polylines.
fn mirror_y(r: &Region2) -> Region2 {
let f = |p: Vec2| Vec2::new(p.x, -p.y);
@@ -415,8 +430,21 @@ pub(super) fn sheet_eval(
Ok(())
}
FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body } => {
let i = sheet_for(st, body, edges.first().copied())?;
let i = sheet_for(st, body, edges.first().map(|e| e.point))?;
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());
for e in edges {
let height = match &e.height {
Some(x) => val(vals, x, Kind::Length)?,
None => h,
};
let angle = match &e.angle {
Some(x) => val(vals, x, Kind::Angle)?,
None => a,
};
own.push((height, angle));
}
let rr = match radius {
Some(e) => val(vals, e, Kind::Length)?,
None => {
@@ -428,9 +456,14 @@ pub(super) fn sheet_eval(
if sh.flat {
return Err(DocError::Invalid("refold the sheet before adding flanges".into()));
}
let picks = sheet_picks(sh, f, edges, warning);
let points: Vec<Vec3> = edges.iter().map(|e| e.point).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_noted(&picks, h, a, rr, position, *flip)?;
let (_, notes) = sh.add_flanges_each(&each, rr, position, *flip)?;
if !notes.is_empty() {
let notes = notes.join("; ");
*warning = Some(match warning.take() {
@@ -482,12 +515,25 @@ pub(super) fn sheet_eval(
doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius
}
};
// 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)
{
*warning = Some(format!(
"{} at ({:.3}, {:.3}, {:.3}); the side of the fold line it lies on stays",
sheet::FOLD_FIXED_OFF_SHEET,
p.x,
p.y,
p.z
));
}
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 folding it".into()));
}
let n0 = sh.flanges.len();
sh.add_fold(a, b, ang, rr, position, *flip, *fixed)?;
sh.add_fold(a, b, ang, rr, position, *flip, *fixed, warning)?;
tag_flanges(sh, n0, f.id);
rebuild_sheet(st, i)
}
@@ -622,24 +668,112 @@ pub(super) fn sheet_cut(
constructor: Option<&ExtrudeConstructor>,
) -> Result<Vec<String>> {
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
if extent.taper.is_some() {
return Ok(Vec::new());
}
let targets = if targets.is_empty() { &f.participants } else { targets };
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
let eligible = |original: &crate::sheet::SheetBody| {
all || if targets.is_empty() {
st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id))
} else {
targets.contains(&original.body)
}
};
if !st.sheets.iter().any(&eligible) {
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 regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let n = ss.plane.normal();
sheet_prism_cut(f, st, &ss.plane, &regions, *operation, tools, targets, constructor, extent.to.is_some())
}
/// Whether a cut of `f` with these targets (else its participants, else its component's
/// bodies) machines the sheet `original`.
fn sheet_eligible(f: &Feature, st: &ModelState, targets: &[String], original: &crate::sheet::SheetBody) -> bool {
let targets = if targets.is_empty() { &f.participants } else { targets };
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
all || if targets.is_empty() {
st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id))
} else {
targets.contains(&original.body)
}
}
/// Whether a cut of `f` with these targets machines any sheet metal body.
fn any_eligible_sheet(f: &Feature, st: &ModelState, targets: &[String]) -> bool {
st.sheets.iter().any(|original| sheet_eligible(f, st, targets, original))
}
/// A Box or Cylinder cut (or intersect) whose tool is a straight prism along the normal of
/// the sheet it meets: machined like the equivalent extrude of its footprint, so the sheet
/// keeps it as a flat cut-out or pocket. Returns the sheet bodies it handled; none when the
/// primitive is not such a prism (the generic preflight then refuses real 3D machining).
pub(super) fn sheet_primitive_cut(f: &Feature, st: &mut ModelState, tool: &Body, prism: PrimitivePrism, operation: Operation) -> Result<Vec<String>> {
if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) {
return Ok(Vec::new());
}
// The first sheet the primitive meets decides the machining direction.
let mut normal = None;
for sh in st.sheets.iter().filter(|sh| sheet_eligible(f, st, &[], sh)) {
if let Some(mb) = st.body(&sh.body)
&& kernel::boolean(&mb.body, tool, kernel::BoolOp::Intersect)?.is_some()
{
normal = Some(sh.frame.normal());
break;
}
}
let Some(normal) = normal else { return Ok(Vec::new()) };
let Some((plane, region, interval)) = prism.along(normal) else { return Ok(Vec::new()) };
let tools = kernel::extrude(&plane, std::slice::from_ref(&region), interval.0, interval.1)?;
sheet_prism_cut(f, st, &plane, std::slice::from_ref(&region), operation, &tools, &[], None, false)
}
/// A primitive tool's exact geometry, for [`sheet_primitive_cut`].
pub(super) enum PrimitivePrism {
/// An axis-aligned box between two corners.
Box { lo: Vec3, hi: Vec3 },
/// A cylinder: base centre, unit axis, radius and signed height.
Cylinder { base: Vec3, axis: Vec3, radius: f64, height: f64 },
}
impl PrimitivePrism {
/// The sketch-like plane, footprint region and depth interval of this tool as a prism along
/// `normal` (either sense), or None when it is not one.
fn along(&self, normal: Vec3) -> Option<(Plane, Region2, (f64, f64))> {
let n = normal.normalized()?;
match *self {
PrimitivePrism::Box { lo, hi } => {
let (lo, hi) = (lo.min(hi), lo.max(hi));
let s = hi - lo;
// Plane axes (x, y) and the depth axis, as a right-handed frame.
let (x, y, depth) = if n.cross(Vec3::Z).len() <= 1e-12 {
(Vec3::X, Vec3::Y, s.z)
} else if n.cross(Vec3::X).len() <= 1e-12 {
(Vec3::Y, Vec3::Z, s.x)
} else if n.cross(Vec3::Y).len() <= 1e-12 {
(Vec3::Z, Vec3::X, s.y)
} else {
return None;
};
let plane = Plane::new(lo, x, y)?;
let (w, h) = (s.dot(x), s.dot(y));
let outer = Loop2::polygon(&[Vec2::ZERO, Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)]);
Some((plane, Region2 { outer, holes: vec![] }, (0.0, depth)))
}
PrimitivePrism::Cylinder { base, axis, radius, height } => {
let axis = axis.normalized()?;
if axis.cross(n).len() > 1e-12 {
return None;
}
let plane = Plane::from_normal(base, axis)?;
Some((plane, Region2 { outer: Loop2::circle(Vec2::ZERO, radius), holes: vec![] }, (height.min(0.0), height.max(0.0))))
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn sheet_prism_cut(
f: &Feature,
st: &mut ModelState,
plane: &Plane,
regions: &[Region2],
operation: Operation,
tools: &[Body],
targets: &[String],
constructor: Option<&ExtrudeConstructor>,
footprints_bound: bool,
) -> Result<Vec<String>> {
let n = plane.normal();
let mut work = sheet::FlatWork::new();
let faces = tools.iter().fold(0usize, |count, body| count.saturating_add(body.face_count()));
if tools.len() > 1000 || faces > 16_384 {
@@ -650,7 +784,7 @@ pub(super) fn sheet_cut(
let mut cap_roundoff: f64 = 0.0;
let mut current_footprints = Vec::new();
for tool in tools {
let Some((lo, hi, roundoff, footprint)) = sheet_tool_span(tool, &ss.plane) else {
let Some((lo, hi, roundoff, footprint)) = sheet_tool_span(tool, plane) else {
// Modified tools need a current geometric prism certificate. The generic
// sheet preflight permits actual no-change, otherwise refuses stale metadata.
return Ok(Vec::new());
@@ -670,7 +804,7 @@ pub(super) fn sheet_cut(
let selected_edges =
regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).fold(0usize, usize::saturating_add);
work.charge(faces.saturating_mul(selected_edges).saturating_mul(16))?;
let nominal = constructor.filter(|c| c.plane == ss.plane).and_then(|c| {
let nominal = constructor.filter(|c| c.plane == *plane).and_then(|c| {
let placed_basis = Plane::new(c.plane.origin, c.plane.x, c.plane.y)?;
(current_footprints.len() == tools.len()
&& current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r)))
@@ -678,23 +812,23 @@ pub(super) fn sheet_cut(
});
let mut plans = Vec::new();
'sheets: for original in &st.sheets {
if !eligible(original) {
if !sheet_eligible(f, st, targets, original) {
continue;
}
let Some(mb) = st.body(&original.body) else {
continue;
};
let mut sh = original.clone();
if *operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) {
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
if operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) {
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
continue;
}
if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, *operation)? {
if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, operation)? {
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
continue;
}
for bends in sh.unmodeled_solids(&ss.plane)? {
for bends in sh.unmodeled_solids(plane)? {
for tool in tools {
if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() {
return Err(DocError::Invalid(
@@ -706,21 +840,21 @@ pub(super) fn sheet_cut(
let candidates = if sh.flat {
if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|r| (None, r)).collect() }
} else {
sh.material_panels(Some(&ss.plane), &mut work)?
sh.material_panels(Some(plane), &mut work)?
};
let mut modeled = false;
for (pi, panel) in candidates {
let w = if sh.flat { sh.world(None) } else { sh.world(pi) };
let zorigin = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n);
let zorigin = (apply_point(&w, Vec3::ZERO) - plane.origin).dot(n);
let sign = crate::apply_vector(&w, Vec3::Z).dot(n);
if sign.abs() < 1e-6 {
continue;
}
let (a, b) = ((span.0 - zorigin) / sign, (span.1 - zorigin) / sign);
let roundoff = (cap_roundoff
+ 128.0 * f64::EPSILON * (apply_point(&w, Vec3::ZERO).len() + ss.plane.origin.len() + span.0.abs() + span.1.abs() + sh.t).max(1.0))
+ 128.0 * f64::EPSILON * (apply_point(&w, Vec3::ZERO).len() + plane.origin.len() + span.0.abs() + span.1.abs() + sh.t).max(1.0))
/ sign.abs();
let nominal_depth = if apply_point(&w, Vec3::ZERO) == ss.plane.origin
let nominal_depth = if apply_point(&w, Vec3::ZERO) == plane.origin
&& let Some((lo, hi)) = nominal
&& (span.0 - lo).abs() <= roundoff
&& (span.1 - hi).abs() <= roundoff
@@ -740,7 +874,7 @@ pub(super) fn sheet_cut(
None
};
if nominal_depth.is_none() && roundoff > 1e-7 {
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
plans.push((original.body.clone(), Some((original.clone(), mb.body.clone()))));
continue 'sheets;
}
@@ -748,12 +882,12 @@ pub(super) fn sheet_cut(
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
// Sub-resolution layers must not be silently erased by endpoint snapping.
let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6);
let mapped = if extent.to.is_some() && current_footprints.len() == tools.len() {
let mapped = if footprints_bound && current_footprints.len() == tools.len() {
current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
} else {
regions.iter().map(|r| sh.region_to_flat(pi, &ss.plane, r)).collect::<Result<Vec<_>>>()?
regions.iter().map(|r| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
};
if *operation == Operation::Intersect {
if operation == Operation::Intersect {
if z1 <= z0 {
sh.holes.push(panel);
modeled = true;
@@ -809,7 +943,7 @@ pub(super) fn sheet_cut(
}
}
if !modeled {
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
} else if sh.flat_material()?.is_empty() {
// Empty exact material removes geometry and metadata together. Budget or
+38 -9
View File
@@ -117,11 +117,13 @@ impl Document {
}
}
/// All status planes are world-space. A lost plane is a repair guide only.
/// All status planes are world-space. A lost plane is a repair guide only. `Sheet` is a sketch on
/// a flat face of a sheet metal body, attached by the name of its panel (`sheet::sheet_face_name`).
#[derive(Clone, Debug)]
pub enum FaceAttachmentStatus {
Fixed { plane: Option<Plane> },
Legacy { plane: Option<Plane> },
Sheet { plane: Option<Plane> },
Healthy { plane: Plane, target: ConfigFaceTarget, path: Vec<u64> },
Lost { message: String, repair_plane: Option<Plane> },
}
@@ -142,15 +144,30 @@ impl Document {
author: u64,
author_path: &[u64],
) -> Result<FaceAnchor> {
let source = body
// A construction certificate that names this face alone, else its exact history name.
let certified = body
.body
.face_plane(index)
.ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))
.and_then(|source| {
let target = self.strong_face_target(body, index, source.origin)?;
if target.certificate.is_none() {
return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into()));
}
let indices = self.configuration_face_indices(body, &target)?;
self.coplanar_pieces(body, &indices, |_| true)?;
Ok(target)
});
let target = match certified {
Ok(target) => target,
Err(error) => match body.body.planar_face(index).and_then(|plane| self.lineage_face_target(body, index, plane.origin)) {
Some(target) => target,
None => return Err(error),
},
};
let source = self
.anchor_face_plane(body, index, target.certificate.as_ref())
.ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))?;
let target = self.strong_face_target(body, index, source.origin)?;
if target.certificate.is_none() {
return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into()));
}
self.configuration_face_indices(body, &target)?;
let mut anchor = FaceAnchor {
target,
component: self.model_body_component(body),
@@ -176,9 +193,14 @@ impl Document {
state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("attached body `{}` no longer exists", anchor.target.body)))?;
let indices = self.configuration_face_indices(body, &anchor.target)?;
let first = indices.first().copied().ok_or_else(|| DocError::Unknown("attached face no longer exists".into()))?;
let plane = body.body.face_plane(first).ok_or_else(|| DocError::Invalid("attached face no longer has an exact planar source".into()))?;
let certificate = anchor.target.certificate.as_ref();
let plane = self
.anchor_face_plane(body, first, certificate)
.ok_or_else(|| DocError::Invalid("attached face no longer has an exact planar source".into()))?;
for index in indices.iter().skip(1) {
let other = body.body.face_plane(*index).ok_or_else(|| DocError::Invalid("attached face pieces are not exactly planar".into()))?;
let other = self
.anchor_face_plane(body, *index, certificate)
.ok_or_else(|| DocError::Invalid("attached face pieces are not exactly planar".into()))?;
if plane.normal().dot(other.normal()) < 1. - 1e-8 || (other.origin - plane.origin).dot(plane.normal()).abs() > 1e-7 {
return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into()));
}
@@ -210,6 +232,13 @@ impl Document {
self.resolve_plane(&vals, plane, 0).ok()
});
let current = world.and_then(|world| current.and_then(|plane| map_plane(&world, &plane).ok()));
if crate::sheet::plane_sheet_face(plane).is_some() {
let state = model.state_before(feature.id);
return match crate::eval::sheet_face_plane_in_frame(self, &state, feature.component, plane) {
Ok(_) => FaceAttachmentStatus::Sheet { plane: current },
Err(error) => FaceAttachmentStatus::Lost { message: error.to_string(), repair_plane: current },
};
}
return if has_face_reference(plane) {
FaceAttachmentStatus::Legacy { plane: current }
} else {
+31 -2
View File
@@ -46,13 +46,40 @@
//! legacy mark (`?F2:top#2`) and are found again as the build that saved them found them.
//! Pieces a feature splits a body into are named after their faces; features saved before
//! keep the kernel's shell order (`shell_piece_order`), which their stored body names mean.
//! - 15: sketch face anchors and face bindings can name a cylinder primitive's cap
//! (`cylinder_cap`), sketches can link lines to their own axes (`sketch_axis`), and sketch
//! anchors can name a sheet metal panel side (`sheet:<body>:<panel>:<side>`). Version 14
//! designs need no transformation; the version stamp lets older readers request an update
//! instead of encountering an unknown certificate or link kind.
//! - 16: sheet metal rules can choose a corner relief shape (`corner_relief_shape`: notch,
//! round or rectangular). Version 15 designs need no transformation (an absent shape is the
//! notch they always had); the version stamp lets older readers request an update instead of
//! silently building notches where a design asks for another shape.
//! Under the same version, sketch text can be set in a font other than the built-in one
//! (`font` in the text link's source: an installed family or a font file); a text without
//! `font` is in the built-in font, as it always was.
//! A Move feature can also keep its source bodies and transform a copy (`copy: true`); a
//! move without it saves as before.
//! A component can link to another design file (`links`: component id → `{path, feature}`,
//! the path relative to the design's folder); the linked bodies are kept in the component's
//! Import feature, so an older build opens the design with them and only loses the link.
//! A design can also define its own physical materials (`user_materials`: name, density in
//! g/cm³, optional look), which bodies (`materials`) and configuration material cells name
//! like built-in ones; a design without them saves as before.
//! A design can also keep what was hidden when it was saved (`visibility`: hidden bodies,
//! occurrence placements, sketches and origin items; `browser.visibility`). A design without
//! it shows everything, as it always did; a reader that ignores it loses only the hiding.
//! Sketch face anchors can name a flat face by its exact history name (a `lineage`
//! certificate: the target's `name`, e.g. a cut wall `F5:side:l1` or a pattern copy
//! `F4:top@F6.2`), where no construction certificate names it alone. Anchors with
//! construction certificates resolve as before.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 14;
pub const FORMAT: u32 = 16;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -69,7 +96,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 13] = [
const JSON_STEPS: [JsonStep; 15] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -83,6 +110,8 @@ const JSON_STEPS: [JsonStep; 13] = [
|_| Ok(()),
|_| Ok(()),
v13_to_v14,
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
+1
View File
@@ -32,6 +32,7 @@ pub mod naming;
mod params;
mod piece_names;
mod profile_refs;
pub mod progress;
pub use profile_refs::{capture_profile_selection, profile_selection_indices};
pub mod plastic;
pub mod project;
+29
View File
@@ -211,6 +211,35 @@ impl FaceHit {
}
}
impl FaceHit {
/// Where the line through `p` along `dir` meets the face found (the hit nearest `p`, on
/// either side of it), `None` when the line misses the face or runs along it. A place on
/// a face follows the face this way, along its own axis, instead of sliding sideways to
/// [`point`](Self::point), the face's nearest point.
pub fn along(&self, st: &ModelState, p: Vec3, dir: Vec3) -> Option<Vec3> {
let b = st.bodies.get(self.body)?;
let d = dir.normalized()?;
let m = b.mesh();
let slack = (b.body.size() * 1e-9).max(1e-9);
let mut best: Option<f64> = None;
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
if *f as usize != self.face {
continue;
}
let Some([a, bb, c]) = m.tri(t) else { continue };
// A hair's width around each triangle, so a place on the seam of two is on the face.
let centre = (a + bb + c) * (1.0 / 3.0);
let grow = |v: Vec3| v + (v - centre).normalized().unwrap_or(Vec3::ZERO) * slack;
if let Some(hit) = naming::ray_hit(p, d, grow(a), grow(bb), grow(c))
&& best.is_none_or(|old| hit.abs() < old.abs())
{
best = Some(hit);
}
}
best.map(|t| p + d * t)
}
}
impl EdgeHit {
/// What a feature says about how it found an edge by its stored name `name` (`None`: exactly).
pub fn note(&self, name: &str) -> Option<String> {
+27 -9
View File
@@ -31,6 +31,8 @@ pub struct NamingOrigin {
pub depth: usize,
/// Instance transforms of a pattern or mirror (its copies are named after their sources).
pub mats: Vec<crate::Mat>,
/// The sheet metal bodies after a sheet feature (their folds place moved faces).
pub sheets: Vec<crate::sheet::SheetBody>,
}
/// The lazily computed names of a body, shared by its clones.
@@ -334,6 +336,24 @@ fn geometric_from(b: &ModelBody, o: &NamingOrigin, nf: usize, names_of: fn(&Mode
names = got.into_iter().map(|n| n.map(|n| format!("{n}@F{fid}.{}", k + 1))).collect();
}
}
// A sheet feature that moved panels (a fold, an unfold): faces it carried along lie on the
// faces they were, taken back through the panel they are on.
if let (Some(after), Some(before), Some(input)) = (
o.sheets.iter().find(|s| s.body == b.name),
o.before.sheets.iter().find(|s| s.body == b.name),
inputs.iter().find(|(x, _, _)| x.name == b.name),
) {
let input = std::slice::from_ref(input);
for (name, ps) in names.iter_mut().zip(&pts) {
if name.is_some() {
continue;
}
let back: Option<Vec<Vec3>> = ps.iter().map(|p| after.point_before(before, *p)).collect();
if let Some(back) = back.filter(|b| !b.is_empty()) {
*name = vote(&back, input, tol, None);
}
}
}
let empty = Vec::new();
let names: Vec<String> = names
.into_iter()
@@ -386,7 +406,7 @@ fn behind(ps: &[Vec3], ns: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<St
votes.first().filter(|(_, k)| *k * 2 >= ps.len().max(1)).map(|(n, _)| n.clone())
}
fn ray_hit(o: Vec3, d: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Option<f64> {
pub(crate) fn ray_hit(o: Vec3, d: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Option<f64> {
let (e1, e2) = (b - a, c - a);
let h = d.cross(e2);
let det = e1.dot(h);
@@ -454,14 +474,12 @@ pub fn strip_edge_piece(n: &str) -> String {
/// Edges sharing a name: number them along the longest axis of their spread.
fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
let mut seen: Vec<String> = Vec::new();
for i in 0..names.len() {
let n = names[i].clone();
if seen.contains(&n) {
continue;
}
seen.push(n.clone());
let same: Vec<usize> = (0..names.len()).filter(|j| names[*j] == n).collect();
// The pieces of each name, gathered in one pass (a faceted body has thousands of names).
let mut groups: std::collections::BTreeMap<String, Vec<usize>> = std::collections::BTreeMap::new();
for (i, n) in names.iter().enumerate() {
groups.entry(n.clone()).or_default().push(i);
}
for (n, same) in groups {
if same.len() < 2 {
continue;
}
+10 -1
View File
@@ -223,12 +223,21 @@ impl FeatureKind {
v.push(("Catch length", catch_length, L));
}
FeatureKind::SheetContour { distance, .. } => v.push(("Distance", distance, L)),
FeatureKind::SheetFlange { height, angle, radius, .. } => {
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
v.push(("Height", height, L));
v.push(("Angle", angle, A));
if let Some(r) = radius {
v.push(("Bend radius", r, L));
}
// The edges' own values come last, so the inputs above keep their names.
for e in edges {
if let Some(a) = &mut e.angle {
v.push(("Edge angle", a, A));
}
if let Some(h) = &mut e.height {
v.push(("Edge height", h, L));
}
}
}
FeatureKind::SheetFold { angle, radius, .. } => {
v.push(("Angle", angle, A));
+133
View File
@@ -0,0 +1,133 @@
//! Progress and cooperative cancellation for a long evaluation (opening a large design) run
//! off the window thread. The caller wraps the work in [`watch`]; the timeline's evaluation
//! loop reports each feature it reaches and, once [`Progress::cancel`] is called, skips the
//! rest. Nothing changes for evaluations that are not watched.
use std::cell::RefCell;
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
/// Shared between the worker evaluating a design and the window showing its progress.
#[derive(Debug, Default)]
pub struct Progress {
cancelled: AtomicBool,
done: AtomicUsize,
total: AtomicUsize,
}
impl Progress {
/// Ask the watched evaluation to stop at its next feature.
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Relaxed);
}
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Relaxed)
}
/// Features evaluated so far and the timeline's length (0 before evaluation starts).
pub fn features(&self) -> (usize, usize) {
(self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed))
}
}
struct Current {
progress: Arc<Progress>,
/// Nested evaluations (derived designs) don't overwrite the outer timeline's count.
depth: usize,
}
thread_local! {
static CURRENT: RefCell<Option<Current>> = const { RefCell::new(None) };
}
/// Run `f` with `progress` watching every evaluation it makes on this thread.
pub fn watch<R>(progress: Arc<Progress>, f: impl FnOnce() -> R) -> R {
let previous = CURRENT.with(|c| c.replace(Some(Current { progress, depth: 0 })));
struct Restore(Option<Option<Current>>);
impl Drop for Restore {
fn drop(&mut self) {
if let Some(previous) = self.0.take() {
CURRENT.with(|c| *c.borrow_mut() = previous);
}
}
}
let _restore = Restore(Some(previous));
f()
}
/// Entered at the start of an evaluation; the guard leaves it.
pub(crate) struct Evaluation {
outer: bool,
}
pub(crate) fn begin(total: usize) -> Evaluation {
let outer = CURRENT.with(|c| {
let mut c = c.borrow_mut();
let Some(cur) = c.as_mut() else { return false };
cur.depth += 1;
if cur.depth == 1 {
cur.progress.total.store(total, Ordering::Relaxed);
cur.progress.done.store(0, Ordering::Relaxed);
true
} else {
false
}
});
Evaluation { outer }
}
impl Evaluation {
/// Feature `index` is next. True when the watcher asked to stop: skip it.
pub(crate) fn step(&self, index: usize) -> bool {
CURRENT.with(|c| {
let c = c.borrow();
let Some(cur) = c.as_ref() else { return false };
if self.outer {
cur.progress.done.store(index, Ordering::Relaxed);
}
cur.progress.is_cancelled()
})
}
}
impl Drop for Evaluation {
fn drop(&mut self) {
CURRENT.with(|c| {
if let Some(cur) = c.borrow_mut().as_mut() {
cur.depth = cur.depth.saturating_sub(1);
if self.outer {
let total = cur.progress.total.load(Ordering::Relaxed);
cur.progress.done.store(total, Ordering::Relaxed);
}
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unwatched_evaluations_never_stop_and_watched_ones_report_and_stop() {
let e = begin(3);
assert!(!e.step(0));
drop(e);
let p = Arc::new(Progress::default());
watch(p.clone(), || {
let e = begin(5);
assert!(!e.step(2));
assert_eq!(p.features(), (2, 5));
{
let inner = begin(100);
assert!(!inner.step(50));
}
assert_eq!(p.features(), (2, 5), "a nested evaluation keeps the outer count");
p.cancel();
assert!(e.step(3));
});
assert_eq!(p.features(), (5, 5));
assert!(!begin(1).step(0), "the watch ends with its scope");
}
}
+5 -1
View File
@@ -259,7 +259,10 @@ fn resolve_uncached(
_ => vec![LinkGeom::Point(o)],
}
}
LinkSource::Text { text, at, height, angle } => solidcraft_sketch::text_geometry(text, *at, *height, *angle)?,
LinkSource::SketchAxis { name } => {
vec![solidcraft_sketch::sketch_axis_geom(name).ok_or_else(|| DocError::Unknown(format!("sketch axis `{name}`")))?]
}
LinkSource::Text { text, at, height, angle, font } => solidcraft_sketch::text_geometry_in(font.as_deref(), text, *at, *height, *angle)?,
LinkSource::Plane { name } => {
let pr =
if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } };
@@ -321,6 +324,7 @@ fn certified_planar_construction(body: &crate::ModelBody, index: usize) -> bool
Some(
solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(_), .. }
| solidcraft_kernel::FaceSource::Box { producer: Some(_), .. }
| solidcraft_kernel::FaceSource::CylinderCap { .. }
| solidcraft_kernel::FaceSource::CertifiedExtrudeSide { plane: Some(_), .. }
| solidcraft_kernel::FaceSource::CertifiedExtrudeCap { .. }
)
+2435 -61
View File
File diff suppressed because it is too large Load Diff
@@ -88,3 +88,68 @@ fn struts_ending_on_a_bore_wall_join_without_a_panic() {
result.unwrap();
assert_eq!(s.model.state().bodies.len(), 1);
}
/// A blind Ø2 hole into the flat back of a long D-profile bar ends 2 mm short of the round front,
/// yet the cut was refused (#544). It removes π · 1² · 10 mm³ (within 5 %: the measure tessellates
/// the small bore) and adds the bore's wall and floor to the bar's six faces.
#[test]
fn blind_hole_into_a_long_d_profile_bar_stops_short_of_the_round_front() {
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/blind_hole_in_long_d_profile.json")).unwrap();
let steps = script.get("commands").and_then(Value::as_array).unwrap();
let mut s = Session::default();
s.run_script(&Value::Array(steps[..5].to_vec())).unwrap();
let bar = volume(&s, "Slat");
s.run_script(&Value::Array(steps[5..].to_vec())).unwrap();
let hole = std::f64::consts::PI * 10.0;
let removed = bar - volume(&s, "Slat");
assert!((removed - hole).abs() < 0.05 * hole, "removed {removed} vs {hole}");
let faces = s.model.state().body("Slat").map(|b| solidcraft_kernel::measure(&b.body).unwrap().merged.faces);
assert_eq!(faces, Some(8));
}
/// A ring pocket cut from below a recess leaves a 0.5 mm floor joining the outer wall to the inner
/// block: one connected part, yet the cut left two bodies and lost volume (#545).
#[test]
fn ring_cut_below_a_recess_keeps_the_part_in_one_body() {
let s = replay(include_str!("../../../examples/regressions/ring_cut_below_recess_thin_floor.json"));
let bodies: Vec<(String, f64)> = s.model.state().bodies.iter().map(|b| (b.name.clone(), volume(&s, &b.name))).collect();
let want = 100.0 * 100.0 * 30.0 - 88.0 * 88.0 * 12.0 - (95.0 * 95.0 - 68.0 * 68.0) * 17.5;
assert_eq!(bodies.len(), 1, "{bodies:?}");
let got = volume(&s, "Frame");
assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}");
}
/// Four ribs joined on a rounded lid, standing on the plate over the skirt and flush with the
/// window's sides: each adds exactly its own material and nothing changes away from it. The
/// joins displaced the tool and one left a slit through the far side of the skirt and plate
/// (#616), which no volume shows: the area does. The reply of each join carries its warning, as
/// the timeline does (an agent placing parts over MCP saw none).
#[test]
fn ribs_flush_with_a_window_change_nothing_away_from_them() {
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/lid_ribs_flush_with_window.json")).unwrap();
let steps = script.get("commands").and_then(Value::as_array).unwrap();
let (lid, ribs) = steps.split_at(steps.len() - 4);
let mut s = Session::default();
s.run_script(&Value::Array(lid.to_vec())).unwrap();
let measure = |s: &Session| s.model.state().body("Lid").map(|b| solidcraft_kernel::measure(&b.body).unwrap()).unwrap();
let (v0, a0) = (measure(&s).volume, measure(&s).area);
assert!((v0 - 6703.5222).abs() < 1e-3, "{v0}");
for (i, rib) in ribs.iter().enumerate() {
let reply = s.execute(rib["command"].as_str().unwrap(), &rib["params"]).unwrap();
let id = reply["feature"].as_u64().unwrap();
let result = s.model.result(id).unwrap();
assert_eq!(reply["warning"], json!(result.warning), "the reply says what the timeline says");
assert!(!result.warning.as_deref().unwrap_or_default().contains("tool displacement"), "{:?}", result.warning);
let m = measure(&s);
let n = (i + 1) as f64;
// 1.6 x 6 x 11.7 less the 1.35 x 6 x 3 inside the skirt.
assert!((m.volume - (v0 + n * 88.02)).abs() < 1e-6, "rib {}: {}", i + 1, m.volume - v0);
// Top 9.6, outer side 6 x 8.7 above the skirt, inner side 6 x 11.7 (flush with the
// window's side), ends 2 x (1.6 x 11.7 - 1.35 x 3); the skirt top 8.1, its inner side
// 18 and 1.5 of the plate go under the rib.
let area = 9.6 + 52.2 + 70.2 + 29.34 - 8.1 - 18.0 - 1.5;
assert!((m.area - (a0 + n * area)).abs() < 1e-6, "rib {}: area {} (no slit adds any)", i + 1, m.area - a0);
}
let bounds = s.model.state().body("Lid").unwrap().body.tessellate(0.01).unwrap().bounds();
assert!((bounds.max.z - 14.1).abs() < 1e-9, "{}", bounds.max.z);
}
+75 -2
View File
@@ -33,10 +33,13 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"),
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
CommandSpec::new("view.list", "List Named Views", view_list).noundo().doc_only(),
CommandSpec::new("view.apply", "Restore Named View", view_apply).icon("camera").noundo().params(
"view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (radians, mm) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step",
),
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
or folder?: bodies|sketches|construction|origin (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). View state: not saved, not an undo step",
or folder?: bodies|sketches|construction|origin (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design; not an undo step",
),
];
@@ -375,6 +378,38 @@ fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "view": name }))
}
fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "view.apply";
let cam = match (str_(p, "view"), p.get("camera")) {
(Some(_), Some(_)) => return Err(bad(cmd, "give `view` or `camera`, not both")),
(None, None) => return Err(bad(cmd, "`view` (a name) or `camera` is required")),
(None, Some(c)) => {
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
camera_of(&v)
}
// Standard view names come first, as in the browser's Named Views folder.
(Some(name), None) => match crate::render::StandardView::parse(name) {
Some(sv) => {
let mut c = crate::view::home_camera(s);
c.set_view(sv);
c.fit(&crate::view::bounds(s));
c
}
None => s.doc.named_views.iter().find(|v| v.name == name).map(camera_of).ok_or_else(|| bad(cmd, format!("no named view `{name}`")))?,
},
};
if !cam.is_valid() || !cam.fov.is_finite() || cam.fov < 0.0 {
return Err(bad(cmd, "the camera must be finite with a positive distance"));
}
s.camera = Some(cam);
Ok(json!({ "camera": cam }))
}
/// The orbit camera of a named view (Z up, no roll).
fn camera_of(v: &solidcraft_doc::NamedView) -> crate::render::Camera {
crate::render::Camera { target: v.target, yaw: v.yaw, pitch: v.pitch, distance: v.distance, fov: v.fov, ..Default::default() }
}
fn view_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let k = str_(p, "view").ok_or_else(|| bad(cmd, "`view` must be a view name"))?;
s.doc.named_views.iter().position(|v| v.name == k).ok_or_else(|| bad(cmd, format!("no named view `{k}`")))
@@ -464,6 +499,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
// Later features refer to bodies by name.
let mut failed = None;
for f in d.features.iter_mut().filter(|f| f.id != fid) {
if let FeatureKind::Sketch { plane, .. } = &mut f.kind {
solidcraft_doc::sheet::rename_plane_sheet_body(plane, &old, name);
}
if let FeatureKind::Sketch { plane, sketch } = &mut f.kind
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
&& anchor.target.body == old
@@ -614,7 +652,13 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
s.pending_moves = trial.pending_moves;
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() { "exact_face" } else { "fixed" }),
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() {
"exact_face"
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
"sheet_face"
} else {
"fixed"
}),
_ => None,
});
Ok(json!({ "sketch": id,"attachment":attachment }))
@@ -715,6 +759,35 @@ mod tests {
assert_eq!(s.doc.param("w").map(|p| p.expr.as_str()), Some("2 in"));
}
#[test]
fn applying_a_named_view_or_camera_moves_the_view() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 10}));
run(&mut s, "view.save", json!({"name": "Photo", "camera": {"target": [1, 2, 3], "yaw": 0.5, "pitch": -0.6, "distance": 150}}));
let undo = s.undo.len();
assert!(s.camera.is_none());
let r = run(&mut s, "view.apply", json!({"view": "Photo"}));
let c = s.camera.expect("a camera for the view");
assert_eq!((c.target, c.yaw, c.pitch, c.distance), (solidcraft_geom::Vec3::new(1.0, 2.0, 3.0), 0.5, -0.6, 150.0));
assert_eq!(r["camera"]["yaw"], 0.5);
// A standard view looks from its direction and frames the model.
run(&mut s, "view.apply", json!({"view": "top"}));
let c = s.camera.expect("a camera for the top view");
assert!((c.pitch - std::f64::consts::FRAC_PI_2).abs() < 1e-3 && (c.target.x - 20.0).abs() < 1e-6, "{c:?}");
run(&mut s, "view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 1, "pitch": 0.2, "distance": 80, "fov": 0.5}}));
assert_eq!(s.camera.map(|c| (c.yaw, c.distance, c.fov)), Some((1.0, 80.0, 0.5)));
assert_eq!(s.undo.len(), undo, "view state, not an undo step");
for p in [
json!({}),
json!({"view": "Nope"}),
json!({"view": "Photo", "camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 1}}),
json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0}}),
json!({"camera": "x"}),
] {
assert!(s.execute("view.apply", &p).is_err(), "{p}");
}
}
#[test]
fn named_views_are_saved_renamed_and_deleted() {
let mut s = Session::default();
+329
View File
@@ -0,0 +1,329 @@
//! Linked designs: a component whose bodies come from another design file (Insert Design).
//!
//! The linked design is opened and evaluated on its own, and its solid bodies, as placed in it,
//! join a new component through an Import feature (exact B-rep, kept in this design so it opens
//! without the part file). The link remembers the file, relative to this design's folder once it
//! is saved; Update Links reads the file again and replaces the Import feature's bodies, and
//! Break Link keeps the bodies and forgets the file.
use std::path::{Component as PathPart, Path, PathBuf};
use serde_json::{Value, json};
use solidcraft_doc::{DesignLink, Document, FeatureKind};
use super::CommandSpec;
use crate::params::{bad, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.insert_design", "Insert Design", insert_design).icon("component").params(
"path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design; \
name? (default: the design's name); parent?: component id or name (default: the active one); translate?: [x,y,z]",
),
CommandSpec::new("component.update_links", "Update Links", update_links).params(
"component?: id or name (default: every linked component) — reads the linked designs again and replaces their bodies where they changed",
),
CommandSpec::new("component.break_link", "Break Link", break_link)
.params("component: id or name of a linked component — keeps its bodies as they are and forgets the design file"),
CommandSpec::new("component.links", "List Links", list_links)
.noundo()
.params("→ linked components: component, name, path, file (resolved), found"),
];
/// The folder relative link paths are kept against: the design's own, once it has a file.
fn base_dir(path: Option<&str>) -> Option<PathBuf> {
path.and_then(|p| absolute(Path::new(p)).parent().map(Path::to_path_buf))
}
/// A path as given, made absolute when the platform can, with `.` and `..` worked out.
fn absolute(p: &Path) -> PathBuf {
let full = std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf());
let mut out = PathBuf::new();
for c in full.components() {
match c {
PathPart::CurDir => {}
PathPart::ParentDir if matches!(out.components().next_back(), Some(PathPart::Normal(_))) => {
out.pop();
}
c => out.push(c.as_os_str()),
}
}
out
}
/// `target` relative to the folder `base` (both absolute), when they share a root.
fn relative(base: &Path, target: &Path) -> Option<PathBuf> {
let (b, t): (Vec<_>, Vec<_>) = (base.components().collect(), target.components().collect());
let root = |c: &[PathPart]| c.iter().take_while(|x| matches!(x, PathPart::Prefix(_) | PathPart::RootDir)).count();
if root(&b) == 0 || b.get(..root(&b)) != t.get(..root(&b)) {
return None;
}
let common = b.iter().zip(&t).take_while(|(x, y)| x == y).count();
let mut out = PathBuf::new();
for _ in common..b.len() {
out.push("..");
}
for c in t.iter().skip(common) {
out.push(c.as_os_str());
}
Some(out)
}
/// How a link stores `file` for a design saved at `design` (`None`: not saved yet).
fn stored(design: Option<&str>, file: &Path) -> String {
let file = absolute(file);
match base_dir(design).and_then(|d| relative(&d, &file)) {
// Relative paths are written with `/`, so a design moves between systems with its parts.
Some(rel) => rel.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::<Vec<_>>().join("/"),
None => file.to_string_lossy().into_owned(),
}
}
/// The file a stored link path names, for a design saved at `design`.
fn resolve(design: Option<&str>, link: &str) -> PathBuf {
let p = Path::new(link);
match base_dir(design) {
Some(d) if p.is_relative() => d.join(p),
_ => p.to_path_buf(),
}
}
/// The links of `doc` stored for a design saved at `to` instead of at `from`.
pub(super) fn rebased(doc: &Document, from: Option<&str>, to: &str) -> std::collections::BTreeMap<u64, DesignLink> {
doc.links.iter().map(|(c, l)| (*c, DesignLink { path: stored(Some(to), &resolve(from, &l.path)), feature: l.feature })).collect()
}
/// The solid bodies of the design at `file`, evaluated, as an Import feature.
fn linked_bodies(file: &Path, own: Option<&str>) -> Result<(String, solidcraft_io::StepFeature)> {
let shown = file.display().to_string();
if own.is_some_and(|o| absolute(Path::new(o)) == absolute(file)) {
return Err(EngineError::Other(format!("{shown}: a design can't be linked into itself")));
}
let bytes = solidcraft_io::vfs::read(&shown, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
let doc = solidcraft_io::read_design(&bytes).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
let name = doc.name.clone();
let part = Session::new(doc);
if let Some(e) = part.model.results.iter().find_map(|r| r.error.clone()) {
return Err(EngineError::Other(format!("{shown}: the design has a feature that fails ({e}); fix it before linking it")));
}
let world = part.world_state();
let solids: Vec<String> = world.bodies.iter().filter(|b| !b.body.is_mesh()).map(|b| b.name.clone()).collect();
if solids.is_empty() {
return Err(EngineError::Other(format!("{shown}: the design has no solid bodies to link")));
}
let step = solidcraft_io::export(&part.doc.painted(&world), &solids, solidcraft_io::Format::Step, &name)
.map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
let mut feature = solidcraft_io::step_import_feature(&step, &shown).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
if world.bodies.len() > solids.len() {
feature.warnings.push(format!("{} mesh bodies of {shown} are not linked", world.bodies.len() - solids.len()));
}
Ok((name, feature))
}
fn component_key(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<u64> {
let k = match p.get(key) {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => return Err(bad(cmd, format!("`{key}` must be a component id or name"))),
};
s.doc.find_component(&k).ok_or_else(|| bad(cmd, format!("no component `{k}`")))
}
fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.insert_design";
let given = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))?;
let translate = match p.get("translate") {
None => solidcraft_geom::Vec3::ZERO,
Some(v) => vec3(v).filter(|t| t.is_finite() && t.len() < 1e9).ok_or_else(|| bad(cmd, "`translate` must be [x, y, z] in mm"))?,
};
let file = absolute(Path::new(given));
let (design_name, sf) = linked_bodies(&file, s.path.as_deref())?;
let name = str_(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string).unwrap_or(design_name);
let mut create = json!({"name": name, "activate": false});
if let Some(parent) = p.get("parent") {
create["parent"] = parent.clone();
}
let made = s.execute("component.create", &create)?;
let comp = made["component"].as_u64().ok_or_else(|| bad(cmd, "the component was not created"))?;
let occ = made["occurrence"].as_u64();
let id = s.doc_mut().add_feature(sf.kind, Some(&name))?;
if let Some(f) = s.doc_mut().feature_mut(id) {
f.component = comp;
f.body_names = sf.body_names;
}
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| Some(o.id) == occ) {
o.transform[3] = [translate.x, translate.y, translate.z, 1.0];
}
let path = stored(s.path.as_deref(), &file);
s.doc_mut().links.insert(comp, DesignLink { path: path.clone(), feature: id });
s.refresh();
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
let st = s.model.state();
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
Ok(
json!({"component": comp, "occurrence": occ, "name": name, "feature": id, "path": path, "bodies": bodies, "created_bodies": bodies, "warnings": sf.warnings}),
)
}
fn update_links(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.update_links";
let only = if p.get("component").is_some() { Some(component_key(s, p, "component", cmd)?) } else { None };
if let Some(c) = only
&& !s.doc.links.contains_key(&c)
{
return Err(bad(cmd, "that component is not linked to a design"));
}
let links: Vec<(u64, DesignLink)> = s.doc.links.iter().filter(|(c, _)| only.is_none_or(|o| o == **c)).map(|(c, l)| (*c, l.clone())).collect();
let (mut updated, mut unchanged, mut warnings) = (Vec::new(), Vec::new(), Vec::new());
for (comp, link) in links {
let cname = s.doc.components.iter().find(|c| c.id == comp).map(|c| c.name.clone());
let Some(cname) = cname else {
// The component was deleted: the link goes with it.
s.doc_mut().links.remove(&comp);
continue;
};
let Some(FeatureKind::Import { step: old, .. }) = s.doc.feature(link.feature).filter(|f| f.component == comp).map(|f| &f.kind) else {
return Err(bad(
cmd,
format!("{cname}: its linked bodies (feature {}) are gone; break the link or insert the design again", link.feature),
));
};
let old = old.clone();
let file = resolve(s.path.as_deref(), &link.path);
let (_, sf) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, format!("{cname}: {e}")))?;
warnings.extend(sf.warnings.iter().cloned());
if matches!(&sf.kind, FeatureKind::Import { step, .. } if *step == old) {
unchanged.push(cname);
continue;
}
if let Some(f) = s.doc_mut().feature_mut(link.feature) {
// Bodies keep their names where the design still has as many.
if f.body_names.len() != sf.body_names.len() {
f.body_names = sf.body_names;
}
f.kind = sf.kind;
}
updated.push(cname);
}
s.refresh();
if let Some(e) = s.doc.links.values().find_map(|l| s.model.result(l.feature).and_then(|r| r.error.clone())) {
return Err(EngineError::Other(e));
}
Ok(json!({"updated": updated, "unchanged": unchanged, "warnings": warnings}))
}
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.break_link";
let comp = component_key(s, p, "component", cmd)?;
let link = s.doc_mut().links.remove(&comp).ok_or_else(|| bad(cmd, "that component is not linked to a design"))?;
Ok(json!({"component": comp, "path": link.path}))
}
fn list_links(s: &mut Session, _p: &Value) -> Result<Value> {
let links: Vec<Value> = s
.doc
.links
.iter()
.map(|(c, l)| {
let file = resolve(s.path.as_deref(), &l.path);
let name = s.doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone());
json!({"component": c, "name": name, "path": l.path, "file": file.display().to_string(), "found": file.is_file()})
})
.collect();
Ok(json!({"links": links}))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn link_paths_are_kept_relative_to_the_design_and_follow_save_as() {
let root = absolute(Path::new("projects"));
let part = root.join("parts").join("plate.solidcraft");
let design = root.join("assembly").join("case.solidcraft");
let design = design.to_string_lossy().into_owned();
assert_eq!(stored(Some(&design), &part), "../parts/plate.solidcraft");
assert_eq!(absolute(&resolve(Some(&design), "../parts/plate.solidcraft")), part);
// A design path given relative to the working folder works the same.
assert_eq!(stored(Some("case.solidcraft"), &absolute(Path::new("parts/plate.solidcraft"))), "parts/plate.solidcraft");
// Before the design has a file the path is kept whole; saving it makes it relative.
let unsaved = stored(None, &part);
assert!(Path::new(&unsaved).is_absolute(), "{unsaved}");
let mut doc = Document::default();
doc.links.insert(7, DesignLink { path: unsaved, feature: 3 });
let moved = rebased(&doc, None, &design);
assert_eq!(moved.get(&7).map(|l| l.path.as_str()), Some("../parts/plate.solidcraft"));
let beside = root.join("parts").join("asm.solidcraft").to_string_lossy().into_owned();
assert_eq!(
rebased(&Document { links: moved, ..Document::default() }, Some(&design), &beside).get(&7).map(|l| l.path.as_str()),
Some("plate.solidcraft")
);
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn save_box(path: &Path, height: f64) {
let mut part = Session::default();
run(&mut part, "solid.box", json!({"length": 10, "width": 20, "height": height, "body_name": "Plate"}));
run(&mut part, "file.save", json!({"path": path}));
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
#[test]
fn a_saved_assembly_reopens_with_its_link_updates_it_and_breaks_it() {
let dir = std::env::temp_dir().join(format!("solidcraft-link-reopen-{}", std::process::id()));
std::fs::create_dir_all(dir.join("parts")).unwrap();
std::fs::create_dir_all(dir.join("asm")).unwrap();
let part = dir.join("parts").join("plate.solidcraft");
let case = dir.join("asm").join("case.solidcraft");
save_box(&part, 30.0);
// Given an assembly holding the part, saved in a folder beside the part's.
let mut a = Session::default();
let inserted = run(&mut a, "component.insert_design", json!({"path": part}));
assert_eq!(inserted["name"], "plate");
run(&mut a, "file.save", json!({"path": case}));
assert!((volume(&mut a) - 6000.0).abs() < 1e-6);
// When it is opened again, the link is kept relative to it and finds the part.
let mut b = Session::default();
run(&mut b, "doc.open", json!({"path": case}));
let links = run(&mut b, "component.links", json!({}));
assert_eq!(links["links"][0]["path"], "../parts/plate.solidcraft", "{links}");
assert_eq!(links["links"][0]["found"], true, "{links}");
assert!((volume(&mut b) - 6000.0).abs() < 1e-6);
// An unchanged part leaves the assembly as it is; an edited one comes in on update.
assert_eq!(run(&mut b, "component.update_links", json!({}))["unchanged"], json!(["plate"]));
save_box(&part, 45.0);
assert_eq!(run(&mut b, "component.update_links", json!({}))["updated"], json!(["plate"]));
assert!((volume(&mut b) - 9000.0).abs() < 1e-6, "{}", volume(&mut b));
// A part file that has gone is reported, and the assembly keeps the bodies it had.
std::fs::remove_file(&part).unwrap();
let err = b.execute("component.update_links", &json!({})).unwrap_err().to_string();
assert!(err.contains("plate"), "{err}");
assert!((volume(&mut b) - 9000.0).abs() < 1e-6);
// Break Link keeps the bodies and forgets the file.
run(&mut b, "component.break_link", json!({"component": "plate"}));
assert_eq!(run(&mut b, "component.links", json!({}))["links"], json!([]));
assert!((volume(&mut b) - 9000.0).abs() < 1e-6);
assert!(b.execute("component.update_links", &json!({"component": "plate"})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn inserting_a_missing_file_or_bad_parameters_changes_nothing() {
let mut s = Session::default();
let missing = std::env::temp_dir().join(format!("solidcraft-no-such-part-{}.solidcraft", std::process::id()));
assert!(s.execute("component.insert_design", &json!({"path": missing})).is_err());
assert!(s.execute("component.insert_design", &json!({})).is_err());
assert!(s.execute("component.insert_design", &json!({"path": "x.solidcraft", "translate": [1, "a", 2]})).is_err());
assert!(s.doc.components.is_empty() && s.doc.links.is_empty() && s.doc.features.is_empty());
}
}
@@ -127,3 +127,175 @@ fn excessively_nested_assembly_refuses_partial_totals() {
let error = s.execute("component.properties", &json!({"component": 0})).unwrap_err();
assert!(error.to_string().contains("traversal limits"), "{error}");
}
/// #532: a product BOM needs materials outside the built-in library (walnut, polypropylene,
/// rubber, leather). A user-defined material with its density is assigned like a built-in one
/// and sets the body's mass: a 10 mm walnut cube at 0.65 g/cm³ weighs 0.65 g.
#[test]
fn user_defined_material_sets_the_body_mass() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Slat"}));
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
run(&mut s, "material.assign", json!({"bodies": ["Slat"], "material": "Walnut"}));
let props = run(&mut s, "component.properties", json!({"component": "root"}));
assert_eq!(props["bodies"][0]["material"], "Walnut", "{props}");
close(&props["bodies"][0]["density_g_cm3"], 0.65);
close(&props["total"]["mass_g"], 0.65);
}
/// A design file path in a fresh temporary folder (removed by the caller).
fn temp_design(tag: &str) -> (std::path::PathBuf, String) {
let dir = std::env::temp_dir().join(format!("solidcraft-user-material-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("design.solidcraft").to_string_lossy().to_string();
(dir, path)
}
fn mass(s: &mut Session) -> Value {
run(s, "component.properties", json!({"component": "root"}))["total"]["mass_g"].clone()
}
/// #532: a design's own material is listed after the library, gives its bodies its look, and
/// can be edited (density and name: the bodies follow) and deleted; each step is undoable.
#[test]
fn user_defined_material_is_edited_renamed_and_deleted() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Pad"}));
assert!(s.execute("material.edit", &json!({"material": "Rubber", "density_g_cm3": 1.2})).is_err(), "nothing to edit yet");
let created = run(&mut s, "material.create", json!({"name": "Rubber", "density_g_cm3": 1.5, "color": "#1c1c1c"}));
assert_eq!(created["appearance"]["color"], "#1c1c1c", "{created}");
let list = run(&mut s, "material.list", json!({}));
let materials = list["materials"].as_array().unwrap();
assert_eq!(materials.len(), solidcraft_doc::MATERIALS.len() + 1);
let rubber = materials.last().unwrap();
assert_eq!((rubber["name"].as_str(), rubber["user"].as_bool()), (Some("Rubber"), Some(true)));
// Assigned (in any case), it sets the mass and the body's colour.
run(&mut s, "material.assign", json!({"bodies": ["Pad"], "material": "rubber"}));
close(&mass(&mut s), 1.5);
let pad = s.model.state().body("Pad").unwrap().clone();
let grey = 28.0 / 255.0;
assert_eq!(s.doc.body_color(&pad), Some([grey, grey, grey]));
// A new density changes the mass; a new name carries the body with it.
run(&mut s, "material.edit", json!({"material": "Rubber", "density_g_cm3": 1.2, "name": "Foot Rubber"}));
close(&mass(&mut s), 1.2);
let props = run(&mut s, "component.properties", json!({"component": "root"}));
assert_eq!(props["bodies"][0]["material"], "Foot Rubber");
run(&mut s, "edit.undo", json!({}));
close(&mass(&mut s), 1.5);
assert_eq!(s.doc.materials.get("Pad").map(String::as_str), Some("Rubber"));
run(&mut s, "edit.redo", json!({}));
// A material in use is not deleted unless its bodies get another one.
let refused = s.execute("material.delete", &json!({"material": "Foot Rubber"})).unwrap_err().to_string();
assert!(refused.contains("body `Pad`"), "{refused}");
run(&mut s, "material.delete", json!({"material": "Foot Rubber", "replace": "Oak"}));
close(&mass(&mut s), 0.75);
assert!(s.doc.user_materials.is_empty());
assert!(s.execute("material.assign", &json!({"bodies": ["Pad"], "material": "Foot Rubber"})).is_err());
run(&mut s, "edit.undo", json!({}));
close(&mass(&mut s), 1.2);
}
/// #532: names and densities are checked before anything changes.
#[test]
fn user_defined_material_refuses_bad_names_and_densities() {
let mut s = Session::default();
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
let doc = s.doc.clone();
for p in [
json!({"name": "Steel", "density_g_cm3": 1.0}),
json!({"name": "walnut", "density_g_cm3": 1.0}),
json!({"name": " ", "density_g_cm3": 1.0}),
json!({"name": "x".repeat(129), "density_g_cm3": 1.0}),
json!({"name": "Leather", "density_g_cm3": 0}),
json!({"name": "Leather", "density_g_cm3": -1}),
json!({"name": "Leather", "density_g_cm3": 1e9}),
json!({"name": "Leather", "density_g_cm3": "heavy"}),
json!({"name": "Leather"}),
json!({"name": "Leather", "density_g_cm3": 0.9, "color": "#zzzzzz"}),
json!({"name": "Leather", "density_g_cm3": 0.9, "appearance": "No Such Look"}),
json!({"name": "Leather", "density_g_cm3": 0.9, "opacity": 0.5}),
] {
assert!(s.execute("material.create", &p).is_err(), "{p}");
}
for p in [
json!({"material": "Steel", "density_g_cm3": 1.0}),
json!({"material": "Walnut", "name": "Oak"}),
json!({"material": "Walnut", "density_g_cm3": 0}),
] {
assert!(s.execute("material.edit", &p).is_err(), "{p}");
}
assert_eq!(*s.doc, *doc);
}
/// #532: a design's materials are saved with it: after reopening, the bodies keep their
/// material, its density and its look.
#[test]
fn user_defined_material_survives_save_and_reopen() {
let (dir, path) = temp_design("saved");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Tab"}));
run(&mut s, "material.create", json!({"name": "Leather", "density_g_cm3": 0.86, "appearance": "Rubber - Black"}));
run(&mut s, "material.assign", json!({"bodies": ["Tab"], "material": "Leather"}));
run(&mut s, "file.save", json!({"path": path}));
assert!(s.doc.to_json().contains("\"solidcraft/16\""));
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
let props = run(&mut s, "component.properties", json!({"component": "root"}));
assert_eq!(props["bodies"][0]["material"], "Leather", "{props}");
close(&props["total"]["mass_g"], 0.86);
assert_eq!(s.doc.material_look("Leather").map(|l| l.name), Some("Rubber - Black".to_string()));
run(&mut s, "material.edit", json!({"material": "Leather", "density_g_cm3": 0.9}));
close(&mass(&mut s), 0.9);
let _ = std::fs::remove_dir_all(&dir);
}
/// #532: designs from before user materials open unchanged: built-in assignments keep their
/// densities and a design without its own materials saves no `user_materials`. A damaged
/// file's unusable density falls back to the default instead of producing nonsense masses.
#[test]
fn designs_without_user_materials_open_unchanged() {
let (dir, path) = temp_design("legacy");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Plate"}));
run(&mut s, "material.assign", json!({"bodies": ["Plate"], "material": "Steel"}));
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
assert!(legacy.get("user_materials").is_none());
legacy["format"] = json!("solidcraft/16");
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
close(&mass(&mut s), 7.85);
assert!(s.doc.user_materials.is_empty());
assert!(s.execute("material.delete", &json!({"material": "Steel"})).is_err(), "no design materials: nothing to delete");
legacy["user_materials"] = json!([{"name": "Broken", "density": -3.0, "appearance": {"name": "Broken", "color": [1, 2, 3], "opacity": 9.0}}]);
legacy["materials"] = json!({"Plate": "Broken"});
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
run(&mut s, "doc.open", json!({"path": path}));
close(&mass(&mut s), solidcraft_doc::DEFAULT_DENSITY);
assert_eq!(s.doc.material_look("Broken").map(|l| l.opacity), Some(1.0));
let _ = std::fs::remove_dir_all(&dir);
}
/// #532: configuration material cells take a design's own material and follow its rename.
#[test]
fn configurations_switch_to_a_user_defined_material() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Slat"}));
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
run(&mut s, "config.configure", json!({"materials": ["Slat"]}));
run(&mut s, "config.row", json!({"name": "Walnut", "values": {"material:Slat": "walnut"}}));
run(&mut s, "config.activate", json!({"row": "Walnut"}));
close(&mass(&mut s), 0.65);
run(&mut s, "material.edit", json!({"material": "Walnut", "name": "Black Walnut"}));
run(&mut s, "config.activate", json!({"row": "Default"}));
run(&mut s, "config.activate", json!({"row": "Walnut"}));
assert_eq!(s.doc.materials.get("Slat").map(String::as_str), Some("Black Walnut"));
run(&mut s, "material.delete", json!({"material": "Black Walnut", "replace": "Default"}));
assert!(!s.doc.materials.contains_key("Slat"));
}
+1 -1
View File
@@ -265,7 +265,7 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
match s.doc.configs.columns.get(col) {
Some(Column::Material(_)) => {
text = solidcraft_doc::config::material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
text = s.doc.material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
}
Some(Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_)) => {
text = match solidcraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? {
+22 -2
View File
@@ -1,5 +1,7 @@
//! Undo/redo, the timeline, deletion and selection.
use std::collections::BTreeMap;
use serde_json::{Value, json};
use solidcraft_doc::{Feature, FeatureKind};
@@ -22,7 +24,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("delete")
.key("Delete")
.params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"),
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name"),
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name (references to it follow)"),
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
@@ -163,15 +165,25 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let id = feature_id(s, p.get("feature"), cmd)?;
let name =
str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
let old = s.doc.feature(id).map(|f| f.name.clone()).ok_or_else(|| bad(cmd, "feature"))?;
if s.doc.features.iter().any(|f| f.name == name && f.id != id) {
return Err(bad(cmd, format!("another feature is called `{name}`")));
}
if s.doc.feature(id).is_some_and(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })) {
super::features::check_plane_name(name, cmd)?;
}
if let Some(f) = s.doc_mut().feature_mut(id) {
if name == old {
return Ok(json!({"feature": id, "name": name}));
}
let names = BTreeMap::from([(old.clone(), name.to_string())]);
let sketches = BTreeMap::new();
let doc = s.doc_mut();
if let Some(f) = doc.feature_mut(id) {
f.name = name.to_string();
}
for f in doc.features.iter_mut() {
f.kind.remap_refs(&sketches, &names);
}
Ok(json!({"feature": id, "name": name}))
}
@@ -322,6 +334,14 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
nf.edge_refs = solidcraft_doc::edge_refs(&before, body, a);
nf.edge_names = solidcraft_doc::edge_names_for(&before, body, 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));
}
}
// 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() {
super::features::stable_profiles(s, &mut nf.kind)?;
+171 -9
View File
@@ -136,30 +136,42 @@ fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() {
}
}
/// A 40 x 30 x 20 box whose top is split at x = 20 and its left half raised 5 by Offset Face:
/// the raised top has no construction certificate and no exact history name yet (#494).
fn raised_half(s: &mut Session, body: &str) {
run(s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":body}));
run(s, "solid.split_face", json!({"faces":[[10,15,20]],"plane":{"origin":[20,0,0],"normal":[1,0,0]}}));
run(s, "solid.offset_face", json!({"faces":[[10,15,20]],"distance":5}));
}
#[test]
fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
let mut s = Session::default();
let component = run(&mut s, "component.create", json!({"name":"Source"}));
let occurrence = component["occurrence"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Stock"}));
run(&mut s, "solid.press_pull", json!({"face":[5,5,20],"distance":5}));
// A face Offset Face raised has no construction certificate or exact history name yet:
// the legacy path.
raised_half(&mut s, "Stock");
run(&mut s, "component.activate", json!({"component":0}));
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":false}));
let state = s.model.state();
let index = state
// The raised half's top.
let end = state
.body("Stock")
.unwrap()
.body
.faces(1e-4)
.unwrap()
.iter()
.into_iter()
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 25.0).abs() < 1e-6)
.unwrap()
.index;
.unwrap();
let before = s.doc.to_json();
let error =
super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,25],"body":"Stock","index":index,"path":[occurrence]}), "internal")
.unwrap_err();
let error = super::super::sketch::face::capture_internal(
&s,
&json!({"face":[40,15,25],"body":"Stock","index":end.index,"path":[occurrence]}),
"internal",
)
.unwrap_err();
assert!(error.to_string().contains("capture the source occurrence position"), "{error}");
assert_eq!(s.doc.to_json(), before);
}
@@ -229,3 +241,153 @@ fn blind_cut_inside_a_cylinder_keeps_only_planes_and_cylinders() {
assert_eq!(body["face_types"], json!({"cylinder": 2, "plane": 12}), "{m}");
assert_eq!(body["faces"], 14, "{m}");
}
fn face_anchor(s: &Session, sketch: u64) -> solidcraft_doc::FaceAnchor {
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch).unwrap().kind else {
panic!("sketch {sketch} has no face anchor")
};
(**anchor).clone()
}
#[test]
fn join_keeps_exact_face_attachments_from_both_operands() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-10,-10],"p1":[10,10]}));
run(&mut s, "sketch.finish", json!({}));
let base = run(&mut s, "solid.extrude", json!({"distance":40,"name":"Base"}))["feature"].as_u64().unwrap();
// A cross bar joined through the base (the shape of #420) and a boss joined on its top.
run(&mut s, "sketch.create", json!({"plane":"YZ"}));
run(&mut s, "sketch.circle.center", json!({"center":[0,20],"radius":5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":20,"symmetric":true,"operation":"join"}));
run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,40]}}));
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":4}));
run(&mut s, "sketch.finish", json!({}));
let boss = run(&mut s, "solid.extrude", json!({"distance":10,"operation":"join","name":"Boss"}))["feature"].as_u64().unwrap();
assert_eq!(s.model.state().bodies.len(), 1);
// The base's top around the boss, and the boss's own top (the Union's second operand).
let on_base = run(&mut s, "sketch.create", json!({"plane":{"face":[8,8,40]}}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.finish", json!({}));
let on_boss = run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,50]}}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.finish", json!({}));
assert_eq!(face_anchor(&s, on_base).target.origin_feature, base);
assert_eq!(face_anchor(&s, on_boss).target.origin_feature, boss);
// The attachments follow their faces through an upstream edit and a reopen.
run(&mut s, "timeline.edit", json!({"feature":"Base","set":{"extent":{"distance":"50"}}}));
s.refresh();
assert!(s.model.results.iter().all(|result| result.error.is_none()), "{:?}", s.model.results);
let state = s.model.state();
assert!((state.sketch(on_base).unwrap().plane.origin.z - 50.0).abs() < 1e-6);
assert!((state.sketch(on_boss).unwrap().plane.origin.z - 60.0).abs() < 1e-6);
let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
reopened.refresh();
assert!(reopened.model.results.iter().all(|result| result.error.is_none()), "{:?}", reopened.model.results);
assert!((reopened.model.state().sketch(on_boss).unwrap().plane.origin.z - 60.0).abs() < 1e-6);
}
#[test]
fn join_gives_a_face_merged_from_both_operands_its_history_name() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10}));
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"corner":[20,0,0],"operation":"join"}));
// Both boxes' tops share one support: no single construction certificate owns it, but its
// exact history names it (#491).
let top = run(&mut s, "sketch.create", json!({"plane":{"face":[10,10,10]}}))["sketch"].as_u64().unwrap();
assert_eq!(face_anchor(&s, top).target.certificate, Some(solidcraft_doc::config::FaceCertificate::Lineage));
// An unshared support keeps its box role.
run(&mut s, "sketch.finish", json!({}));
let side = run(&mut s, "sketch.create", json!({"plane":{"face":[0,10,5]}}))["sketch"].as_u64().unwrap();
assert!(matches!(face_anchor(&s, side).target.certificate, Some(solidcraft_doc::config::FaceCertificate::Box { axis: 0, high: false })));
}
#[test]
fn join_covering_a_whole_attached_face_loses_the_attachment_without_moving_it() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":10,"name":"Block"}));
let top = run(&mut s, "sketch.create", json!({"plane":{"face":[30,15,10]},"name":"Top"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.circle.center", json!({"center":[30,15],"radius":3}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":"Top","distance":5,"operation":"join","name":"Peg"}));
// An earlier join whose roof replaces Block's whole top.
run(&mut s, "timeline.roll_to", json!({"feature":"Block"}));
run(&mut s, "sketch.create", json!({"plane":"XY","name":"B"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-5,-5],"p1":[45,35]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":"B","distance":20,"operation":"join","name":"Boss"}));
run(&mut s, "timeline.roll_to", json!({}));
let failed = |s: &Session, id: u64| s.model.results.iter().find(|r| r.id == id).unwrap().error.is_some();
assert!(failed(&s, top), "the attachment never follows the substitute roof");
assert!(s.model.state().sketch(top).is_none());
run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true}));
assert!(!failed(&s, top));
assert!((s.model.state().sketch(top).unwrap().plane.origin.z - 10.).abs() < 1e-9);
}
fn certificate_of(s: &Session, sketch: u64) -> Option<solidcraft_doc::config::FaceCertificate> {
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch).unwrap().kind else {
return None;
};
anchor.target.certificate.clone()
}
#[test]
fn cylinder_primitive_caps_take_attachments_that_follow_edits_and_reopen() {
use solidcraft_doc::config::FaceCertificate;
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":40,"name":"Post"}));
let top = run(&mut s, "sketch.create", json!({"plane":{"face":[2,1,40]}}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.finish", json!({}));
let bottom = run(&mut s, "sketch.create", json!({"plane":{"face":[2,1,0]}}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.finish", json!({}));
assert_eq!(certificate_of(&s, top), Some(FaceCertificate::CylinderCap { high: true }));
assert_eq!(certificate_of(&s, bottom), Some(FaceCertificate::CylinderCap { high: false }));
let heights = |s: &Session| {
let state = s.model.state();
(state.sketch(top).unwrap().plane.origin.z, state.sketch(bottom).unwrap().plane.origin.z)
};
run(&mut s, "timeline.edit", json!({"feature":"Post","set":{"height":"55","base":[0,0,5]}}));
s.refresh();
assert!(s.model.results.iter().all(|result| result.error.is_none()), "{:?}", s.model.results);
let (high, low) = heights(&s);
assert!((high - 60.).abs() < 1e-9 && (low - 5.).abs() < 1e-9, "{high} {low}");
let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
reopened.refresh();
assert!(reopened.model.results.iter().all(|result| result.error.is_none()), "{:?}", reopened.model.results);
assert_eq!(heights(&reopened), (high, low));
}
#[test]
fn crossed_cylinder_primitives_keep_their_caps_attachable() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":40}));
run(&mut s, "solid.cylinder", json!({"radius":5,"height":40,"base":[-20,0,20],"axis":"X","operation":"join"}));
assert_eq!(s.model.state().bodies.len(), 1);
for face in [[0., 0., 40.], [20., 0., 20.]] {
let sketch = run(&mut s, "sketch.create", json!({"plane":{"face":face}}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.finish", json!({}));
assert!(certificate_of(&s, sketch).is_some(), "{face:?}");
}
}
#[test]
fn shelled_cylinder_floor_keeps_its_cap_identity() {
use solidcraft_doc::config::FaceCertificate;
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":40}));
run(&mut s, "solid.shell", json!({"faces":[[2,1,40]],"thickness":2}));
let floor = run(&mut s, "sketch.create", json!({"plane":{"face":[2,1,2]}}))["sketch"].as_u64().unwrap();
assert!(
matches!(
certificate_of(&s, floor),
Some(FaceCertificate::ShellPlanar { source, inner: true, .. }) if *source == FaceCertificate::CylinderCap { high: false }
),
"{:?}",
certificate_of(&s, floor)
);
}
+70 -9
View File
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("fillet")
.key("F")
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce (exact, the default: a radius too large for the geometry around the edges is refused; reduce: it is reduced to the largest that fits all of them, returned as `radius_applied` and noted in the feature's warning)"),
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
@@ -107,7 +107,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("presspull")
.key("Q")
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"),
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place)"),
];
/// Where an extrude starts and which way is positive: a point inside the (first) profile or
@@ -365,6 +365,8 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
/// `add_feature`; `world` is false when `kind` is already in the active component's frame.
fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> Result<Value> {
let name = str_(p, "name");
// The bodies before the feature, to say which ones it changed or removed.
let before = s.model.state();
// Commands speak world coordinates; the feature is authored in the active component.
let mut kind = kind;
if world {
@@ -387,9 +389,11 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
(solidcraft_doc::edge_refs(&st, body, edges), solidcraft_doc::edge_names_for(&st, body, edges))
}
// Sheet metal edges are named by the sheet model.
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => {
(Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), edges))
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))
}
FeatureKind::SheetHem { edges, .. } => (Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), edges)),
_ => (Vec::new(), Vec::new()),
};
let face_names = {
@@ -470,8 +474,20 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
}
let st = s.model.state();
let bodies: Vec<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
// The bodies this feature made, apart from the ones it changed or that were there (#374).
let created: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
// Bodies that were there before and now have other geometry, and bodies that are gone.
let old: std::collections::HashMap<&str, &solidcraft_doc::kernel::Body> = before.bodies.iter().map(|b| (b.name.as_str(), &b.body)).collect();
let now: std::collections::HashSet<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
let changed: Vec<&str> =
st.bodies.iter().filter(|b| b.feature != id && old.get(b.name.as_str()).is_some_and(|o| !o.same(&b.body))).map(|b| b.name.as_str()).collect();
let removed: Vec<&str> = before.bodies.iter().map(|b| b.name.as_str()).filter(|n| !now.contains(n)).collect();
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
Ok(json!({"feature": id, "name": fname, "bodies": bodies, "ms": r.map(|r| r.ms)}))
// A warning (a Boolean that needed a retry, a reference found again elsewhere) is part of
// the reply: a script or an agent sees it without asking the timeline (#616).
let warning = r.as_ref().and_then(|r| r.warning.clone());
Ok(json!({"feature": id, "name": fname, "bodies": bodies, "created_bodies": created, "changed_bodies": changed,
"removed_bodies": removed, "warning": warning, "ms": r.map(|r| r.ms)}))
}
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
@@ -794,11 +810,15 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "`fit: reduce` takes a constant radius"));
}
let mut out = add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string), style, fit })?;
// The radius the fillet used, when `fit: reduce` reduced it.
// The radii the fillet used, when `fit: reduce` reduced it.
if let Some(id) = out.get("feature").and_then(Value::as_u64)
&& let Some(r) = s.model.result(id).and_then(|res| res.state.fitted_radius(id))
&& let Some(state) = s.model.result(id).map(|res| &res.state)
&& let Some(r) = state.fitted_radius(id)
{
out["radius_applied"] = serde_json::json!(r);
let each: Vec<Value> =
state.fitted_edges(id).unwrap_or_default().into_iter().map(|(p, r)| serde_json::json!({"edge": [p.x, p.y, p.z], "radius": r})).collect();
out["radii_applied"] = Value::Array(each);
}
Ok(out)
}
@@ -873,7 +893,11 @@ fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(a) = &angle {
check_expr(s, a, Kind::Angle, cmd, "angle")?;
}
add_feature(s, p, FeatureKind::Move { bodies, translate: t, rotate_axis: p.get("axis").and_then(vec3), angle })
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) },
)
}
fn source_features(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
@@ -1302,7 +1326,33 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] on the face to drill to"))?),
None => None,
};
add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread, to })
// A hole picked on a body of another component (a moved one, shown where it is placed) cuts
// that body: the pick is already in that body's frame, and the component's own bodies alone
// would leave nothing to cut.
let mut p = p.clone();
if points.is_none()
&& p.get("participants").is_none()
&& let Some(body) = body_of_other_component_at(s, position)
{
p["participants"] = json!([body]);
}
add_feature(s, &p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread, to })
}
/// The body whose face is nearest `p` (within its pick tolerance), when it belongs to another
/// component than the active one.
fn body_of_other_component_at(s: &Session, p: Vec3) -> Option<String> {
let st = s.model.state();
let (_, body) = st
.bodies
.iter()
.filter(|b| !b.body.is_mesh())
.filter_map(|b| {
let (d, _) = solidcraft_doc::appearance::nearest_face(b, p)?;
(d <= solidcraft_doc::appearance::pick_tolerance(b)).then_some((d, b))
})
.min_by(|a, b| a.0.total_cmp(&b.0))?;
(s.doc.model_body_component(body) != s.active_component).then(|| body.name.clone())
}
/// The points of a sketch a hole drills at when only the sketch is given.
@@ -1530,3 +1580,14 @@ mod offset_edit_guard_tests;
#[cfg(test)]
#[path = "offset_plane_face_tests.rs"]
mod offset_plane_face_tests;
#[cfg(test)]
#[path = "lineage_face_tests.rs"]
mod lineage_face_tests;
#[cfg(test)]
#[path = "stacked_face_tests.rs"]
mod stacked_face_tests;
#[cfg(test)]
#[path = "solid_move_copy_tests.rs"]
mod solid_move_copy_tests;
@@ -602,3 +602,79 @@ fn cylinder_auto_inference_refuses_ambiguity_curves_and_seam_crossing_atomically
assert_eq!(exact_cylinder_volume(&s), volume);
}
}
/// The design from #396: a loft from a Ø12 circle on a 40 × 30 × 20 box's top to a hexagon 15 up.
fn loft_on_box(op: &str) -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [20, 15]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 20, "body_name": "Box"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 20, "name": "A"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 35, "name": "B"}));
run(&mut s, "sketch.create", json!({"plane": "A", "name": "L0"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "diameter": 12}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "B", "name": "L1"}));
run(&mut s, "sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 4, "sides": 6}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "L0"}, {"sketch": "L1"}], "operation": op}));
s
}
#[test]
fn loft_joined_onto_a_box_exports_one_connected_mesh() {
// #396: one body, but its two shells only touched, and the STL came out in two pieces.
let mut apart = loft_on_box("new_body");
let m = measure(&mut apart);
assert_eq!(m["body_count"], 2);
let loft = m["bodies"][1]["volume_mm3"].as_f64().unwrap();
let mut s = loft_on_box("join");
let m = measure(&mut s);
assert_eq!(m["body_count"], 1);
assert_eq!(m["bodies"][0]["kernel"]["shells"], 1);
let v = volume(&mut s);
assert!(rel(v, 24000.0 + loft) < 1e-5, "{v} vs {}", 24000.0 + loft);
// The exported mesh, its vertices merged, is one connected piece.
let mesh = s.model.state().body("Box").unwrap().body.tessellate(0.01).unwrap();
let (positions, triangles) = solidcraft_io::weld(&mesh.positions, &mesh.triangles, 1e-4);
let mut piece: Vec<usize> = (0..positions.len()).collect();
let root = |piece: &mut Vec<usize>, mut i: usize| {
while piece[i] != i {
i = piece[i];
}
i
};
for t in &triangles {
let r0 = root(&mut piece, t[0] as usize);
for k in [1, 2] {
let rk = root(&mut piece, t[k] as usize);
piece[rk] = r0;
}
}
let pieces: std::collections::BTreeSet<usize> = triangles.iter().map(|t| root(&mut piece, t[0] as usize)).collect();
assert_eq!(pieces.len(), 1);
}
/// A cone that takes only a 1 mm chamfer off a cylinder's top edge leaves nearly all of the
/// cylinder inside it; the intersection is still the chamfered cylinder (#443).
#[test]
fn loft_intersect_that_trims_one_edge_changes_the_body() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 35}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "body_name": "Body"}));
for (name, offset, radius) in [("A", -1, 45), ("B", 11, 33)] {
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": offset, "name": name}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": radius}));
run(&mut s, "sketch.finish", json!({}));
}
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "A"}, {"sketch": "B"}], "operation": "intersect"}));
// r 35 up to z = 9, then a frustum to r 34 at z = 10.
let want = std::f64::consts::PI * (35.0 * 35.0 * 9.0 + (35.0 * 35.0 + 35.0 * 34.0 + 34.0 * 34.0) / 3.0);
let got = volume(&mut s);
assert!((got - want).abs() < want * 2e-4, "{got} vs {want}");
let m = measure(&mut s);
assert_eq!(m["bodies"][0]["face_types"]["cone"], 1, "{m}");
}
+326 -19
View File
@@ -1,5 +1,8 @@
//! Documents and files: new, open (designs and STEP), insert STEP, save, export.
#[path = "file_thumbnail.rs"]
mod thumbnail;
use serde_json::{Value, json};
use solidcraft_doc::Document;
use solidcraft_io::Format;
@@ -27,11 +30,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("save")
.key("Ctrl+S")
.noundo()
.params("path? (default: current file); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); preview?: bool (experimental saved picture, default: false)"),
.params("path? (default: current file); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (warm native geometry, default: false); preview?: bool (saved display meshes, default: false)"),
CommandSpec::new("file.save_as", "Save As", save_as)
.icon("save")
.noundo()
.params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); preview?: bool (experimental saved picture, default: false)"),
.params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (warm native geometry, default: false); preview?: bool (saved display meshes, default: false)"),
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list)
.noundo()
.params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"),
@@ -78,7 +81,7 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
let st = s.model.state();
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "warnings": sf.warnings}))
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": sf.warnings}))
}
/// Add a MeshImport feature for a 3MF or STL file.
@@ -98,7 +101,7 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
let st = s.model.state();
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "warnings": mf.warnings}))
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": mf.warnings}))
}
fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
@@ -131,35 +134,115 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
}
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let doc = solidcraft_io::read_design(&bytes)?;
if let Some(notify) = s.open_preview.take()
&& let Some(preview) = solidcraft_io::read_design_preview(&bytes)
{
notify(preview);
let mut doc = solidcraft_io::read_design_file(path, s.open_preview.take()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if let Some(stem) = default_name_for(&doc.name, path) {
doc.name = stem;
}
*s = Session::new(doc);
s.path = Some(path.to_string());
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors}))
Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "name": s.doc.name}))
}
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> {
/// The name a design never given one by its user is shown under.
const DEFAULT_NAME: &str = "Untitled";
/// A design still under the default name takes its file's name (`bracket.solidcraft` →
/// "bracket"); a name the user chose is kept. `None` when nothing changes.
fn default_name_for(name: &str, path: &str) -> Option<String> {
let name = name.trim();
if !name.is_empty() && name != DEFAULT_NAME {
return None;
}
let stem = std::path::Path::new(path).file_stem()?.to_string_lossy().trim().to_string();
(!stem.is_empty()).then_some(stem)
}
/// Rename the design, and the undo and redo states that still carry the old name, so undo
/// does not bring the default name back and leave the design looking changed.
fn rename_design(s: &mut Session, from: &str, to: &str) {
s.doc_mut().name = to.to_string();
for snap in s.undo.iter_mut().chain(s.redo.iter_mut()) {
if snap.doc.name == from {
std::sync::Arc::make_mut(&mut snap.doc).name = to.to_string();
}
}
}
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool) -> Result<Value> {
// Saving an unnamed design names it after its file (not an undoable edit).
let renamed = default_name_for(&s.doc.name, path).map(|stem| (s.doc.name.clone(), stem));
if let Some((from, to)) = &renamed {
rename_design(s, from, to);
}
let r = write_file(s, path, format, compression, preview, cache);
if r.is_err()
&& let Some((from, to)) = &renamed
{
rename_design(s, to, from);
}
r
}
fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool) -> Result<Value> {
let compression = match compression {
"fast" => solidcraft_io::DesignCompression::Fast,
"compact" => solidcraft_io::DesignCompression::Compact,
_ => return Err(bad("file.save", "compression must be fast or compact")),
};
// Linked designs are kept relative to the folder the design is saved in.
let links = super::component_link::rebased(&s.doc, s.path.as_deref(), path);
let old_links = (links != s.doc.links).then(|| std::mem::replace(&mut s.doc_mut().links, links));
let r = write_bytes(s, path, format, compression, preview, cache);
if r.is_err()
&& let Some(old) = old_links
{
s.doc_mut().links = old;
}
r
}
fn write_bytes(
s: &mut Session,
path: &str,
format: &str,
compression: solidcraft_io::DesignCompression,
preview: bool,
cache: bool,
) -> Result<Value> {
let bytes = match format {
"container" => solidcraft_io::write_design_container_with_preview(&s.doc, compression, preview.then(|| saved_preview(s)).flatten().as_ref())?,
"json" => solidcraft_io::write_design(&s.doc),
"container" => s.design_container(compression, preview, cache)?,
"json" => solidcraft_io::write_design(&s.design()),
_ => return Err(bad("file.save", "format must be container or json")),
};
// Atomic, keeping the version being replaced as `<file>.bak`.
solidcraft_io::write_atomic(std::path::Path::new(path), &bytes, true).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
s.path = Some(path.to_string());
s.mark_saved();
Ok(json!({"path": path, "bytes": bytes.len()}))
Ok(json!({"path": path, "bytes": bytes.len(), "name": s.doc.name}))
}
impl Session {
/// Container bytes for desktop and browser saves. Only already evaluated bodies
/// and warm meshes are eligible; saving never forces evaluation or tessellation.
pub fn design_container(&self, compression: solidcraft_io::DesignCompression, preview: bool, cache: bool) -> solidcraft_io::Result<Vec<u8>> {
let geometry = cache.then(|| saved_geometry(self)).flatten();
let preview = preview.then(|| saved_preview(self)).flatten();
let thumbnail = cache.then(|| thumbnail::saved(self)).flatten();
solidcraft_io::write_design_container_with_thumbnail(&self.design(), compression, preview.as_ref(), geometry.as_deref(), thumbnail.as_deref())
}
}
fn saved_geometry(s: &Session) -> Option<Vec<solidcraft_io::DesignGeometry>> {
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
return None;
}
let world = s.cached_world_state()?;
if world.bodies.is_empty() || world.bodies.len() > 4096 {
return None;
}
let painted = s.doc.painted(&world);
Some(painted.bodies.iter().map(|b| solidcraft_io::DesignGeometry { name: b.name.clone(), body: b.body.clone() }).collect())
}
/// Cache only warm display geometry. A save never waits for an unevaluated mesh.
@@ -203,6 +286,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
str_(p, "format").unwrap_or(default_design_format(&path)),
str_(p, "compression").unwrap_or("fast"),
bool_(p, "preview").unwrap_or(false),
bool_(p, "cache").unwrap_or(false),
)
}
@@ -218,6 +302,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
str_(p, "format").unwrap_or(default_design_format(&path)),
str_(p, "compression").unwrap_or("fast"),
bool_(p, "preview").unwrap_or(false),
bool_(p, "cache").unwrap_or(false),
)
}
@@ -228,7 +313,7 @@ fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Resu
if !bodies.is_empty() {
return Err(bad("file.export", "design JSON exports the complete document; omit bodies"));
}
solidcraft_io::write_design(&s.doc)
solidcraft_io::write_design(&s.design())
} else if format == Format::Step && bodies.is_empty() && !s.doc.occurrences.is_empty() {
solidcraft_io::step_assembly(&s.doc, &s.doc.painted(&s.model.state()), &name)?
} else {
@@ -267,7 +352,10 @@ fn recover(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "doc.recover";
let dir = recovery_dir(p, cmd)?;
let id = str_(p, "id").ok_or_else(|| bad(cmd, "`id` is required (see doc.recovery_list)"))?;
let (doc, entry) = crate::recovery::load(&dir, id)?;
let (mut doc, entry) = crate::recovery::load(&dir, id)?;
if let Some(stem) = entry.path.as_deref().and_then(|path| default_name_for(&doc.name, path)) {
doc.name = stem;
}
*s = Session::new(doc);
s.path = entry.path.clone();
s.mark_unsaved();
@@ -313,7 +401,7 @@ mod tests {
for index in if round % 2 == 0 { [0, 1] } else { [1, 0] } {
let path = target.join(if index == 0 { "issue303-warm.json" } else { "issue303-warm.solidcraft" });
let began = std::time::Instant::now();
session.execute("file.save", &json!({"path":path,"preview":true})).unwrap();
session.execute("file.save", &json!({"path":path,"preview":true,"cache":false})).unwrap();
samples[index].push(began.elapsed().as_secs_f64() * 1000.0);
}
}
@@ -350,7 +438,9 @@ mod tests {
}
let save_path = std::env::temp_dir().join(format!("solidcraft-preview-save-{}.solidcraft", std::process::id()));
s.execute("file.save", &json!({"path":save_path})).unwrap();
assert!(solidcraft_io::read_design_preview(&std::fs::read(&save_path).unwrap()).is_none(), "ordinary saves omit redundant display data");
let saved = std::fs::read(&save_path).unwrap();
assert!(solidcraft_io::read_design_geometry(&saved).is_none(), "ordinary saves omit native geometry caches");
assert!(!saved.windows(b"cache/preview.bin".len()).any(|s| s == b"cache/preview.bin"), "ordinary saves omit redundant display meshes");
s.execute("file.save", &json!({"path":save_path,"preview":true})).unwrap();
assert!(solidcraft_io::read_design_preview(&std::fs::read(&save_path).unwrap()).is_some());
let _ = std::fs::remove_file(&save_path);
@@ -393,6 +483,7 @@ mod tests {
}
*s.world_cache.lock().unwrap() = None;
assert!(saved_preview(&s).is_none());
assert!(saved_geometry(&s).is_none());
assert!(s.world_cache.lock().unwrap().is_none(), "saving must not prepare cold placements");
for body in &s.world_state().bodies {
body.mesh();
@@ -404,6 +495,7 @@ mod tests {
}
assert!(!s.pending_moves.is_empty());
assert!(saved_preview(&s).is_none(), "uncaptured positions are not in the saved document");
assert!(saved_geometry(&s).is_none());
s.execute("component.capture_position", &json!({})).unwrap();
for body in &s.world_state().bodies {
body.mesh();
@@ -418,6 +510,192 @@ mod tests {
}
assert!(editing.active_sketch.is_some());
assert!(saved_preview(&editing).is_none());
assert!(saved_geometry(&editing).is_none());
}
#[test]
fn save_and_save_as_require_explicit_native_cache_opt_in() {
let mut session = Session::new(Document::new("Cache opt-in"));
session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
let world = session.world_state();
for body in &world.bodies {
body.mesh();
}
let path = std::env::temp_dir().join(format!("solidcraft-cache-opt-in-{}.solidcraft", std::process::id()));
for command in ["file.save", "file.save_as"] {
for cache in [None, Some(true), Some(false), None] {
let mut params = json!({"path":path});
if let Some(cache) = cache {
params["cache"] = json!(cache);
}
session.execute(command, &params).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(solidcraft_io::read_design_geometry(&bytes).is_some(), cache == Some(true), "{command}: {cache:?}");
assert_eq!(solidcraft_io::read_design_thumbnail(&bytes).is_some(), cache == Some(true), "{command}: {cache:?}");
assert!(
!bytes.windows(b"cache/preview.bin".len()).any(|entry| entry == b"cache/preview.bin"),
"display meshes require preview: true"
);
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
}
}
std::fs::remove_file(&path).unwrap();
std::fs::remove_file(format!("{}.bak", path.display())).unwrap();
}
#[test]
fn native_cache_does_not_force_meshes_or_supply_editing_state() {
let mut session = Session::new(Document::new("Native cache authority"));
session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
let world = session.world_state();
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()));
let mut cached = saved_geometry(&session).unwrap();
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()), "saving native geometry must not triangulate");
assert!(solidcraft_io::read_design_geometry(&bytes).is_some());
assert!(
solidcraft_io::read_design_geometry(&session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap()).is_none()
);
cached[0].body = solidcraft_kernel::box_solid(solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::new(100., 100., 100.)).unwrap();
let bytes =
solidcraft_io::write_design_container_with_cache(&session.doc, solidcraft_io::DesignCompression::Fast, None, Some(&cached)).unwrap();
let path = std::env::temp_dir().join(format!("solidcraft-native-forged-{}.solidcraft", std::process::id()));
std::fs::write(&path, bytes).unwrap();
let seen = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let callback_seen = seen.clone();
let mut reopened = Session {
open_preview: Some(Box::new(move |p| {
callback_seen.store(!p.meshes.is_empty(), std::sync::atomic::Ordering::Relaxed);
})),
..Session::default()
};
reopened.execute("doc.open", &json!({"path":path})).unwrap();
assert!(seen.load(std::sync::atomic::Ordering::Relaxed));
assert_eq!(reopened.doc, session.doc);
let measured = reopened.execute("inspect.measure", &json!({})).unwrap();
assert!((measured["bodies"][0]["volume_mm3"].as_f64().unwrap() - 6000.).abs() < 1e-6);
std::fs::remove_file(path).unwrap();
}
#[test]
#[ignore = "manual native cache save benchmark; generate issue303-step.json first"]
fn benchmark_native_cache_save() {
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target"));
let input = std::env::var_os("SOLIDCRAFT_BENCH_FILE").map(std::path::PathBuf::from).unwrap_or_else(|| target.join("issue303-step.json"));
let mut session = Session::new(solidcraft_io::read_design(&std::fs::read(input).unwrap()).unwrap());
for body in &session.world_state().bodies {
body.mesh();
}
let modes = [("json", false, "fast"), ("container", false, "fast"), ("container", true, "fast"), ("container", true, "compact")];
let mut samples = [Vec::new(), Vec::new(), Vec::new(), Vec::new()];
for round in 0..9 {
for i in if round % 2 == 0 { [0, 1, 2, 3] } else { [3, 2, 1, 0] } {
let (format, cache, compression) = modes[i];
let path = target.join(format!("issue303-native-{i}.solidcraft"));
let start = std::time::Instant::now();
session.execute("file.save", &json!({"path":path,"format":format,"cache":cache,"compression":compression})).unwrap();
samples[i].push(start.elapsed().as_secs_f64() * 1000.);
}
}
for (i, times) in samples.iter_mut().enumerate() {
times.sort_by(f64::total_cmp);
let bytes = std::fs::read(target.join(format!("issue303-native-{i}.solidcraft"))).unwrap();
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
assert_eq!(solidcraft_io::read_design_geometry(&bytes).is_some(), modes[i].1);
eprintln!("native_save mode={i} median_ms={:.3} bytes={}", times[4], bytes.len());
}
}
#[test]
#[ignore = "manual fresh-process opening benchmark; one encoding per process"]
fn benchmark_native_cache_open() {
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target"));
let mode = std::env::var("SOLIDCRAFT_BENCH_ENCODING").unwrap_or_else(|_| "2".into());
let path = target.join(format!("issue303-native-{mode}.solidcraft"));
let display = std::sync::Arc::new(std::sync::Mutex::new(None));
let notify = display.clone();
let start = std::time::Instant::now();
let mut session = Session {
open_preview: Some(Box::new(move |_| {
*notify.lock().unwrap() = Some(start.elapsed().as_secs_f64() * 1000.);
})),
..Session::default()
};
let result = session.execute("doc.open", &json!({"path":path})).unwrap();
assert_eq!(result["errors"], 0);
let reconstructed = start.elapsed().as_secs_f64() * 1000.;
let mut triangles = 0;
let world = session.world_state();
for body in &world.bodies {
triangles += body.mesh().triangles.len();
}
let ready = start.elapsed().as_secs_f64() * 1000.;
eprintln!(
"native_open mode={mode} provisional_ms={:?} rebuilt_ms={reconstructed:.3} meshes_ready_ms={ready:.3} bodies={} triangles={triangles}",
*display.lock().unwrap(),
world.bodies.len()
);
}
fn name_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-name-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn save_as_names_an_untitled_design_after_its_file() {
let dir = name_dir("untitled");
let path = dir.join("bracket.solidcraft");
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
assert_eq!(s.doc.name, "Untitled");
let r = s.execute("file.save_as", &json!({"path":path})).unwrap();
assert_eq!(r["name"], "bracket");
assert_eq!(s.doc.name, "bracket");
assert!(!s.is_dirty(), "taking the file's name is part of the save");
assert_eq!(solidcraft_io::read_design(&std::fs::read(&path).unwrap()).unwrap().name, "bracket");
// Undoing the box does not bring the old name back.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc.name, "bracket");
// Saving under another file keeps the name it now has.
s.execute("file.save_as", &json!({"path":dir.join("copy.solidcraft")})).unwrap();
assert_eq!(s.doc.name, "bracket");
// A failed save leaves the name alone.
let mut failed = Session::default();
assert!(failed.execute("file.save_as", &json!({"path":dir.join("missing-dir").join("plate.solidcraft")})).is_err());
assert_eq!(failed.doc.name, "Untitled");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn save_as_keeps_a_name_the_user_chose() {
let dir = name_dir("named");
let mut s = Session::default();
s.execute("file.new", &json!({"name":"Gearbox lid"})).unwrap();
s.execute("file.save_as", &json!({"path":dir.join("lid-v2.solidcraft")})).unwrap();
assert_eq!(s.doc.name, "Gearbox lid");
let mut reopened = Session::default();
reopened.execute("doc.open", &json!({"path":dir.join("lid-v2.solidcraft")})).unwrap();
assert_eq!(reopened.doc.name, "Gearbox lid");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn opening_an_untitled_file_shows_its_file_name() {
let dir = name_dir("open");
for (stored, file) in [("Untitled", "hinge.solidcraft"), ("", "spacer.json")] {
let path = dir.join(file);
let bytes = solidcraft_io::write_design(&Document::new(stored));
std::fs::write(&path, bytes).unwrap();
let mut s = Session::default();
let r = s.execute("doc.open", &json!({"path":path})).unwrap();
let stem = file.split('.').next().unwrap();
assert_eq!(s.doc.name, stem);
assert_eq!(r["name"], stem);
assert!(!s.is_dirty(), "showing the file's name is not a change to save");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
@@ -454,4 +732,33 @@ mod tests {
assert!(std::fs::read_to_string(&packaged).unwrap().contains("solidcraft/"));
std::fs::remove_dir_all(dir).unwrap();
}
/// A watched open reports the timeline's progress; cancelled, it skips the rebuild.
#[test]
fn watched_open_reports_progress_and_stops_when_cancelled() {
use solidcraft_doc::progress::{Progress, watch};
use std::sync::Arc;
let dir = std::env::temp_dir().join(format!("solidcraft-watched-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("three.solidcraft").to_string_lossy().to_string();
let mut s = Session::default();
for x in [0, 20, 40] {
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"corner":[x,0,0]})).unwrap();
}
s.execute("file.save", &json!({"path":path})).unwrap();
let progress = Arc::new(Progress::default());
let mut opened = Session::default();
watch(progress.clone(), || opened.execute("doc.open", &json!({"path":path}))).unwrap();
assert_eq!(progress.features(), (3, 3));
assert_eq!(opened.world_state().bodies.len(), 3);
let progress = Arc::new(Progress::default());
progress.cancel();
let mut stopped = Session::default();
watch(progress, || stopped.execute("doc.open", &json!({"path":path}))).unwrap();
assert!(stopped.model.results.iter().all(|r| r.skipped), "no feature was rebuilt after Cancel");
assert!(stopped.world_state().bodies.is_empty());
std::fs::remove_dir_all(dir).unwrap();
}
}
+150
View File
@@ -0,0 +1,150 @@
//! A portable picture from warm display meshes only; never evaluate on save.
use crate::Session;
use solidcraft_geom::Aabb3;
use solidcraft_render::{Camera, Rgb, Scene, SceneMesh, StandardView};
pub(super) fn saved(s: &Session) -> Option<Vec<u8>> {
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
return None;
}
let world = s.cached_world_state()?;
if world.bodies.is_empty() || world.bodies.len() > 1024 {
return None;
}
let mut scene = Scene::default();
let mut bounds = Aabb3::EMPTY;
let mut triangles = 0usize;
let mut points = 0usize;
for body in world.bodies.iter().filter(|b| s.body_visible(&b.name)) {
let mesh = body.cached_mesh()?;
triangles = triangles.saturating_add(mesh.triangles.len());
points = points.saturating_add(mesh.positions.len());
if triangles > 100_000 || points > 100_000 {
return None;
}
bounds = bounds.union(&mesh.bounds());
let channel = |x: f32| (x.clamp(0., 1.) * 255.).round() as u8;
let color = s.doc.body_color(body).map_or(crate::view::colors::BODY, |[r, g, b]| Rgb(channel(r), channel(g), channel(b)));
let face_colors =
s.doc.face_colors(body).into_iter().map(|(face, look)| (face as u32, Rgb(look.color[0], look.color[1], look.color[2]))).collect();
scene.meshes.push(SceneMesh { mesh, color, face_colors });
}
if bounds.is_empty() {
return None;
}
let mut camera = Camera::default();
camera.set_view(StandardView::Iso);
camera.fit(&bounds);
camera.distance *= 0.7;
scene.radius = (bounds.diagonal() * 0.5).max(camera.half_height()).max(10.);
scene.background = Some((crate::view::colors::BG_TOP, crate::view::colors::BG_BOTTOM));
let [width, height] = solidcraft_io::THUMBNAIL_SIZE;
if !within_raster_budget(&scene, &camera, width, height) {
return None;
}
let image = solidcraft_render::render(&scene, &camera, width, height);
solidcraft_render::png(width, height, image.rgba)
}
// Bound overlapping-triangle work too, not only mesh size. The renderer uses
// 2x supersampling; conservatively count each projected triangle's pixel box.
fn within_raster_budget(scene: &Scene, camera: &Camera, width: usize, height: usize) -> bool {
let (w, h) = ((width * 2) as f64, (height * 2) as f64);
let matrix = camera.view_proj(w / h, scene.radius.max(1.));
let mut pixels = 0usize;
let mut colors = 0usize;
for entry in &scene.meshes {
colors = colors.saturating_add(entry.mesh.triangles.len().saturating_mul(entry.face_colors.len()));
if colors > 1_000_000 {
return false;
}
let mut projected = Vec::with_capacity(entry.mesh.positions.len());
for point in &entry.mesh.positions {
let q = matrix.apply(*point);
if q.iter().any(|x| !x.is_finite()) || q[3] <= 1e-9 {
return false;
}
let pixel = [(q[0] / q[3] + 1.) * 0.5 * w, (1. - q[1] / q[3]) * 0.5 * h];
if pixel.iter().any(|x| !x.is_finite()) {
return false;
}
projected.push(pixel);
}
for triangle in &entry.mesh.triangles {
let [Some(a), Some(b), Some(c)] = triangle.map(|i| projected.get(i as usize).copied()) else {
return false;
};
let dx = (a[0].max(b[0]).max(c[0]).ceil().clamp(0., w) - a[0].min(b[0]).min(c[0]).floor().clamp(0., w) + 2.) as usize;
let dy = (a[1].max(b[1]).max(c[1]).ceil().clamp(0., h) - a[1].min(b[1]).min(c[1]).floor().clamp(0., h) + 2.) as usize;
pixels = pixels.saturating_add(dx.saturating_mul(dy));
if pixels > 4_000_000 {
return false;
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn portable_thumbnail_refuses_excessive_overdraw() {
let mut session = Session::default();
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
let mesh = session.world_state().bodies[0].mesh();
let bounds = mesh.bounds();
let mut camera = Camera::default();
camera.set_view(StandardView::Iso);
camera.fit(&bounds);
let mut scene = Scene { radius: bounds.diagonal(), ..Scene::default() };
scene.meshes.push(SceneMesh { mesh: mesh.clone(), color: Rgb(100, 100, 100), face_colors: vec![] });
assert!(within_raster_budget(&scene, &camera, 240, 150));
let mut repeated = (*mesh).clone();
repeated.triangles = vec![repeated.triangles[0]; 100_000];
scene.meshes[0].mesh = std::sync::Arc::new(repeated);
assert!(!within_raster_budget(&scene, &camera, 240, 150));
}
#[test]
fn portable_thumbnail_preserves_saved_visibility() {
let mut session = Session::default();
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30,"body_name":"Visible"})).unwrap();
session.execute("solid.box", &serde_json::json!({"length":100,"width":100,"height":100,"body_name":"Hidden"})).unwrap();
session.execute("browser.visibility", &serde_json::json!({"items":["Hidden"],"visible":false})).unwrap();
let world = session.world_state();
for body in &world.bodies {
body.mesh();
}
let expected = saved(&session).unwrap();
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
let doc = solidcraft_io::read_design(&bytes).unwrap();
assert_eq!(doc.visibility.hidden_bodies, vec!["Hidden"]);
assert_eq!(doc, *session.design());
assert_eq!(solidcraft_io::read_design_thumbnail(&bytes), Some(expected));
let reopened = Session::new(doc);
assert_eq!(reopened.visibility, session.visibility);
let disabled = session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap();
assert_eq!(solidcraft_io::read_design(&disabled).unwrap().visibility, session.visibility);
assert!(solidcraft_io::read_design_thumbnail(&disabled).is_none());
}
#[test]
fn portable_thumbnail_never_forces_cold_evaluation_or_meshing() {
let mut session = Session::default();
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
let world = session.world_state();
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()));
assert!(saved(&session).is_none());
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()));
for body in &world.bodies {
body.mesh();
}
let png = saved(&session).unwrap();
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
assert_eq!(solidcraft_io::read_design_thumbnail(&bytes), Some(png));
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
let disabled = session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap();
assert!(solidcraft_io::read_design_thumbnail(&disabled).is_none());
}
}
+14 -11
View File
@@ -22,7 +22,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("material.assign", "Physical Material", physical_material)
.at("SOLID", "MODIFY")
.icon("material")
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …; `material.list`)"),
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …, or one made with material.create; `material.list`)"),
CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
@@ -34,7 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("appearance.list", "List Appearances", appearance_list)
.noundo()
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
CommandSpec::new("material.list", "List Materials", material_list).noundo().doc_only(),
CommandSpec::new("material.list", "List Materials", material_list)
.noundo()
.doc_only()
.params("→ the built-in materials, then the design's own (user: true, with their look)"),
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
];
@@ -283,11 +286,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "material.assign";
let bodies = string_list(p, "bodies");
let material = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
let canonical = solidcraft_doc::MATERIALS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(material.trim()))
.map(|(n, _)| n.to_string())
.ok_or_else(|| bad(cmd, format!("unknown material `{material}` (see material.list)")))?;
let canonical = s.doc.material_name(material).ok_or_else(|| bad(cmd, format!("unknown material `{material}` (see material.list)")))?;
let st = s.model.state();
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
return Err(bad(cmd, "`bodies` must list existing bodies"));
@@ -303,7 +302,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
}
/// A colour parameter: `"#rrggbb"` or `[r, g, b]` (0–255).
fn color_param(v: &Value) -> Option<[u8; 3]> {
pub(super) fn color_param(v: &Value) -> Option<[u8; 3]> {
if let Some(t) = v.as_str() {
let h = t.trim().trim_start_matches('#');
if h.len() != 6 || !h.is_ascii() {
@@ -455,7 +454,7 @@ fn dist_to(b: &solidcraft_doc::ModelBody, p: solidcraft_geom::Vec3) -> f64 {
c.dist(p)
}
fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
pub(super) fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
json!({"name": l.name, "color": l.hex(), "opacity": l.opacity})
}
@@ -524,6 +523,10 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
fn material_list(_s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"materials": solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d})).collect::<Vec<_>>()}))
fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d}));
let user = s.doc.user_materials.iter().map(
|m| json!({"name": m.name, "density_g_cm3": m.density, "appearance": s.doc.material_look(&m.name).as_ref().map(look_json), "user": true}),
);
Ok(json!({"materials": built_in.chain(user).collect::<Vec<_>>()}))
}
+66 -22
View File
@@ -6,7 +6,7 @@
use serde_json::{Value, json};
use solidcraft_doc::expr::Kind;
use solidcraft_doc::joints::{self, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Snap};
use solidcraft_geom::Vec3;
use solidcraft_geom::{Aabb3, Vec3};
use super::CommandSpec;
use crate::params::{bad, bool_, num, str_, string_list, vec3};
@@ -37,7 +37,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.list", "List Joints", list).noundo().params("→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts"),
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).params("re-place the occurrences from the joints → conflicts, errors, dof"),
CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
"bodies?: [names] (default: all, in world placement) → pairs that overlap with the overlap volume, area, bounding box and centre",
"bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, so any number of bodies is fine; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out)",
),
];
@@ -523,35 +523,79 @@ fn solve_cmd(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn interference(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.interference";
let want = string_list(p, "bodies");
let st = s.world_state();
let bodies: Vec<&solidcraft_doc::ModelBody> =
st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).filter(|b| !b.body.is_mesh()).collect();
if bodies.len() > 200 {
return Err(bad(cmd, "too many bodies (200 at most)"));
}
let mut hits = Vec::new();
for (i, a) in bodies.iter().enumerate() {
for b in bodies.iter().skip(i + 1) {
let (ba, bb) = (a.mesh().bounds(), b.mesh().bounds());
let apart = ba.max.x < bb.min.x
|| bb.max.x < ba.min.x
|| ba.max.y < bb.min.y
|| bb.max.y < ba.min.y
|| ba.max.z < bb.min.z
|| bb.max.z < ba.min.z;
if apart {
// Prune by bounding boxes first (sort and sweep), so the cost follows the pairs that can
// touch rather than the square of the body count.
let boxes: Vec<Aabb3> = bodies.iter().map(|b| b.mesh().bounds()).collect();
let (pairs, skipped) = overlapping_pairs(&boxes, MAX_INTERFERENCE_PAIRS);
let (mut hits, mut unchecked) = (Vec::new(), Vec::new());
for (i, j) in pairs {
let (Some(a), Some(b)) = (bodies.get(i), bodies.get(j)) else { continue };
// A pair whose shared part can't be worked out is reported, not passed as clear.
let (shared, report) =
solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect));
let mut skip = |reason: String| unchecked.push(json!({"a": a.name, "b": b.name, "reason": reason}));
let shared = match shared {
Ok(o) => o,
Err(e) => {
skip(e.to_string());
continue;
}
let Ok(Some(o)) = solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect) else { continue };
let Ok(m) = solidcraft_kernel::measure(&o) else { continue };
if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) {
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}}));
};
if let Some(reason) = report.unverified() {
skip(reason);
}
let Some(o) = shared else { continue };
let m = match solidcraft_kernel::measure(&o) {
Ok(m) => m,
Err(e) => {
skip(e.to_string());
continue;
}
};
if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) {
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}}));
}
}
Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len(), "unchecked": unchecked,
"truncated": skipped > 0, "skipped_pairs": skipped}))
}
/// Most pairs with overlapping bounding boxes `inspect.interference` intersects in one call.
const MAX_INTERFERENCE_PAIRS: usize = 20_000;
/// Index pairs `(i, j)`, `i < j`, whose boxes overlap or touch (sort and sweep along X), at most
/// `limit` of them, plus how many more there were.
fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, usize)>, usize) {
let mut order: Vec<usize> = (0..boxes.len()).collect();
order.sort_by(|&a, &b| match (boxes.get(a), boxes.get(b)) {
(Some(a), Some(b)) => a.min.x.total_cmp(&b.min.x),
_ => std::cmp::Ordering::Equal,
});
let (mut pairs, mut skipped) = (Vec::new(), 0);
for (k, &i) in order.iter().enumerate() {
let Some(a) = boxes.get(i) else { continue };
for &j in order.iter().skip(k + 1) {
let Some(b) = boxes.get(j) else { continue };
// Sorted by min.x: once one starts past `a`, all later ones do.
if b.min.x > a.max.x {
break;
}
if b.max.x < a.min.x || a.max.y < b.min.y || b.max.y < a.min.y || a.max.z < b.min.z || b.max.z < a.min.z {
continue;
}
if pairs.len() < limit {
pairs.push((i.min(j), i.max(j)));
} else {
skipped += 1;
}
}
}
Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len()}))
pairs.sort_unstable();
(pairs, skipped)
}
#[cfg(test)]
+100
View File
@@ -151,6 +151,57 @@ fn rigid_cycles_solve_and_conflicts_report() {
assert!(s.execute("joint.create", &json!({"type": "hinge"})).is_err());
}
/// A 30 x 30 x 3 plate with a through slot whose x = 20 wall carries one rib (0.3 tall, 0.8
/// wide), and a peg in the slot with 0.15 clearance all round.
fn ribbed_slot_and_peg(s: &mut Session, peg_x1: f64) {
run(s, "sketch.create", json!({"plane": "XY"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 30]}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 3, "body_name": "Plate"}));
run(s, "sketch.create", json!({"plane": "XY", "offset": -1}));
let slot = json!([[10, 5], [20, 5], [20, 14.6], [19.7, 14.6], [19.7, 15.4], [20, 15.4], [20, 25], [10, 25]]);
run(s, "sketch.line", json!({"points": slot, "closed": true}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 5, "operation": "cut", "targets": ["Plate"]}));
run(s, "sketch.create", json!({"plane": "XY"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [10.15, 5.15], "p1": [peg_x1, 24.85]}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 3, "operation": "new", "body_name": "Peg"}));
}
/// The peg overlaps only the rib: 0.15 x 0.8 x 3 = 0.36 mm³ (#442).
#[test]
fn interference_finds_a_peg_overlapping_a_small_rib() {
let mut s = Session::default();
ribbed_slot_and_peg(&mut s, 19.85);
let i = run(&mut s, "inspect.interference", json!({}));
assert_eq!(i["count"], 1, "{i}");
assert!((i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0) - 0.36).abs() < 1e-6, "{i}");
let (lo, hi) = (&i["interferences"][0]["bbox"]["min"], &i["interferences"][0]["bbox"]["max"]);
assert!((lo[0].as_f64().unwrap_or(0.0) - 19.7).abs() < 1e-6 && (hi[0].as_f64().unwrap_or(0.0) - 19.85).abs() < 1e-6, "{i}");
}
/// An arc revolved up to a post ends on the post's wall, where the two bodies can't be
/// intersected: the pair is listed as unchecked instead of passing as clear.
#[test]
fn interference_lists_a_pair_it_could_not_check() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 14, -5], "radius": 4, "height": 15, "body_name": "Post"}));
run(&mut s, "sketch.create", json!({"name": "Profile", "plane": "XZ"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [15, 5]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": {"origin": [0, 0, 0], "dir": [0, 0, 1]}, "to": [4, 14, 2], "body_name": "Arc"}));
let i = run(&mut s, "inspect.interference", json!({}));
assert_eq!(i["count"], 0, "{i}");
let unchecked = i["unchecked"].as_array().cloned().unwrap_or_default();
assert_eq!(unchecked.len(), 1, "{i}");
assert!(unchecked[0]["reason"].as_str().is_some_and(|r| r.starts_with("not verified") && !r.contains("displacement")), "{i}");
// Bodies that are clear of each other, or overlap plainly, leave the list empty.
let mut s = two_cubes();
let i = run(&mut s, "inspect.interference", json!({}));
assert_eq!(i["unchecked"].as_array().map(Vec::len), Some(0), "{i}");
}
#[test]
fn motion_links_and_interference() {
let mut s = two_cubes();
@@ -195,3 +246,52 @@ fn faces_mate_by_default() {
run(&mut s, "joint.edit", json!({"joint": "Stack", "flip": true}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 0.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0)));
}
/// #527: an assembly with its fasteners passes 200 bodies quickly; the clash check must still
/// cover the whole design. 201 separate 1 mm cubes 2 mm apart, plus one cube pushed half-way
/// into the first: exactly that pair overlaps, by 0.5 mm³.
#[test]
fn interference_checks_designs_with_more_than_200_bodies() {
let mut s = Session::default();
for i in 0..201 {
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [2 * i, 0, 0], "operation": "new"}));
}
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [0.5, 0, 0], "operation": "new", "body_name": "Clash"}));
assert_eq!(s.model.state().bodies.len(), 202);
let i = run(&mut s, "inspect.interference", json!({}));
assert_eq!(i["checked"], 202, "{i}");
assert_eq!(i["count"], 1, "{i}");
let hit = &i["interferences"][0];
let mut pair = [hit["a"].as_str().unwrap_or(""), hit["b"].as_str().unwrap_or("")];
pair.sort_unstable();
assert_eq!(pair, ["Body1", "Clash"], "{i}");
assert!((hit["volume_mm3"].as_f64().unwrap_or(0.0) - 0.5).abs() < 1e-6, "{i}");
}
/// The bounding-box sweep behind `inspect.interference` finds exactly the pairs a check of every
/// pair finds (touching boxes count), and reports how many it left out past its limit.
#[test]
fn interference_box_sweep_matches_every_pair_check() {
use solidcraft_geom::Aabb3;
let cube = |x: f64, y: f64, z: f64, e: f64| Aabb3 { min: Vec3::new(x, y, z), max: Vec3::new(x + e, y + e, z + e) };
// A deterministic scatter: overlapping, touching, and apart in only one axis.
let boxes: Vec<Aabb3> = (0..60)
.map(f64::from)
.map(|i| cube((i * 7.3) % 20.0, (i * 3.1) % 9.0, (i * 5.7) % 4.0, 1.0 + (i % 3.0)))
.chain([cube(30.0, 0.0, 0.0, 1.0), cube(31.0, 0.0, 0.0, 1.0)])
.collect();
let mut every = Vec::new();
for (i, a) in boxes.iter().enumerate() {
for (j, b) in boxes.iter().enumerate().skip(i + 1) {
let apart = a.max.x < b.min.x || b.max.x < a.min.x || a.max.y < b.min.y || b.max.y < a.min.y || a.max.z < b.min.z || b.max.z < a.min.z;
if !apart {
every.push((i, j));
}
}
}
assert!(every.contains(&(60, 61)), "touching boxes are a candidate pair");
let (pairs, skipped) = super::overlapping_pairs(&boxes, usize::MAX);
assert_eq!((pairs, skipped), (every.clone(), 0));
let (pairs, skipped) = super::overlapping_pairs(&boxes, 10);
assert_eq!((pairs.len(), skipped), (10, every.len() - 10));
}
+166
View File
@@ -0,0 +1,166 @@
//! Sketches on flat faces that no construction certificate names: a Cut's walls and floor, a
//! chamfer, a pattern's copies, a side a Join merged from two features, the pieces of a split
//! face (#491, #492, #493, #557). They attach by the face's exact history name
//! (`FaceCertificate::Lineage`), follow the face through parameter edits and survive a save.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use crate::Session;
use serde_json::{Value, json};
use solidcraft_doc::FaceAttachmentStatus;
use solidcraft_doc::config::FaceCertificate;
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command} {params}: {e}"))
}
fn sketch_on(s: &mut Session, at: [f64; 3], name: &str) {
run(s, "sketch.create", json!({"plane": {"face": at}, "name": name}));
run(s, "sketch.finish", json!({}));
}
fn status(s: &Session, sketch: &str) -> FaceAttachmentStatus {
let id = s.doc.find_feature(sketch).map(|f| f.id).unwrap();
s.placed_document().sketch_face_attachment(id, &s.model)
}
fn healthy(s: &Session, sketch: &str) -> (solidcraft_geom::Plane, Option<FaceCertificate>) {
match status(s, sketch) {
FaceAttachmentStatus::Healthy { plane, target, .. } => (plane, target.certificate),
other => panic!("{sketch}: {other:?}"),
}
}
fn no_errors(s: &Session) {
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
}
fn reopened(s: &Session) -> Session {
let mut t = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
t.refresh();
no_errors(&t);
t
}
/// A 40 x 30 x 10 block with a slot cut 5 deep across it from x = `slot_x` (a parameter), 10 wide.
fn slotted_block() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Block"}));
run(&mut s, "parameters.add", json!({"name": "slot_x", "expression": "15 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "SlotSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [15, -5], "p1": [25, 35]}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p1"], "type": "horizontal", "value": "slot_x"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "10 mm"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "SlotSketch", "distance": 12, "start_offset": 5, "operation": "cut", "name": "Slot"}));
no_errors(&s);
s
}
#[test]
fn a_cut_wall_and_floor_take_a_sketch_that_follows_the_cut() {
let mut s = slotted_block();
sketch_on(&mut s, [15., 15., 7.5], "Wall");
sketch_on(&mut s, [20., 15., 5.], "Floor");
let (wall, certificate) = healthy(&s, "Wall");
assert_eq!(certificate, Some(FaceCertificate::Lineage));
// The wall faces into the slot (+x), its frame's origin over the design origin.
assert!((wall.normal().x - 1.).abs() < 1e-9 && wall.origin.x.abs() > 14.9 && (wall.origin.x - 15.).abs() < 1e-9, "{wall:?}");
assert!((wall.origin.y.abs() + wall.origin.z.abs()) < 1e-9, "{wall:?}");
let (floor, _) = healthy(&s, "Floor");
assert!((floor.normal().z - 1.).abs() < 1e-9 && (floor.origin.z - 5.).abs() < 1e-9, "{floor:?}");
// The slot moves: the sketch on its wall moves with it.
run(&mut s, "parameters.change", json!({"name": "slot_x", "expression": "20 mm"}));
no_errors(&s);
let (wall, _) = healthy(&s, "Wall");
assert!((wall.origin.x - 20.).abs() < 1e-9, "{wall:?}");
let t = reopened(&s);
assert!((healthy(&t, "Wall").0.origin.x - 20.).abs() < 1e-9);
assert!((healthy(&t, "Floor").0.origin.z - 5.).abs() < 1e-9);
}
#[test]
fn the_pieces_of_a_split_top_take_a_sketch() {
let mut s = slotted_block();
// The slot splits the block's top in two; each piece takes a sketch (on that face's plane).
for (name, x) in [("Left", 5.), ("Right", 35.)] {
sketch_on(&mut s, [x, 15., 10.], name);
assert!((healthy(&s, name).0.origin.z - 10.).abs() < 1e-9, "{name}");
}
run(&mut s, "parameters.change", json!({"name": "slot_x", "expression": "20 mm"}));
no_errors(&s);
assert!((healthy(&s, "Left").0.origin.z - 10.).abs() < 1e-9);
}
#[test]
fn a_chamfer_face_takes_a_sketch_that_follows_the_chamfer() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "bevel", "expression": "3 mm"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "name": "Base"}));
run(&mut s, "solid.chamfer", json!({"edges": [[40, 15, 10]], "distance": "bevel", "name": "Bevel"}));
no_errors(&s);
sketch_on(&mut s, [38.5, 15., 8.5], "OnBevel");
let (plane, certificate) = healthy(&s, "OnBevel");
assert_eq!(certificate, Some(FaceCertificate::Lineage));
let n = std::f64::consts::FRAC_1_SQRT_2;
assert!((plane.normal().x - n).abs() < 1e-9 && (plane.normal().z - n).abs() < 1e-9, "{plane:?}");
// A bigger chamfer moves the face inward: the sketch goes with it.
let height = |p: &solidcraft_geom::Plane| (p.origin - solidcraft_geom::Vec3::new(40., 0., 10.)).dot(p.normal());
assert!((height(&plane) + 3. * n).abs() < 1e-9, "{plane:?}");
run(&mut s, "parameters.change", json!({"name": "bevel", "expression": "5 mm"}));
no_errors(&s);
assert!((height(&healthy(&s, "OnBevel").0) + 5. * n).abs() < 1e-9);
healthy(&reopened(&s), "OnBevel");
}
/// A plate with a boss patterned 3 times along x, `pitch` apart.
fn patterned_bosses(sketch_on_seed_first: bool) -> Session {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "pitch", "expression": "20 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [80, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "sketch.create", json!({"plane": {"face": [40, 10, 5]}}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 4}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 6, "operation": "join", "name": "Boss"}));
if sketch_on_seed_first {
sketch_on(&mut s, [10., 10., 11.], "Seed");
}
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Boss"], "dir1": [1, 0, 0], "count1": 3, "spacing1": "pitch", "name": "Row"}));
no_errors(&s);
s
}
#[test]
fn each_patterned_copy_takes_its_own_sketch() {
let mut s = patterned_bosses(true);
// A sketch made on the seed before the pattern keeps its construction certificate: the
// copies carry it too, but are named as copies.
assert!(matches!(healthy(&s, "Seed").1, Some(FaceCertificate::ExtrudeCap { .. })));
for (name, x) in [("First", 10.), ("Middle", 30.), ("Last", 50.)] {
sketch_on(&mut s, [x, 10., 11.], name);
assert!((healthy(&s, name).0.origin.z - 11.).abs() < 1e-9, "{name}");
}
run(&mut s, "parameters.change", json!({"name": "pitch", "expression": "25 mm"}));
no_errors(&s);
for name in ["Seed", "First", "Middle", "Last"] {
healthy(&s, name);
}
let t = reopened(&s);
for name in ["Seed", "Middle", "Last"] {
healthy(&t, name);
}
}
#[test]
fn a_piece_of_a_face_split_on_purpose_still_needs_a_fixed_plane() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Modified"}));
run(&mut s, "solid.split_face", json!({"body": "Modified", "faces": [[5, 5, 10]], "plane": {"origin": [5, 0, 0], "normal": [1, 0, 0]}}));
let error = s.execute("sketch.create", &json!({"plane": {"face": [2.5, 5, 10]}})).unwrap_err().to_string();
assert!(error.contains("fixed:true"), "{error}");
}
+214
View File
@@ -0,0 +1,214 @@
//! Physical materials defined in a design (`material.create`, `material.edit`,
//! `material.delete`): a name, a density and, optionally, a look. They are assigned like the
//! built-in library's (`material.assign`), set masses (`component.properties`) and fill
//! configuration material cells. The design keeps them, so they are saved and undone like
//! features.
use serde_json::{Value, json};
use solidcraft_doc::appearance::{Look, library};
use solidcraft_doc::config::Column;
use solidcraft_doc::{MATERIALS, UserMaterial, valid_density};
use super::CommandSpec;
use super::inspect::{color_param, look_json};
use crate::params::{bad, str_};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("material.create", "New Physical Material", create).icon("material").params(
"name (not a built-in material's); density_g_cm3: (0, 1000]; appearance?: library name (appearance.library) | color?: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1): the look bodies of this material get without an appearance of their own. Assign it with material.assign",
),
CommandSpec::new("material.edit", "Edit Physical Material", edit).icon("material").enabled(has_user_materials).params(
"material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity? (as material.create; color: null clears the look)",
),
CommandSpec::new("material.delete", "Delete Physical Material", delete).enabled(has_user_materials).params(
"material: a material defined in this design; replace?: material for the bodies and configuration cells that use it (without it, a material in use is refused)",
),
];
/// Most materials a design defines.
const MAX_USER_MATERIALS: usize = 1000;
fn has_user_materials(s: &Session) -> std::result::Result<(), String> {
if s.doc.user_materials.is_empty() { Err("the design defines no physical materials (material.create)".into()) } else { Ok(()) }
}
/// A new material name: 1–128 characters, not a built-in material's or another design
/// material's (`except`: the one being renamed).
fn new_name(s: &Session, cmd: &str, name: &str, except: Option<&str>) -> Result<String> {
let name = name.trim();
if name.is_empty() || name.chars().count() > 128 {
return Err(bad(cmd, "`name` must have 1–128 characters"));
}
if MATERIALS.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)) {
return Err(bad(cmd, format!("`{name}` is a built-in material (material.list)")));
}
let taken = |m: &&UserMaterial| m.name.eq_ignore_ascii_case(name) && except.is_none_or(|e| !m.name.eq_ignore_ascii_case(e));
if s.doc.user_materials.iter().any(|m| taken(&m)) {
return Err(bad(cmd, format!("the design already has a material `{name}`")));
}
Ok(name.to_string())
}
fn density(cmd: &str, p: &Value) -> Result<f64> {
p.get("density_g_cm3")
.and_then(Value::as_f64)
.filter(|d| valid_density(*d))
.ok_or_else(|| bad(cmd, "`density_g_cm3` must be a number above 0 and at most 1000"))
}
/// The look `appearance` / `color` / `opacity` give: `None` when none is given, `Some(None)`
/// to clear it (`color: null`).
fn look(cmd: &str, p: &Value, material: &str) -> Result<Option<Option<Look>>> {
let opacity = match p.get("opacity") {
Some(o) => Some(o.as_f64().filter(|x| x.is_finite() && (0.0..=1.0).contains(x)).ok_or_else(|| bad(cmd, "`opacity` must be 0…1"))?),
None => None,
};
let mut l = match (str_(p, "appearance"), p.get("color")) {
(Some(_), Some(c)) if !c.is_null() => return Err(bad(cmd, "give `appearance` or `color`, not both")),
(Some(n), _) => library(n).ok_or_else(|| bad(cmd, format!("no appearance `{n}` in the library (appearance.library)")))?,
(None, Some(Value::Null)) => return Ok(Some(None)),
(None, Some(c)) => {
let mut l = Look::custom(color_param(c).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?, 1.0);
l.name = material.to_string();
l
}
(None, None) if opacity.is_some() => return Err(bad(cmd, "`opacity` needs `appearance` or `color`")),
(None, None) => return Ok(None),
};
if let Some(o) = opacity {
l.opacity = o;
}
Ok(Some(Some(l)))
}
fn material_json(m: &UserMaterial) -> Value {
json!({"name": m.name, "density_g_cm3": m.density, "appearance": m.appearance.as_ref().map(look_json), "user": true})
}
/// The index of the design material `material` names.
fn existing(s: &Session, cmd: &str, p: &Value) -> Result<usize> {
let name = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
s.doc
.user_materials
.iter()
.position(|m| m.name.eq_ignore_ascii_case(name.trim()))
.ok_or_else(|| bad(cmd, format!("the design defines no material `{name}` (built-in materials cannot be changed)")))
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "material.create";
let name = new_name(s, cmd, str_(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?, None)?;
if s.doc.user_materials.len() >= MAX_USER_MATERIALS {
return Err(bad(cmd, format!("a design defines at most {MAX_USER_MATERIALS} materials")));
}
let material = UserMaterial { density: density(cmd, p)?, appearance: look(cmd, p, &name)?.flatten(), name };
let out = material_json(&material);
s.doc_mut().user_materials.push(material);
Ok(out)
}
/// Which configuration columns hold materials.
fn material_columns(s: &Session) -> Vec<bool> {
s.doc.configs.columns.iter().map(|c| matches!(c, Column::Material(_))).collect()
}
/// Where material `name` is used: bodies, and configuration rows with a cell naming it.
fn uses(s: &Session, name: &str) -> (Vec<String>, Vec<String>) {
let bodies = s.doc.materials.iter().filter(|(_, m)| m.eq_ignore_ascii_case(name)).map(|(b, _)| b.clone()).collect();
let columns = material_columns(s);
let rows = s
.doc
.configs
.rows
.iter()
.filter(|r| r.cells.iter().zip(&columns).any(|(cell, material)| *material && cell.trim().eq_ignore_ascii_case(name)))
.map(|r| r.name.clone())
.collect();
(bodies, rows)
}
/// Point every use of material `from` (body assignments, configuration cells) at `to`
/// ("Default" removes the body assignments).
fn move_uses(s: &mut Session, from: &str, to: &str) {
let columns = material_columns(s);
let d = s.doc_mut();
let bodies: Vec<String> = d.materials.iter().filter(|(_, m)| m.eq_ignore_ascii_case(from)).map(|(b, _)| b.clone()).collect();
for b in bodies {
if to == "Default" {
d.materials.remove(&b);
} else {
d.materials.insert(b, to.to_string());
}
}
for row in &mut d.configs.rows {
for (cell, material) in row.cells.iter_mut().zip(&columns) {
if *material && cell.trim().eq_ignore_ascii_case(from) {
*cell = to.to_string();
}
}
}
}
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "material.edit";
let i = existing(s, cmd, p)?;
let old = s.doc.user_materials.get(i).cloned().ok_or_else(|| bad(cmd, "no such material"))?;
let name = match str_(p, "name") {
Some(n) => new_name(s, cmd, n, Some(&old.name))?,
None => old.name.clone(),
};
let density = if p.get("density_g_cm3").is_some() { density(cmd, p)? } else { old.density };
let appearance = match look(cmd, p, &name)? {
Some(l) => l,
// A colour named after the material keeps following its name.
None => old.appearance.clone().map(|mut l| {
if l.name == old.name {
l.name = name.clone();
}
l
}),
};
let material = UserMaterial { name: name.clone(), density, appearance };
let mut out = material_json(&material);
if let Some(m) = s.doc_mut().user_materials.get_mut(i) {
*m = material;
}
if name != old.name {
move_uses(s, &old.name, &name);
}
out["bodies"] = json!(uses(s, &name).0);
Ok(out)
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "material.delete";
let i = existing(s, cmd, p)?;
let name = s.doc.user_materials.get(i).map(|m| m.name.clone()).ok_or_else(|| bad(cmd, "no such material"))?;
let (bodies, rows) = uses(s, &name);
let replace = match str_(p, "replace") {
Some(r) => {
let r = s.doc.material_name(r).ok_or_else(|| bad(cmd, format!("unknown material `{r}` (see material.list)")))?;
if r.eq_ignore_ascii_case(&name) {
return Err(bad(cmd, "`replace` must be another material"));
}
Some(r)
}
None if bodies.is_empty() && rows.is_empty() => None,
None => {
let mut used: Vec<String> =
bodies.iter().map(|b| format!("body `{b}`")).chain(rows.iter().map(|r| format!("configuration `{r}`"))).collect();
if used.len() > 10 {
let more = used.len() - 10;
used.truncate(10);
used.push(format!("{more} more"));
}
return Err(bad(cmd, format!("`{name}` is used by {}: give `replace` (a material for them)", used.join(", "))));
}
};
if let Some(r) = &replace {
move_uses(s, &name, r);
}
s.doc_mut().user_materials.remove(i);
Ok(json!({"deleted": name, "replaced_by": replace, "bodies": bodies}))
}
+4
View File
@@ -22,10 +22,12 @@ mod face;
mod features;
mod features_more;
pub use features::auto_operation;
mod component_link;
mod config;
mod file;
mod inspect;
pub(crate) mod joints;
mod materials;
mod measure_sel;
mod mesh;
pub(crate) mod motion;
@@ -203,6 +205,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
v.extend(component::COMMANDS.iter());
v.extend(component_link::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(config::COMMANDS.iter());
@@ -214,6 +217,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(parameters::COMMANDS.iter());
v.extend(file::COMMANDS.iter());
v.extend(inspect::COMMANDS.iter());
v.extend(materials::COMMANDS.iter());
v.extend(section::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
v
+115
View File
@@ -424,3 +424,118 @@ fn instance_styles_follow_source_identity_after_collision_rename_and_pending_mov
assert_eq!(imported.bodies[0].body.color(), Some([1.0, 0.0, 0.0]));
assert!(imported.bodies[0].body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0]));
}
/// A design file path in a fresh temporary folder (removed by the caller).
fn temp_design(tag: &str, ext: &str) -> (std::path::PathBuf, String) {
let dir = std::env::temp_dir().join(format!("solidcraft-visibility-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("design.{ext}")).to_string_lossy().to_string();
(dir, path)
}
/// #531: hiding a placement and a body is saved with the design and comes back when it opens.
/// It is not an undo step (undoing a later feature leaves it hidden), but the design then has
/// unsaved changes, and showing it again is saved the same way.
#[test]
fn hidden_placements_and_bodies_are_saved_with_the_design_but_not_undone() {
let (dir, path) = temp_design("saved", "solidcraft");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":4,"width":4,"height":4,"body_name":"Base"}));
let (component, first) = created(&mut s, "Part");
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
run(&mut s, "component.activate", json!({"component":0}));
run(&mut s, "file.save", json!({"path": path}));
assert!(!s.is_dirty());
let undo = s.undo.len();
run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]}));
run(&mut s, "browser.visibility", json!({"visible":false,"items":["Base"]}));
assert_eq!(s.undo.len(), undo, "hiding is not an undo step");
assert!(s.is_dirty(), "hiding leaves the design with unsaved changes");
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1,"corner":[50,0,0],"operation":"new","body_name":"Later"}));
run(&mut s, "edit.undo", json!({}));
assert!(!s.body_visible("Base") && !s.occurrence_visible(&[second]), "undo keeps the hiding");
run(&mut s, "file.save", json!({}));
assert!(!s.is_dirty());
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
assert!(!s.body_visible("Base"), "the hidden body stays hidden");
assert!(!s.occurrence_visible(&[second]), "the hidden placement stays hidden");
assert!(s.occurrence_visible(&[first]));
assert!(s.doc.components.iter().any(|c| c.id == component));
assert!(!s.is_dirty(), "an opened design has no unsaved changes");
assert!(s.undo.is_empty());
run(&mut s, "browser.visibility", json!({"visible":true,"folder":"bodies"}));
assert!(s.is_dirty());
run(&mut s, "file.save", json!({}));
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
assert!(s.body_visible("Base") && s.occurrence_visible(&[second]), "showing again is saved too");
let _ = std::fs::remove_dir_all(&dir);
}
/// #531: designs saved before visibility was kept (no `visibility` in the file) open with
/// everything shown, and a design with nothing hidden saves exactly as before.
#[test]
fn designs_without_saved_visibility_open_unchanged() {
let (dir, path) = temp_design("legacy", "json");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":4,"width":4,"height":4,"body_name":"Base"}));
let mut legacy: Value = serde_json::from_slice(&solidcraft_io::write_design(&s.doc)).unwrap();
assert!(legacy.get("visibility").is_none(), "nothing hidden: no visibility in the file");
legacy["format"] = json!("solidcraft/15");
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
assert!(s.body_visible("Base"));
assert_eq!(s.visibility, crate::Visibility::default());
assert!(!s.is_dirty());
run(&mut s, "file.save", json!({}));
let saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert!(saved.get("visibility").is_none());
run(&mut s, "browser.visibility", json!({"visible":false,"items":["Base"]}));
run(&mut s, "file.save", json!({}));
let saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(saved["visibility"]["hidden_bodies"], json!(["Base"]));
let _ = std::fs::remove_dir_all(&dir);
}
/// Command names for linking another design are a proposal (#533); adjust them to the real API.
const INSERT_DESIGN: &str = "component.insert_design";
const UPDATE_LINKS: &str = "component.update_links";
fn saved_box_design(path: &std::path::Path, height: f64) {
let mut part = Session::default();
run(&mut part, "solid.box", json!({"length": 10, "width": 20, "height": height, "body_name": "Plate"}));
run(&mut part, "file.save", json!({"path": path}));
}
#[test]
fn a_linked_design_brings_its_bodies_in_and_follows_edits_to_the_part_file() {
let dir = std::env::temp_dir().join(format!("solidcraft-linked-design-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let part = dir.join("plate.solidcraft");
// Given a part saved in its own design: a 10 x 20 x 30 plate.
saved_box_design(&part, 30.0);
// When an assembly inserts it as a linked component, the plate's body is in the assembly.
let mut assembly = Session::default();
let inserted = assembly.execute(INSERT_DESIGN, &json!({"path": part, "translate": [50, 0, 0]}));
let inserted = inserted.unwrap_or_else(|e| panic!("{INSERT_DESIGN}: {e}"));
let occurrence = inserted["occurrence"].as_u64().unwrap_or_else(|| panic!("no occurrence in {inserted}"));
let measured = run(&mut assembly, "occurrence.properties", json!({"occurrence": occurrence, "path": [occurrence]}));
near(&measured["total"]["volume_mm3"], 6000.0);
near(&measured["total"]["center_of_mass"][0], 55.0);
// When the part is edited (30 -> 45 tall) and saved again, updating the links follows it.
saved_box_design(&part, 45.0);
run(&mut assembly, UPDATE_LINKS, json!({}));
let measured = run(&mut assembly, "occurrence.properties", json!({"occurrence": occurrence, "path": [occurrence]}));
near(&measured["total"]["volume_mm3"], 9000.0);
near(&measured["total"]["center_of_mass"][0], 55.0);
let _ = std::fs::remove_dir_all(&dir);
}
+19
View File
@@ -208,6 +208,25 @@ fn patterns_of_a_blind_hole_drill_every_copy() {
assert!((c - 2.0 * one).abs() < 1e-3 * one, "{c} vs 2 × {one}");
}
/// Feature patterns and mirrors reference features by name; a rename follows (#401).
#[test]
fn renaming_a_source_feature_follows_pattern_and_mirror_references() {
let mut s = plate_with_hole();
run(&mut s, "construct.plane.offset", json!({"base": "YZ", "offset": 0, "name": "Mirror plane"}));
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Hole1"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 20, "name": "Copies"}));
run(&mut s, "solid.mirror", json!({"features": ["Hole1"], "plane": "Mirror plane", "name": "Mirrored"}));
let renamed = run(&mut s, "timeline.rename", json!({"feature": "Hole1", "name": "Single hole"}));
assert_eq!(renamed["name"], "Single hole", "{renamed}");
run(&mut s, "timeline.rename", json!({"feature": "Mirror plane", "name": "Renamed plane"}));
for feature in ["Copies", "Mirrored"] {
let inspected = run(&mut s, "timeline.inspect", json!({"feature": feature}));
assert!(inspected["error"].is_null(), "{feature}: {inspected}");
assert_eq!(inspected["definition"]["features"], json!(["Single hole"]), "{inspected}");
}
let inspected = run(&mut s, "timeline.inspect", json!({"feature": "Mirrored"}));
assert_eq!(inspected["definition"]["plane"]["name"], "Renamed plane", "{inspected}");
}
/// A plate with a `count1` × `count2` feature pattern of one through-hole cut: its volume and how
/// long the pattern took. It runs on its own thread so a hang fails the test instead of CI.
fn hole_pattern(count1: usize, count2: usize) -> (f64, std::time::Duration) {
+128 -21
View File
@@ -2,9 +2,9 @@
//! the flat pattern (outline, bend lines, bend table) and its DXF export.
use serde_json::{Value, json};
use solidcraft_doc::FeatureKind;
use solidcraft_doc::expr::Kind;
use solidcraft_doc::sheet::{SheetBody, SheetRule};
use solidcraft_doc::sheet::{self, SheetBody, SheetRule};
use solidcraft_doc::{FeatureKind, FlangeEdge};
use solidcraft_geom::{Vec2, Vec3};
use super::CommandSpec;
@@ -15,8 +15,10 @@ use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sheet.flange", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params(
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \
edge: edges: [[x,y,z] on top/bottom edges] + height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs); name?, body_name? (the new body)",
edge: edges: [[x,y,z] on top/bottom edges, or {point: [x,y,z], angle?, height?} for an edge with its own angle and height] + \
height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs); name?, body_name? (the new body). \
Thickness, K-factor and bend radius come from the rule (sheet.manage_rules); passing them here is refused",
),
CommandSpec::new("sheet.hem", "Hem", hem)
.at("SHEET METAL", "CREATE")
@@ -35,20 +37,24 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's; \
gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); active?: bool; delete?: bool",
gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); \
corner_relief_shape?: notch|round|rectangular (what is cut where two bends meet at a corner of the base, `corner_relief` across; default notch); \
active?: bool; delete?: bool",
),
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
.at("SHEET METAL", "CREATE")
.icon("flat")
.noundo()
.params("body? → flat size, outline loops and cut-outs (flat coordinates), bend lines and the bend table"),
.params(
"body? → flat size, outline loops and cut-outs (flat coordinates, as point lists), cutout_shapes (the same cut-outs exactly, in the same order: {circle: {center, radius}} for a round hole, else {segments: [{line: [a, b]} | {arc: {center, radius, start_deg, sweep_deg}} | {polyline: [...]}]}), pockets (with their own cutout_shapes), bend lines and the bend table",
),
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf)
.noundo()
.params("body?, path? → DXF (mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"),
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table)
.noundo()
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates"),
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; a bend that cut-outs split has a row for each piece"),
];
fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
@@ -61,6 +67,39 @@ fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
.collect()
}
/// The `edges` of an edge flange: points `[x, y, z]` on sheet edges, or `{point, angle?, height?}`
/// for an edge with its own angle and height (expressions, checked like the feature's).
fn flange_edges(s: &Session, p: &Value, cmd: &str) -> Result<Vec<FlangeEdge>> {
const KEYS: [&str; 3] = ["point", "angle", "height"];
let list = "`edges` must list points [x, y, z] on sheet edges, or {point, angle?, height?}";
let a = p.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, list))?;
if a.is_empty() || a.len() > 1000 {
return Err(bad(cmd, "`edges` must list 1…1000 points"));
}
let mut edges = Vec::with_capacity(a.len());
for (i, v) in a.iter().enumerate() {
// A bare point, as before: [x, y, z] or {x, y, z}.
let Some(o) = v.as_object().filter(|o| KEYS.iter().any(|k| o.contains_key(*k))) else {
edges.push(FlangeEdge::at(vec3(v).ok_or_else(|| bad(cmd, format!("edges[{i}]: {list}")))?));
continue;
};
if let Some(k) = o.keys().find(|k| !KEYS.contains(&k.as_str())) {
return Err(bad(cmd, format!("edges[{i}]: unknown key `{k}` (an edge takes point, angle and height)")));
}
let point = o.get("point").and_then(vec3).ok_or_else(|| bad(cmd, format!("edges[{i}]: `point` must be [x, y, z]")))?;
let own = |k: &str, kind: Kind| -> Result<Option<String>> {
if o.get(k).is_none_or(Value::is_null) {
return Ok(None);
}
let e = expr(v, k).ok_or_else(|| bad(cmd, format!("edges[{i}]: `{k}` must be a number or an expression")))?;
check_expr(s, &e, kind, cmd, &format!("edges[{i}].{k}"))?;
Ok(Some(e))
};
edges.push(FlangeEdge { point, angle: own("angle", Kind::Angle)?, height: own("height", Kind::Length)? });
}
Ok(edges)
}
fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
match str_(p, "rule") {
Some(r) if s.doc.sheet.rules.iter().any(|x| x.name == r) || r == SheetRule::steel().name => Ok(Some(r.to_string())),
@@ -69,8 +108,23 @@ fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
}
}
/// Sheet values a flange takes from its rule. Passing one to `sheet.flange` is refused rather
/// than ignored, so a script never gets a part of another thickness than it asked for.
const RULE_ONLY: [&str; 8] =
["thickness", "k_factor", "bend_radius", "relief_width", "relief_depth", "corner_relief", "corner_relief_shape", "hem_gap"];
fn flange(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.flange";
let nested = p.get("base").filter(|b| b.is_object());
if let Some(key) = RULE_ONLY.iter().find(|k| p.get(**k).is_some() || nested.is_some_and(|b| b.get(**k).is_some())) {
return Err(bad(
cmd,
format!(
"`{key}` is not a sheet.flange parameter: the sheet metal rule sets it. Make a rule with \
sheet.manage_rules {{name, {key}, …}} and pass its name as `rule`"
),
));
}
let ty = match str_(p, "type") {
Some(t) => t.to_ascii_lowercase(),
None if p.get("edges").is_some() => "edge".into(),
@@ -106,7 +160,7 @@ fn flange(s: &mut Session, p: &Value) -> Result<Value> {
)
}
"edge" => {
let edges = points(p, "edges", cmd)?;
let edges = flange_edges(s, p, cmd)?;
let height = req_expr(cmd, p, "height")?;
check_expr(s, &height, Kind::Length, cmd, "height")?;
let angle = expr(p, "angle").unwrap_or_else(|| "90 deg".into());
@@ -182,7 +236,25 @@ fn fold(s: &mut Session, p: &Value) -> Result<Value> {
None => None,
};
let body = str_(p, "body").map(str::to_string);
add_feature(s, p, FeatureKind::SheetFold { sketch, curve, a, b, angle, radius, position, flip: bool_(p, "flip").unwrap_or(false), fixed, body })
let r = add_feature(
s,
p,
FeatureKind::SheetFold { sketch, curve, a, b, angle, radius, position, flip: bool_(p, "flip").unwrap_or(false), fixed, body },
)?;
// A recompute keeps a stored fold whose fixed point no longer touches the sheet (with a
// warning); a new fold refuses one, since it picks no side (#263).
if let Some(f) = fixed {
let id = r.get("feature").and_then(Value::as_u64);
let off =
id.and_then(|id| s.model.result(id)).and_then(|res| res.warning.as_deref()).is_some_and(|w| w.contains(sheet::FOLD_FIXED_OFF_SHEET));
if off {
return Err(bad(
cmd,
format!("the fixed point ({:.3}, {:.3}, {:.3}) is not on the sheet: pick a point on the face of the side that stays", f.x, f.y, f.z),
));
}
}
Ok(r)
}
fn unfold(s: &mut Session, p: &Value) -> Result<Value> {
@@ -235,6 +307,10 @@ fn rules(s: &mut Session, p: &Value) -> Result<Value> {
*slot = e;
}
}
if let Some(shape) = p.get("corner_relief_shape").filter(|v| !v.is_null()) {
let shape = shape.as_str().and_then(solidcraft_doc::sheet::ReliefShape::parse);
rule.corner_relief_shape = shape.ok_or_else(|| bad(cmd, "`corner_relief_shape` must be notch, round or rectangular"))?.name().into();
}
let (vals, _) = s.doc.param_values();
let values = s.doc.rule_values(&vals, &rule).map_err(|e| bad(cmd, e.to_string()))?;
let doc = s.doc_mut();
@@ -274,6 +350,39 @@ fn sheet_of(s: &Session, p: &Value, cmd: &str) -> Result<SheetBody> {
}
}
/// The centre and radius of a loop that is one whole circle (often two semicircles).
fn loop_circle(lp: &solidcraft_geom::Loop2) -> Option<(Vec2, f64)> {
use solidcraft_geom::Seg2;
let Some(Seg2::Arc { center, radius, sweep, .. }) = lp.segs.first() else { return None };
let total: f64 = lp.segs.iter().filter_map(|s| if let Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum();
let circular = lp.segs.iter().all(
|s| matches!(s, Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r - *radius).abs() < 1e-9 && a.signum() == sweep.signum()),
);
(circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9).then_some((*center, *radius))
}
/// A loop's exact shape for the flat pattern JSON: a circle, or its lines and arcs (other
/// curves as point lists at the flat pattern's 0.001 mm tolerance).
fn loop_shape(lp: &solidcraft_geom::Loop2) -> Value {
use solidcraft_geom::Seg2;
let pt = |v: Vec2| json!([v.x, v.y]);
if let Some((center, radius)) = loop_circle(lp) {
return json!({"circle": {"center": pt(center), "radius": radius}});
}
let segments: Vec<Value> = lp
.segs
.iter()
.map(|seg| match *seg {
Seg2::Line { a, b } => json!({"line": [pt(a), pt(b)]}),
Seg2::Arc { center, radius, start, sweep } => {
json!({"arc": {"center": pt(center), "radius": radius, "start_deg": start.to_degrees(), "sweep_deg": sweep.to_degrees()}})
}
ref other => json!({"polyline": other.polyline(1e-3).into_iter().map(pt).collect::<Vec<_>>()}),
})
.collect();
json!({"segments": segments})
}
fn flat_json(sh: &SheetBody) -> Result<Value> {
let regions = sh.flat_regions()?;
let loops: Vec<Vec<Vec2>> = regions.iter().map(|r| r.outer.polyline(1e-3)).collect();
@@ -286,6 +395,7 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
let pt = |v: &Vec2| json!([v.x, v.y]);
let outline: Vec<Value> = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect();
let holes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(|h| Value::Array(h.polyline(1e-3).iter().map(pt).collect())).collect();
let hole_shapes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(loop_shape).collect();
let bends: Vec<Value> = sh
.bends()
.iter()
@@ -307,11 +417,12 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
};
json!({"outline": p.region.outer.polyline(1e-3).iter().map(pt).collect::<Vec<_>>(),
"cutouts": p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect::<Vec<_>>()).collect::<Vec<_>>(),
"cutout_shapes": p.region.holes.iter().map(loop_shape).collect::<Vec<_>>(),
"side": side, "depth_mm": p.z1-p.z0, "z0_mm": p.z0, "z1_mm": p.z1})
})
.collect();
Ok(
json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "pockets": pockets, "bends": bends, "unfolded": sh.flat}),
json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "cutout_shapes": hole_shapes, "pockets": pockets, "bends": bends, "unfolded": sh.flat}),
)
}
@@ -385,17 +496,13 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
for (h, layer) in loops {
// A circle is often represented by two semicircles. Keep the whole analytic
// boundary as one CIRCLE rather than approximating outer boundaries with chords.
if let Some(solidcraft_geom::Seg2::Arc { center, radius, sweep, .. }) = h.segs.first() {
let total: f64 = h.segs.iter().filter_map(|s| if let solidcraft_geom::Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum();
let circular = h.segs.iter().all(|s| matches!(s, solidcraft_geom::Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r-*radius).abs() < 1e-9 && a.signum() == sweep.signum()));
if circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9 {
w(0, "CIRCLE");
w(8, &layer);
w(10, &f(center.x));
w(20, &f(center.y));
w(40, &f(*radius));
continue;
}
if let Some((center, radius)) = loop_circle(h) {
w(0, "CIRCLE");
w(8, &layer);
w(10, &f(center.x));
w(20, &f(center.y));
w(40, &f(radius));
continue;
}
for seg in &h.segs {
match *seg {
File diff suppressed because it is too large Load Diff
+50 -19
View File
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
.at("SOLID", "CREATE")
.icon("sketch")
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source, fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"),
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source (a flat face of a sheet metal body attaches to its panel instead, in the flat pattern's coordinates), fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"),
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"),
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("sketch.line", "Line", draw_line)
@@ -117,12 +117,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "CONSTRAINTS")
.icon("c_coincident")
.enabled(in_sketch)
.params("a, b: points, a point and a curve, or two distinct lines (joins their closest endpoints)"),
.params("a, b: points, a point and a curve or sketch axis (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created), or two distinct lines (joins their closest endpoints)"),
CommandSpec::new("sketch.constraint.tangent", "Tangent", c_tangent)
.at("SKETCH", "CONSTRAINTS")
.icon("c_tangent")
.enabled(in_sketch)
.params("a, b: curves"),
.params("a, b: curves or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.equal", "Equal", c_equal)
.at("SKETCH", "CONSTRAINTS")
.icon("c_equal")
@@ -132,12 +132,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "CONSTRAINTS")
.icon("c_parallel")
.enabled(in_sketch)
.params("a, b: lines"),
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.perpendicular", "Perpendicular", c_perpendicular)
.at("SKETCH", "CONSTRAINTS")
.icon("c_perpendicular")
.enabled(in_sketch)
.params("a, b: lines"),
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.fix", "Fix/UnFix", c_fix)
.at("SKETCH", "CONSTRAINTS")
.icon("c_fix")
@@ -157,12 +157,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "CONSTRAINTS")
.icon("c_collinear")
.enabled(in_sketch)
.params("a, b: lines"),
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.symmetry", "Symmetry", c_symmetry)
.at("SKETCH", "CONSTRAINTS")
.icon("c_symmetry")
.enabled(in_sketch)
.params("a, b: points, line: symmetry line"),
.params("a, b: two points or two curves (lines mirror their ends, circles/arcs their centres); line: symmetry line id, or x / y for the sketch's own axes (a curve with that id wins; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.construction", "Normal/Construction", construction)
.at("SKETCH", "CREATE")
.icon("construction")
@@ -294,7 +294,7 @@ pub(super) fn edit_solved<T>(
let unsolved = sk.clone();
#[cfg(test)]
solve_counts::record(Analysis::Full);
let rep = doc.solve_sketch(&vals, &mut sk, &stored)?;
let rep = doc.solve_sketch(&vals, &mut sk, &stored, None)?;
Some(settle(strict, rep, &mut sk, unsolved)?)
}
};
@@ -336,7 +336,7 @@ pub(super) fn counted_solve(
pub(super) fn solve_current(sk: &mut Sketch, doc: &solidcraft_doc::Document) -> Result<SolveReport> {
let stored = sk.driving_values();
let (values, _) = doc.param_values();
Ok(doc.solve_sketch(&values, sk, &stored)?)
Ok(doc.solve_sketch(&values, sk, &stored, None)?)
}
/// Solves run on this thread by [`counted_solve`] and the edit's own solve: (all of them, those
@@ -454,6 +454,22 @@ pub(super) fn curve_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) -
sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown sketch curve `{id}`")))
}
/// A line a constraint refers to: a curve id, or `x` / `y` for the sketch's own axes (a curve
/// with that id wins). An axis becomes the sketch's linked construction line on it
/// ([`Sketch::sketch_axis_line`]), added on first use and reported in `created`.
pub(super) fn line_or_axis_ref(sk: &mut Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result<usize> {
let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, format!("`{what}` must be a curve id, or x / y for a sketch axis")))?;
curve_or_axis(sk, id)?.ok_or_else(|| bad(cmd, format!("unknown sketch curve `{id}` (x and y name the sketch's own axes)")))
}
/// Curve `id`, or the line of sketch axis `id` (see [`line_or_axis_ref`]); `None` when neither.
fn curve_or_axis(sk: &mut Sketch, id: &str) -> Result<Option<usize>> {
match sk.curve_index(id) {
Some(c) => Ok(Some(c)),
None => Ok(sk.sketch_axis_line(id)?),
}
}
pub(super) fn point_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result<usize> {
let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, format!("`{what}` must be a point reference")))?;
sk.resolve_point(id).ok_or_else(|| bad(cmd, format!("unknown sketch point `{id}`")))
@@ -646,6 +662,8 @@ fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneRef, interna
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
FeatureKind::Sketch { plane, .. } => Some(if face::anchor(plane).is_some() {
"exact_face"
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
"sheet_face"
} else if internal_body.is_some() {
"internal_legacy_face"
} else {
@@ -1154,7 +1172,7 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
// ---------------------------------------------------------------------------------------------
// Constraints
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&Sketch) -> Result<ConstraintKind>) -> Result<Value> {
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch) -> Result<ConstraintKind>) -> Result<Value> {
let mut before = None;
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let k = f(sk)?;
@@ -1179,7 +1197,7 @@ pub(super) enum Ent {
}
fn ent(sk: &Sketch, r: &str) -> Option<Ent> {
sk.resolve_point(r).map(Ent::Point).or_else(|| sk.curve_index(r).map(Ent::Curve))
sk.resolve_point(r).map(Ent::Point).or_else(|| sk.curve_index(r).or_else(|| sk.sketch_axis_curve(r)).map(Ent::Curve))
}
/// The entities of a two-entity constraint in the order they were picked (`a` then `b`, or
@@ -1372,15 +1390,18 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
}
let aref = p.get("a").and_then(Value::as_str).unwrap_or_default();
let bref = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?;
// A curve picked first and a point second: the point goes onto the curve.
if let (None, Some(c), Some(q)) = (sk.resolve_point(aref), sk.curve_index(aref), sk.resolve_point(bref)) {
// A curve (or sketch axis) picked first and a point second: the point goes onto it.
if sk.resolve_point(aref).is_none()
&& let Some(q) = sk.resolve_point(bref)
&& let Some(c) = curve_or_axis(sk, aref)?
{
return Ok(ConstraintKind::PointOnCurve { p: q, c });
}
let a = point_ref(sk, p.get("a"), cmd, "a")?;
if let Some(b) = sk.resolve_point(bref) {
return Ok(ConstraintKind::Coincident { p: a, q: b });
}
let c = sk.curve_index(bref).ok_or_else(|| bad(cmd, format!("unknown entity `{bref}`")))?;
let c = curve_or_axis(sk, bref)?.ok_or_else(|| bad(cmd, format!("unknown entity `{bref}`")))?;
Ok(ConstraintKind::PointOnCurve { p: a, c })
})
}
@@ -1420,20 +1441,27 @@ fn two_curves(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> {
Ok((curve_ref(sk, p.get("a"), cmd, "a")?, curve_ref(sk, p.get("b"), cmd, "b")?))
}
/// Two curves where either may be a sketch axis (`x` / `y`, see [`line_or_axis_ref`]).
fn two_curves_or_axes(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> {
Ok((line_or_axis_ref(sk, p.get("a"), cmd, "a")?, line_or_axis_ref(sk, p.get("b"), cmd, "b")?))
}
fn c_tangent(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.tangent", |sk| two_curves(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b }))
constrain(s, p, "sketch.constraint.tangent", |sk| {
two_curves_or_axes(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b })
})
}
fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.equal", |sk| two_curves(sk, p, "sketch.constraint.equal").map(|(a, b)| ConstraintKind::Equal { a, b }))
}
fn c_parallel(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.parallel", |sk| {
two_curves(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b })
two_curves_or_axes(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b })
})
}
fn c_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.perpendicular", |sk| {
two_curves(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })
two_curves_or_axes(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })
})
}
fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1443,7 +1471,7 @@ fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
}
fn c_collinear(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.collinear", |sk| {
two_curves(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b })
two_curves_or_axes(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b })
})
}
fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1455,7 +1483,7 @@ fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.symmetry";
let (ids, info) = edit(s, p, cmd, true, |sk, _| {
let l = curve_ref(sk, p.get("line"), cmd, "line")?;
let l = line_or_axis_ref(sk, p.get("line"), cmd, "line")?;
let ra = p.get("a").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`a` must be a point or curve"))?;
let rb = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?;
let rep = solve(&mut sk.clone());
@@ -2119,6 +2147,9 @@ mod rect_tests;
#[path = "sketch_ux_tests.rs"]
mod ux_tests;
#[cfg(test)]
#[path = "sketch_axis_ref_tests.rs"]
mod axis_ref_tests;
#[cfg(test)]
#[path = "sketch_drag_live_tests.rs"]
mod drag_live_tests;
@@ -0,0 +1,238 @@
//! Constraints that take a line accept `x` / `y` for the sketch's own axes (issue #394): the
//! axis becomes one linked construction line on it, shared by every constraint on that axis,
//! reported in `created`, and left out of profiles. A real curve with that id wins.
use serde_json::{Value, json};
use solidcraft_geom::{Vec2, Vec3};
use solidcraft_sketch::{CurveKind, LinkSource, Sketch};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn sketch(s: &Session) -> Sketch {
s.doc.sketch(s.active_sketch.unwrap()).unwrap().clone()
}
fn on(plane: Value) -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({ "plane": plane }));
s
}
fn pt(sk: &Sketch, r: &str) -> Vec2 {
sk.point(sk.resolve_point(r).unwrap()).unwrap()
}
fn strs(v: &Value) -> Vec<String> {
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
/// The sketch's axis lines: (axis name, curve id).
fn axis_lines(sk: &Sketch) -> Vec<(String, String)> {
sk.links
.iter()
.filter_map(|l| match &l.source {
LinkSource::SketchAxis { name } => Some((name.clone(), l.id.clone())),
_ => None,
})
.flat_map(|(name, link)| sk.link_curves(&link).into_iter().map(move |c| (name.clone(), sk.curves[c].id.clone())))
.collect()
}
fn assert_axis_line(sk: &Sketch, id: &str, dir: Vec2) {
let c = sk.curve_index(id).unwrap();
let curve = &sk.curves[c];
assert!(curve.construction, "axis line {id} is construction");
assert!(sk.is_linked_curve(c), "axis line {id} is linked");
let CurveKind::Line { a, b } = curve.kind else { panic!("axis line {id} is a line") };
for p in [sk.point(a).unwrap(), sk.point(b).unwrap()] {
assert!(p.cross(dir).abs() < 1e-9, "{id} end {p:?} lies on the axis {dir:?}");
}
}
#[test]
fn issue_394_symmetry_about_the_sketch_y_axis() {
let mut s = on(json!("XY"));
run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]}));
let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"}));
assert!(r["sketch"]["solved"].as_bool().unwrap(), "{r}");
let sk = sketch(&s);
let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end"));
assert!((start.x + end.x).abs() < 1e-7 && (start.y - end.y).abs() < 1e-7, "mirrored about y: {start:?} {end:?}");
assert!((start.x - -4.0).abs() < 1e-7, "the first entity stays: {start:?}");
let axes = axis_lines(&sk);
assert_eq!(axes.len(), 1, "{axes:?}");
let (name, line) = &axes[0];
assert_eq!(name, "y");
assert_axis_line(&sk, line, Vec2::Y);
assert_eq!(strs(&r["created"]["curves"]), vec![line.clone()], "the axis line is reported in created: {r}");
assert_eq!(strs(&r["created"]["constraints"]).len(), 1, "{r}");
// A second constraint on the same axis reuses its line.
run(&mut s, "sketch.line", json!({"points": [[-3, 5], [8, 5]], "ids": ["b"]}));
let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "b.start", "b": "b.end", "line": "y"}));
assert!(strs(&r["created"]["curves"]).is_empty(), "{r}");
assert_eq!(axis_lines(&sketch(&s)).len(), 1);
}
#[test]
fn a_real_curve_named_like_an_axis_wins() {
let mut s = on(json!("XY"));
run(&mut s, "sketch.line", json!({"points": [[5, -20], [5, 20]], "ids": ["y"]}));
run(&mut s, "sketch.constraint.fix", json!({"entity": "y"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [12, 0]], "ids": ["a"]}));
run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"}));
let sk = sketch(&s);
assert!(axis_lines(&sk).is_empty(), "no axis line when a curve is called y");
let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end"));
assert!((start.x + end.x - 10.0).abs() < 1e-7, "mirrored about x = 5: {start:?} {end:?}");
}
#[test]
fn a_rectangle_centred_on_the_origin_keeps_one_profile() {
let mut s = on(json!("XY"));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [2, 1], "p1": [12, 7]}));
let sk = sketch(&s);
let lines = strs(&r["curves"]);
let dir = |id: &String| {
let CurveKind::Line { a, b } = sk.curves[sk.curve_index(id).unwrap()].kind else { panic!() };
sk.point(b).unwrap() - sk.point(a).unwrap()
};
let horizontal = lines.iter().find(|l| dir(l).y.abs() < 1e-9).unwrap().clone();
let vertical = lines.iter().find(|l| dir(l).x.abs() < 1e-9).unwrap().clone();
run(&mut s, "sketch.constraint.symmetry", json!({"a": format!("{horizontal}.start"), "b": format!("{horizontal}.end"), "line": "y"}));
run(&mut s, "sketch.constraint.symmetry", json!({"a": format!("{vertical}.start"), "b": format!("{vertical}.end"), "line": "x"}));
let sk = sketch(&s);
let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY);
let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
for l in &lines {
for end in ["start", "end"] {
let p = pt(&sk, &format!("{l}.{end}"));
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
}
}
assert!((lo.x + hi.x).abs() < 1e-7 && (lo.y + hi.y).abs() < 1e-7, "centred on the origin: {lo:?} {hi:?}");
assert_eq!(axis_lines(&sk).len(), 2);
// The axis lines cross the rectangle but are construction: still one profile.
assert_eq!(run(&mut s, "sketch.solve", json!({}))["profiles"], json!(1));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
}
#[test]
fn sketch_axes_on_a_plane_off_the_world_axes() {
// A plane whose own axes are none of the world axes: x along world Y, y along world Z,
// origin away from the world origin.
let origin = Vec3::new(5.0, 7.0, 9.0);
let mut s = on(json!({"origin": [5, 7, 9], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}));
run(&mut s, "sketch.line", json!({"points": [[3, -6], [3, 2]], "ids": ["a"]}));
let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "x"}));
assert!(r["sketch"]["solved"].as_bool().unwrap(), "{r}");
let sk = sketch(&s);
let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end"));
assert!((start.y + end.y).abs() < 1e-7 && (start.x - end.x).abs() < 1e-7, "mirrored about the sketch x axis: {start:?} {end:?}");
let axes = axis_lines(&sk);
assert_eq!(axes.len(), 1);
assert_axis_line(&sk, &axes[0].1, Vec2::X);
// After a recompute the axis line is still the sketch's x axis, in world space too.
run(&mut s, "sketch.finish", json!({}));
let st = s.model.state();
let id = s.doc.find_feature("Sketch1").map(|f| f.id).unwrap();
let ss = st.sketch(id).unwrap();
let c = ss.sketch.curve_index(&axes[0].1).unwrap();
let CurveKind::Line { a, b } = ss.sketch.curves[c].kind else { panic!() };
for p in [a, b] {
let w = ss.plane.to_world(ss.sketch.point(p).unwrap()) - origin;
assert!(w.x.abs() < 1e-9 && w.z.abs() < 1e-9, "on the plane's x axis (world Y through its origin): {w:?}");
}
}
#[test]
fn other_line_constraints_take_sketch_axes() {
let mut s = on(json!("XZ"));
run(&mut s, "sketch.line", json!({"points": [[1, 1], [6, 3]], "ids": ["p"]}));
run(&mut s, "sketch.constraint.parallel", json!({"a": "p", "b": "x"}));
let sk = sketch(&s);
assert!((pt(&sk, "p.start").y - pt(&sk, "p.end").y).abs() < 1e-7, "parallel to x");
run(&mut s, "sketch.line", json!({"points": [[-2, 1], [-1, 6]], "ids": ["q"]}));
run(&mut s, "sketch.constraint.perpendicular", json!({"a": "x", "b": "q"}));
let sk = sketch(&s);
assert!((pt(&sk, "q.start").x - pt(&sk, "q.end").x).abs() < 1e-7, "perpendicular to x");
run(&mut s, "sketch.line", json!({"points": [[2, -5], [4, -8]], "ids": ["r"]}));
run(&mut s, "sketch.constraint.collinear", json!({"a": "r", "b": "y"}));
let sk = sketch(&s);
assert!(pt(&sk, "r.start").x.abs() < 1e-7 && pt(&sk, "r.end").x.abs() < 1e-7, "on the y axis");
run(&mut s, "sketch.circle.center", json!({"center": [10, 4], "radius": 2, "id": "c"}));
run(&mut s, "sketch.constraint.tangent", json!({"a": "c", "b": "x"}));
let sk = sketch(&s);
let c = sk.curve_index("c").unwrap();
let CurveKind::Circle { c: centre, r } = sk.curves[c].kind else { panic!() };
assert!((sk.point(centre).unwrap().y.abs() - r).abs() < 1e-7, "tangent to x");
// A point onto an axis, either pick order.
run(&mut s, "sketch.point", json!({"point": [7, 9], "id": "u"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "u", "b": "x"}));
assert!(pt(&sketch(&s), "u").y.abs() < 1e-7);
run(&mut s, "sketch.point", json!({"point": [7, 9], "id": "v"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "y", "b": "v"}));
assert!(pt(&sketch(&s), "v").x.abs() < 1e-7);
// Still one line per axis.
let mut names: Vec<String> = axis_lines(&sketch(&s)).into_iter().map(|(n, _)| n).collect();
names.sort();
assert_eq!(names, ["x", "y"]);
}
#[test]
fn the_axis_survives_save_and_load() {
let mut s = on(json!("YZ"));
run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]}));
run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"}));
run(&mut s, "sketch.finish", json!({}));
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let id = doc.find_feature("Sketch1").map(|f| f.id).unwrap();
let sk = doc.sketch(id).unwrap();
assert_eq!(axis_lines(sk).len(), 1);
assert_eq!(sk.links.iter().map(|l| &l.source).collect::<Vec<_>>(), [&LinkSource::SketchAxis { name: "y".into() }]);
}
#[test]
fn bad_line_references_are_errors_and_change_nothing() {
let mut s = on(json!("XY"));
run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]}));
run(&mut s, "sketch.line", json!({"points": [[0, 3], [9, 4]], "ids": ["b"]}));
let before = s.doc.to_json();
let hostile =
[json!("Y"), json!("X"), json!("z"), json!(""), json!("x.start"), json!(" y"), json!(5), json!(null), json!(["y"]), json!({"axis": "y"})];
for line in &hostile {
assert!(s.execute("sketch.constraint.symmetry", &json!({"a": "a.start", "b": "a.end", "line": line})).is_err(), "symmetry line {line}");
for cmd in ["sketch.constraint.parallel", "sketch.constraint.perpendicular", "sketch.constraint.collinear", "sketch.constraint.tangent"] {
assert!(s.execute(cmd, &json!({"a": "b", "b": line})).is_err(), "{cmd} b {line}");
}
assert!(s.execute("sketch.constraint.coincident", &json!({"a": "b.start", "b": line})).is_err(), "coincident b {line}");
}
// Constraints that do not take an axis still refuse x / y, and leave no axis line behind.
for cmd in ["sketch.constraint.equal", "sketch.constraint.concentric"] {
assert!(s.execute(cmd, &json!({"a": "b", "b": "x"})).is_err(), "{cmd}");
}
assert!(s.execute("sketch.constraint.midpoint", &json!({"point": "b.start", "line": "y"})).is_err());
assert!(s.execute("sketch.constraint.horizontal_vertical", &json!({"line": "x"})).is_err());
assert_eq!(s.doc.to_json(), before, "failed commands change nothing");
// A constraint the sketch refuses rolls the axis line back with it.
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "b", "mode": "horizontal"}));
let before_redundant = s.doc.to_json();
assert!(s.execute("sketch.constraint.parallel", &json!({"a": "b", "b": "x"})).is_err(), "already horizontal");
assert_eq!(s.doc.to_json(), before_redundant);
assert!(axis_lines(&sketch(&s)).is_empty());
}
+21 -8
View File
@@ -75,19 +75,24 @@ pub static COMMANDS: &[CommandSpec] = &[
.enabled(in_sketch)
.params(concat!("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them", created_doc!())),
CommandSpec::new("sketch.centerline", "Centerline", centerline)
.at("SKETCH", "CREATE")
.icon("centerline")
.enabled(in_sketch)
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!(
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg",
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg, font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans)",
created_doc!()
)),
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text)
.enabled(in_sketch)
.params(concat!("link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg", created_doc!())),
.params(concat!(
"link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg, font?: family or file path (empty: the built-in font)",
created_doc!()
)),
];
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> Result<LinkSource> {
let (t0, at0, h0, a0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0));
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option<String>)>) -> Result<LinkSource> {
let (t0, at0, h0, a0, f0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0, None));
let text = str_(p, "text").map(str::to_string).unwrap_or(t0);
if text.trim().is_empty() {
return Err(bad(cmd, "`text` must not be empty"));
@@ -101,7 +106,15 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> R
return Err(bad(cmd, "`height` must be positive"));
}
let angle = num(p, "angle").map(f64::to_radians).unwrap_or(a0);
Ok(LinkSource::Text { text, at, height, angle })
let font = match p.get("font") {
None | Some(Value::Null) => f0,
Some(Value::String(f)) if f.trim().is_empty() => None,
Some(Value::String(f)) => Some(f.trim().to_string()),
Some(_) => return Err(bad(cmd, "`font` must be a font family name or a font file path")),
};
// An unknown font or an unreadable file is an error now, not a lost link later.
solidcraft_sketch::font_data(font.as_deref()).map_err(|e| bad(cmd, e.to_string()))?;
Ok(LinkSource::Text { text, at, height, angle, font })
}
fn text(s: &mut Session, p: &Value) -> Result<Value> {
@@ -131,12 +144,12 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, "give `link` or `entity`")),
};
let old = match sk.link(&l).map(|x| (x.kind, x.source.clone())) {
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle })) => (text, at, height, angle),
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle, font })) => (text, at, height, angle, font),
_ => return Err(bad(cmd, format!("`{l}` is not text"))),
};
let src = text_source(p, cmd, Some(old))?;
let LinkSource::Text { text, at, height, angle } = &src else { return Err(bad(cmd, "text")) };
let geom = solidcraft_sketch::text_geometry(text, *at, *height, *angle)?;
let LinkSource::Text { text, at, height, angle, font } = &src else { return Err(bad(cmd, "text")) };
let geom = solidcraft_sketch::text_geometry_in(font.as_deref(), text, *at, *height, *angle)?;
sk.set_link_source(&l, src.clone());
sk.update_link(&l, &geom)?;
let curves = ids_of(&sk, &sk.link_curves(&l));
+36
View File
@@ -79,6 +79,7 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
by_component.entry(doc.model_body_component(body)).or_default().push(body);
}
let mut chosen = None;
let mut sheet_face: Option<(String, Plane)> = None;
let mut work = state.bodies.len().saturating_add(doc.occurrences.len());
for (component, path, placement) in placements {
let Some(bodies) = by_component.get(&component) else {
@@ -104,6 +105,22 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
let inverse = mat_inverse(&frame).ok_or_else(|| bad(cmd, "invalid selected face frame"))?;
let local_point = apply_point(&inverse, point);
let tolerance = (body.body.size() * 1e-4).max(1e-5);
// A flat face of a sheet metal body attaches to its panel: the exact plane comes from
// the panel's flat region and fold, and the sketch keeps the face's name to follow it.
if !fixed
&& !internal
&& let Some(sheet) = state.sheets.iter().find(|sheet| sheet.body == body.name)
&& let Some((face, plane)) = sheet.panel_face_at(local_point, tolerance * 10.0)
{
if chosen.is_some() || sheet_face.is_some() {
return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index"));
}
let relative = mat_mul(&author_inverse, &frame);
let plane = Plane::new(apply_point(&relative, plane.origin), apply_vector(&relative, plane.x), apply_vector(&relative, plane.y))
.ok_or_else(|| bad(cmd, "invalid sheet face plane"))?;
sheet_face = Some((solidcraft_doc::sheet::sheet_face_name(&body.name, &face), plane));
continue;
}
let faces = body.body.faces(tolerance)?;
let mesh = body.body.tessellate(tolerance)?;
work = work.saturating_add(mesh.triangles.len().saturating_mul(if requested_index.is_some() { 1 } else { faces.len() }));
@@ -138,6 +155,12 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
}
}
}
if let Some((name, plane)) = sheet_face {
if chosen.is_some() {
return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index"));
}
return Ok(PlaneRef::Face { plane, at: apply_point(&author_inverse, point), name: Some(name), anchor: None });
}
let (body, index, path, plane) =
chosen.ok_or_else(|| bad(cmd, "the selected planar face no longer exists; choose another face or an explicit fixed plane"))?;
if fixed {
@@ -160,6 +183,19 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
}
None
}
Err(solidcraft_doc::DocError::Invalid(message))
if matches!(
message.as_str(),
"this face has no exact planar source; choose a fixed plane instead"
| "this face has no supported construction certificate; choose a fixed plane instead"
) =>
{
return Err(bad(
cmd,
"this face can't keep an attachment: it was merged from more than one feature's faces or reshaped by an edit. \
Pick another face, or use a fixed plane here (fixed:true), which won't follow later changes to the face",
));
}
Err(error) => return Err(bad(cmd, format!("{error}; choose a supported exact face or explicitly use fixed:true"))),
};
Ok(PlaneRef::Face { plane, at, name, anchor })
+4 -2
View File
@@ -75,8 +75,10 @@ fn explicit_rebind_preserves_geometry_dimensions_context_and_one_undo() {
#[test]
fn unsupported_face_has_explicit_fixed_alternative_and_no_live_projection() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[4,4,10],"operation":"join"}));
// A top half Offset Face raised: no construction certificate and no exact history name yet.
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Base"}));
run(&mut s, "solid.split_face", json!({"faces":[[10,15,20]],"plane":{"origin":[20,0,0],"normal":[1,0,0]}}));
run(&mut s, "solid.offset_face", json!({"faces":[[10,15,20]],"distance":5}));
let mut pick = top_pick(&s, "Base");
let before = s.doc.to_json();
let error = s.execute("sketch.create", &json!({"plane":pick})).unwrap_err();
+2 -1
View File
@@ -135,7 +135,8 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
if let Geom2::Text { text, at, height, angle } = t {
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
if super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, LinkSource::Text { text, at, height, angle }).is_ok() {
if super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, LinkSource::Text { text, at, height, angle, font: None }).is_ok()
{
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
n_text += 1;
@@ -0,0 +1,144 @@
//! Sketches on construction planes built from the model (perpendicular to a plane through a
//! sketch line, through three points, a midplane…): Project, Intersect, Include 3D Geometry and
//! Text resolve the sketch's plane against the model before the sketch, as evaluation does, and
//! the links follow when a parameter moves the plane.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// Sketch `A` on YZ with the line `s` from the origin to (run 100, rise `rise`), and the plane
/// `P` through that line, square to YZ. The plane's origin is the line's start and its x axis
/// runs along the line.
fn sloped_plane(s: &mut Session) {
run(s, "parameters.add", json!({"name": "rise", "expression": "60 mm"}));
run(s, "sketch.create", json!({"plane": "YZ", "name": "A"}));
run(s, "sketch.line", json!({"points": [[0, 0], [100, 60]], "ids": ["s"]}));
run(s, "sketch.dimension", json!({"entities": ["s.start", "s.end"], "type": "horizontal", "value": "100 mm"}));
run(s, "sketch.dimension", json!({"entities": ["s.start", "s.end"], "type": "vertical", "value": "rise"}));
run(s, "sketch.finish", json!({}));
run(s, "construct.plane.perpendicular", json!({"plane": "YZ", "line": {"sketch": "A", "curve": "s"}, "name": "P"}));
}
/// World position of point `point` of sketch `sketch`.
fn world_point(s: &Session, sketch: &str, point: &str) -> Vec3 {
let id = s.doc.find_feature(sketch).unwrap().id;
let st = s.model.state();
let ss = st.sketch(id).unwrap();
let i = ss.sketch.resolve_point(point).unwrap_or_else(|| panic!("no point {point} in {sketch}"));
ss.plane.to_world(ss.sketch.point(i).unwrap())
}
fn no_trouble(s: &Session) {
for r in &s.model.results {
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
}
}
#[test]
fn project_onto_a_plane_built_from_a_sketch_line_follows_the_parameter() {
let mut s = Session::default();
sloped_plane(&mut s);
run(&mut s, "sketch.create", json!({"plane": "P", "name": "B"}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "A", "point": "s.end"}, {"sketch": "A", "curve": "s"}]}));
let point = r["links"][0]["points"][0].as_str().unwrap().to_string();
let curve = r["links"][1]["curves"][0].as_str().unwrap().to_string();
// Geometry drawn against the projection keeps working: a circle on the projected point.
run(&mut s, "sketch.circle.center", json!({"center": point, "radius": 4}));
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
let len = (100.0f64 * 100.0 + 60.0 * 60.0).sqrt();
let end = world_point(&s, "A", "s.end");
assert!(world_point(&s, "B", &point).dist(end) < 1e-9, "{:?} {end:?}", world_point(&s, "B", &point));
let ends = [world_point(&s, "B", &format!("{curve}.start")), world_point(&s, "B", &format!("{curve}.end"))];
assert!(ends.iter().any(|q| q.dist(Vec3::ZERO) < 1e-9) && ends.iter().any(|q| q.dist(end) < 1e-9), "{ends:?}");
{
let st = s.model.state();
let b = st.sketch(s.doc.find_feature("B").unwrap().id).unwrap();
let q = b.sketch.point(b.sketch.resolve_point(&point).unwrap()).unwrap();
assert!((q.x - len).abs() < 1e-9 && q.y.abs() < 1e-9, "the line's end lies on the plane's x axis: {q:?}");
}
// The parameter tilts the plane; the projection and the circle on it follow, in the file too.
run(&mut s, "parameters.change", json!({"name": "rise", "expression": "25 mm"}));
let check = |s: &Session| {
no_trouble(s);
let end = world_point(s, "A", "s.end");
assert!((end.z - 25.0).abs() < 1e-9 && (end.y - 100.0).abs() < 1e-9, "{end:?}");
assert!(world_point(s, "B", &point).dist(end) < 1e-9);
assert!(world_point(s, "B", "c1.center").dist(end) < 1e-9);
};
check(&s);
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
// Editing the sketch again after the change refreshes its links on the moved plane.
run(&mut s, "sketch.edit", json!({"sketch": "B"}));
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
check(&s);
}
#[test]
fn text_on_a_plane_built_from_a_sketch_line() {
let mut s = Session::default();
sloped_plane(&mut s);
run(&mut s, "sketch.create", json!({"plane": "P", "name": "B"}));
let r = run(&mut s, "sketch.text", json!({"text": "AB", "at": [10, 10], "height": 5}));
assert!(!r["curves"].as_array().unwrap().is_empty(), "{r}");
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
let st = s.model.state();
let b = st.sketch(s.doc.find_feature("B").unwrap().id).unwrap();
assert!(!b.profiles.is_empty());
}
#[test]
fn intersect_and_include_on_planes_built_from_the_model() {
let mut s = Session::default();
sloped_plane(&mut s);
run(&mut s, "solid.box", json!({"corner": [-10, 20, -5], "length": 20, "width": 30, "height": 80, "body_name": "Post"}));
// The plane through three sketch points and the midplane of two faces are built from the model too.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "T"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 30]], "ids": ["u", "v"]}));
run(&mut s, "sketch.finish", json!({}));
run(
&mut s,
"construct.plane.three_points",
json!({"a": {"sketch": "T", "point": "u.start"}, "b": {"sketch": "T", "point": "u.end"}, "c": {"sketch": "T", "point": "v.end"}, "name": "Three"}),
);
run(&mut s, "construct.plane.midplane", json!({"a": {"face": [0, 20, 30]}, "b": {"face": [0, 50, 30]}, "name": "Mid"}));
run(&mut s, "sketch.create", json!({"plane": "P", "name": "B"}));
// The slope cuts the post: a closed section of four lines.
let r = run(&mut s, "sketch.intersect", json!({"refs": [{"body": "Post"}]}));
assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}");
// An edge of the post, brought onto the slope.
let r = run(&mut s, "sketch.include_3d", json!({"refs": [{"edge": [-10, 20, 30], "body": "Post"}]}));
assert!(r["links"][0]["link"].is_string(), "{r}");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "Three", "name": "C"}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "T", "point": "v.end"}]}));
let p = r["links"][0]["points"][0].as_str().unwrap().to_string();
run(&mut s, "sketch.finish", json!({}));
assert!(world_point(&s, "C", &p).dist(Vec3::new(40.0, 30.0, 0.0)) < 1e-9);
run(&mut s, "sketch.create", json!({"plane": "Mid", "name": "D"}));
let r = run(&mut s, "sketch.intersect", json!({"refs": [{"body": "Post"}]}));
assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}");
run(&mut s, "sketch.finish", json!({}));
no_trouble(&s);
// The midplane sits half way between the post's two faces (y = 35).
let st = s.model.state();
let d = st.sketch(s.doc.find_feature("D").unwrap().id).unwrap();
assert!(d.sketch.points.iter().any(|q| q.link.is_some()));
for q in d.sketch.points.iter().filter(|q| q.link.is_some()) {
assert!((d.plane.to_world(q.pos).y - 35.0).abs() < 1e-9);
}
}
@@ -429,6 +429,113 @@ fn text_makes_profiles_that_extrude_and_edit() {
assert!(s.execute("sketch.text", &json!({"text": "", "at": [0, 0]})).is_err());
}
/// Volumes (mm³) of the bodies in the design, smallest first.
fn body_volumes(s: &mut Session) -> Vec<f64> {
let m = run(s, "inspect.measure", json!({}));
let mut v: Vec<f64> = m["bodies"].as_array().unwrap().iter().map(|b| b["volume_mm3"].as_f64().unwrap()).collect();
v.sort_by(f64::total_cmp);
v
}
#[test]
fn text_extrudes_with_its_counters_and_beside_other_curves() {
// Letter areas in mm² at a capital height of 10 mm, summed from the Liberation Sans contours (shoelace
// formula, quadratic pieces flattened finely), each outer contour minus its counters:
// A 39.2159 - 9.0647 B 68.9283 - 12.9336 - 16.6277 8 56.8394 - 10.6451 - 13.7347 I 13.5557
// Extruded 2 mm, so the volumes are twice that. Faceted walls: within 0.02 mm³.
let near = |got: &[f64], want: &[f64]| {
assert_eq!(got.len(), want.len(), "{got:?} against {want:?}");
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.02), "{got:?} against {want:?}");
};
// Letters with counters, alone: a letter is one body with its counters as holes. (A counter used to be
// extruded as a body of its own, or the extrude failed with "profile has no area".)
for (text, want) in
[("AB", vec![2.0 * (39.2159 - 9.0647), 2.0 * (68.9283 - 12.9336 - 16.6277)]), ("8", vec![2.0 * (56.8394 - 10.6451 - 13.7347)])]
{
let mut s = new_sketch();
run(&mut s, "sketch.text", json!({"text": text, "at": [0, 0], "height": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2}));
near(&body_volumes(&mut s), &want);
}
// Text beside a drawn rectangle is extruded with it (it used to be left out): the 30 x 20 x 2 plate and an "I".
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}));
run(&mut s, "sketch.text", json!({"text": "I", "at": [40, 0], "height": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2}));
near(&body_volumes(&mut s), &[2.0 * 13.5557, 1200.0]);
// A counter can still be picked on its own, by a point inside it: the inner contour of an O, 47.4584 mm².
let mut s = new_sketch();
run(&mut s, "sketch.text", json!({"text": "O", "at": [0, 0], "height": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2, "profiles": [{"point": [5.65, 5.0]}]}));
near(&body_volumes(&mut s), &[2.0 * 47.4584]);
}
#[test]
fn text_embossed_or_debossed_leaves_its_counters_alone() {
// A 40 x 30 x 5 plate (6000 mm³) with 10 mm tall text raised from, or cut 1 mm into, its top. Only the letters
// change the volume, by their area in mm² (see above) x 1 mm; the counters of the O and of A and B stay as
// they are. (Emboss and deboss used to fail with "boolean Cut: no result" or "boolean Union: no result",
// because each counter was a tool body of its own, filling the hole of its letter.)
let (o, ab) = (83.3133 - 47.4584, (39.2159 - 9.0647) + (68.9283 - 12.9336 - 16.6277));
for (text, mode, change) in [("O", "deboss", -o), ("O", "emboss", o), ("AB", "deboss", -ab)] {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5, "body_name": "Plate"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Lid"}));
run(&mut s, "sketch.create", json!({"plane": "Lid", "name": "Text"}));
run(&mut s, "sketch.text", json!({"text": text, "at": [10, 10], "height": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch": "Text", "depth": 1, "mode": mode}));
let v = body_volumes(&mut s);
assert!(v.len() == 1 && (v[0] - (6000.0 + change)).abs() < 0.02, "{text} {mode}: {v:?} against {}", 6000.0 + change);
}
}
#[test]
fn text_takes_a_font_and_edit_text_keeps_or_changes_it() {
// The repository's second font is BIZ UDPGothic. Its "I" is a polygon of two 606 x 145 bars and a 188 x 1369
// stem in font units, with a capital height of 1567 units, so at 10 mm its area is
// (2 x 606 x 145 + 188 x 1369) x (10 / 1567)^2 = 17.6385 mm². The built-in Liberation Sans "I" is 13.5557 mm².
let font = concat!(env!("CARGO_MANIFEST_DIR"), "/../../apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf");
let near = |s: &Session, want: f64| {
let a = profiles(s);
assert!(a.len() == 1 && (a[0] - want).abs() < 1e-3, "{a:?} against {want}");
};
let mut s = new_sketch();
let r = run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10}));
near(&s, 13.5557);
let link = r["link"].as_str().unwrap().to_string();
// A font by file path changes the outlines; the same edit with the built-in family's name changes them back.
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
near(&s, 17.6385);
run(&mut s, "sketch.edit_text", json!({"link": link, "font": "Liberation Sans"}));
near(&s, 13.5557);
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
// An edit that does not name a font keeps it (the height scales the area by 4), and an empty name is the built-in font.
run(&mut s, "sketch.edit_text", json!({"link": link, "height": 20}));
near(&s, 4.0 * 17.6385);
run(&mut s, "sketch.edit_text", json!({"link": link, "font": ""}));
near(&s, 4.0 * 13.5557);
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
// The font is kept in the design: after saving and reopening, the text is still in it.
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
let source = reopened.doc.sketch(s.active_sketch.unwrap()).unwrap().link(&link).unwrap().source.clone();
assert!(matches!(&source, solidcraft_sketch::LinkSource::Text { font: Some(f), .. } if f == font), "{source:?}");
// A new text takes the font too. Unknown fonts and values that are not names are errors.
let mut s = new_sketch();
run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10, "font": font}));
near(&s, 17.6385);
let e = s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": "No Such Family 12345"})).unwrap_err().to_string();
assert!(e.contains("unknown font `No Such Family 12345`"), "{e}");
assert!(s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": 12})).is_err());
assert!(s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": "C:/no/such/dir/font.ttf"})).is_err());
near(&s, 17.6385);
}
#[test]
fn blend_curve_joins_tangentially() {
let mut s = new_sketch();
+59 -26
View File
@@ -118,15 +118,36 @@ fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
Err(bad(cmd, format!("cannot project {v}")))
}
/// The plane of a sketch feature.
fn sketch_plane(doc: &Document, id: u64) -> Result<Plane> {
/// The plane of a sketch feature, resolved against `st`: the model before the sketch as its
/// component sees it, which is what evaluation resolves it against. A construction plane built
/// from the model (through a sketch line, three points, between two faces…) exists only there.
fn sketch_plane(doc: &Document, id: u64, st: &solidcraft_doc::ModelState) -> Result<Plane> {
let (vals, _) = doc.param_values();
match doc.feature(id).map(|f| &f.kind) {
Some(FeatureKind::Sketch { plane, .. }) => Ok(doc.resolve_plane(&vals, plane, 0)?),
Some(FeatureKind::Sketch { plane, .. }) => Ok(doc.resolve_plane_in(&vals, plane, 0, Some(st))?),
_ => Err(EngineError::Other(format!("feature {id} is not a sketch"))),
}
}
/// The model before sketch `id` in the frame its links and its plane are resolved in: the
/// attached face's frame for a sketch on a face, else its component's.
fn state_for_sketch<'a>(
s: &Session,
doc: &Document,
id: u64,
st: &'a solidcraft_doc::ModelState,
) -> std::result::Result<std::borrow::Cow<'a, solidcraft_doc::ModelState>, solidcraft_doc::DocError> {
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
_ => None,
});
match attachment {
Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(doc, st, anchor, comp),
None => Ok(solidcraft_doc::state_in_frame_at(doc, st, comp, owner_frame(s, doc, comp))),
}
}
/// Keep candidate feature edits while overlaying uncommitted occurrence placements.
fn placed_candidate<'a>(s: &Session, doc: &'a Document) -> std::borrow::Cow<'a, Document> {
if s.pending_moves.is_empty() {
@@ -145,51 +166,54 @@ fn owner_frame(s: &Session, doc: &Document, comp: u64) -> solidcraft_doc::Mat {
if comp == s.active_component { s.active_component_frame() } else { doc.component_transform(comp) }
}
/// Re-resolve the links of a sketch being edited against the model before it, so edits see
/// current reference geometry. Returns warnings.
/// Re-resolve the links of a sketch being edited (`sk`, a copy of the stored sketch `id`)
/// against the model before it, so edits see current reference geometry. Returns warnings.
///
/// When a link moved, the rest of the sketch follows it as the evaluation solves it, keeping
/// the stored sides and turns, so the edit goes on from the sketch the model shows. Left where
/// it was stored beside a line that has moved past it, a dimensioned point would give the
/// edit's solve the other side to keep. A sketch that does not solve that way keeps its stored
/// geometry, with the links moved.
pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> Vec<String> {
if sk.links.is_empty() {
return Vec::new();
}
let doc = placed_candidate(s, doc);
let Ok(plane) = sketch_plane(&doc, id) else { return Vec::new() };
let (vals, _) = doc.param_values();
let st = s.model.state_before(id);
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
_ => None,
});
let st = match attachment {
Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp),
None => Ok(solidcraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))),
};
let Ok(st) = st else {
let Ok(st) = state_for_sketch(s, &doc, id, &st) else {
return vec!["lost face attachment; reattach explicitly".into()];
};
match attachment {
let Ok(plane) = sketch_plane(&doc, id, &st) else { return Vec::new() };
let warnings = match attachment {
Some(anchor) => solidcraft_doc::project::refresh_face_anchor_links(&doc, &vals, &st, &plane, sk, anchor),
None => solidcraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk),
};
if let Ok(stored) = doc.sketch(id)
&& sk.positions_in(stored).is_some()
{
let mut solved = sk.clone();
let follows = solved.refresh_offsets().is_ok()
&& doc.solve_sketch(&vals, &mut solved, &stored.driving_values(), Some(stored)).is_ok_and(|report| report.keeps_orientation());
if follows {
*sk = solved;
}
}
warnings
}
/// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id.
pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, kind: LinkKind, src: LinkSource) -> Result<String> {
let doc = placed_candidate(s, doc);
let plane = sketch_plane(&doc, id)?;
let (vals, _) = doc.param_values();
let st = s.model.state_before(id);
// The model as the sketch's component sees it.
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
_ => None,
});
let st = match attachment {
Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp),
None => Ok(solidcraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))),
};
let st = st?;
let st = state_for_sketch(s, &doc, id, &st)?;
let plane = sketch_plane(&doc, id, &st)?;
let r = resolve(&doc, &vals, &st, &plane, kind, &src)?;
let name = if let LinkSource::Face { body, at } = &r.source {
st.body(body).and_then(|body| {
@@ -454,7 +478,8 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
sk.points.get(pi).map(|q| q.id.clone()).unwrap_or_default()
} else if let Some(at) = edge {
let l = add_link(s, d, id, sk, LinkKind::Project, LinkSource::Edge { body, at }).map_err(|e| bad(cmd, format!("edge: {e}")))?;
let plane = sketch_plane(d, id)?;
let before = s.model.state_before(id);
let plane = sketch_plane(d, id, &*state_for_sketch(s, d, id, &before)?)?;
let q = plane.to_local(at);
let ci = sk.link_curves(&l).first().copied().ok_or_else(|| bad(cmd, "the edge projects to a point"))?;
let pi = sk.add_point(q, None)?;
@@ -490,10 +515,10 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
/// edits, but cannot provide the construction certificate required by public attachments.
pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result<String> {
let mut doc = (*s.doc).clone();
let plane = sketch_plane(&doc, id)?;
let raw = s.model.state_before(id);
let component = doc.feature(id).map(|feature| feature.component).unwrap_or(0);
let state = solidcraft_doc::state_in_frame_at(&doc, &raw, component, owner_frame(s, &doc, component));
let plane = sketch_plane(&doc, id, &state)?;
let resolved = solidcraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?;
let face_name = state.body(body).and_then(|body| solidcraft_doc::naming::face_names(body).get(index).cloned());
let mut sk = doc.sketch(id)?.clone();
@@ -538,3 +563,11 @@ pub(super) fn auto_project_face(s: &mut Session, id: u64, _at: Vec3) -> Result<O
#[cfg(test)]
#[path = "sketch_project_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "sketch_project_side_tests.rs"]
mod side_tests;
#[cfg(test)]
#[path = "sketch_model_plane_tests.rs"]
mod model_plane_tests;
@@ -0,0 +1,180 @@
//! A dimension from linked geometry keeps its side when the link's source moves: a point held
//! 5 from a projected line stays on the side it was drawn on wherever a parameter takes the
//! line, also past the place the point is stored at.
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// Sketch `A`: the horizontal line `rail`, 100 long, at the height the parameter `height`
/// (50) gives it.
fn rail_sketch(s: &mut Session) {
run(s, "parameters.add", json!({"name": "height", "expression": "50 mm"}));
run(s, "sketch.create", json!({"plane": "XY", "name": "A"}));
run(s, "sketch.line", json!({"points": [[0, 50], [100, 50]], "ids": ["rail"]}));
run(s, "sketch.constraint.horizontal_vertical", json!({"line": "rail", "mode": "horizontal"}));
run(s, "sketch.constraint.horizontal_vertical", json!({"points": ["origin", "rail.start"], "mode": "vertical"}));
run(s, "sketch.dimension", json!({"entities": ["origin", "rail.start"], "value": "height"}));
run(s, "sketch.dimension", json!({"entities": ["rail"], "value": "100 mm"}));
run(s, "sketch.finish", json!({}));
}
/// Sketch `S` (left open): `rail` brought in by `command`, and the point `p` at (50, `y`),
/// 50 from the origin horizontally. Returns the linked line.
fn point_by_the_rail(s: &mut Session, command: &str, y: f64) -> String {
rail_sketch(s);
run(s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(s, command, json!({"refs": [{"sketch": "A", "curve": "rail"}]}));
let rail = r["links"][0]["curves"][0].as_str().unwrap().to_string();
run(s, "sketch.point", json!({"point": [50, y], "id": "p"}));
run(s, "sketch.dimension", json!({"entities": ["origin", "p"], "type": "horizontal", "value": "50 mm"}));
rail
}
/// `p` held 5 from `line`: the dimension's parameter.
fn five_from(s: &mut Session, line: &str) -> String {
let r = run(s, "sketch.dimension", json!({"entities": ["p", line], "type": "distance", "value": "5 mm"}));
r["param"].as_str().unwrap().to_string()
}
fn set_height(s: &mut Session, height: f64) {
run(s, "parameters.change", json!({"name": "height", "expression": format!("{height} mm")}));
}
/// Where the model has `p` of sketch `S`.
fn p(s: &Session) -> Vec2 {
let st = s.model.state();
let sk = &st.sketch(s.doc.find_feature("S").unwrap().id).unwrap().sketch;
sk.point(sk.resolve_point("p").unwrap()).unwrap()
}
/// `p` is at (50, `y`), and the sketch has nothing to report.
#[track_caller]
fn assert_p(s: &Session, y: f64, when: &str) {
assert!(p(s).dist(Vec2::new(50.0, y)) < 1e-9, "{when}: {:?}", p(s));
let feature = s.model.result(s.doc.find_feature("S").unwrap().id).unwrap();
assert_eq!((&feature.error, &feature.warning), (&None, &None), "{when}");
}
/// A line of sketch `A` whose height a parameter drives, projected into sketch `S`, whose point
/// is dimensioned 5 from the projection.
#[test]
fn a_distance_from_a_projected_line_keeps_its_side_when_the_source_moves_past() {
let mut s = Session::default();
let rail = point_by_the_rail(&mut s, "sketch.project", 45.0);
five_from(&mut s, &rail);
run(&mut s, "sketch.finish", json!({}));
assert_p(&s, 45.0, "drawn");
// The source drops below the point's stored place: the point stays below the projection.
for height in [20.0, 50.0, 6.0, 400.0] {
set_height(&mut s, height);
assert_p(&s, height - 5.0, &format!("height {height}"));
}
// Editing the sketch afterwards refreshes the projection first; the side is the one from before.
set_height(&mut s, 20.0);
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
run(&mut s, "sketch.point", json!({"point": [80, 80]}));
run(&mut s, "sketch.finish", json!({}));
assert_p(&s, 15.0, "after an edit");
}
/// The same from the other side: the point above the line, the source rising past it and
/// coming back; and with the line brought in by Include 3D Geometry.
#[test]
fn a_point_above_the_line_stays_above_when_the_source_rises_past_and_returns() {
for command in ["sketch.project", "sketch.include_3d"] {
let mut s = Session::default();
let rail = point_by_the_rail(&mut s, command, 55.0);
five_from(&mut s, &rail);
run(&mut s, "sketch.finish", json!({}));
for height in [80.0, 400.0, 50.0, 6.0, 50.0] {
set_height(&mut s, height);
assert_p(&s, height + 5.0, &format!("{command}, height {height}"));
}
}
}
/// Model geometry in a sketch on XZ (whose y runs down the model's Z): the top of a box whose
/// height a parameter drives, as a projected edge, as an edge of a projected face and as a
/// section of the body. The point is 5 below the top, inside the box.
#[test]
fn a_distance_from_model_geometry_keeps_its_side_when_the_model_moves_past() {
// A projected edge is found again by the point picked on it, so it follows small steps
// only; a face (by its name) and a body follow any.
let small: &[f64] = &[42.0, 50.0, 58.0, 50.0, 44.0];
let large: &[f64] = &[20.0, 50.0, 6.0, 400.0];
let cases = [
("sketch.project", json!({"edge": [50, 0, 50]}), 0.0, small),
("sketch.project", json!({"face": [50, 0, 25]}), 0.0, large),
("sketch.intersect", json!({"body": "Body1"}), -20.0, large),
];
for (command, link, front, heights) in cases {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "height", "expression": "50 mm"}));
run(&mut s, "solid.box", json!({"corner": [0, front, 0], "length": 100, "width": 40, "height": "height"}));
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "S"}));
run(&mut s, command, json!({"refs": [link]}));
let curves = run(&mut s, "sketch.inspect", json!({}))["curves"].clone();
let high = |c: &Value, end: &str| (c[end][1].as_f64().unwrap() + 50.0).abs() < 1e-6;
let top = curves.as_array().unwrap().iter().find(|c| high(c, "start_at") && high(c, "end_at")).unwrap()["id"].as_str().unwrap().to_string();
run(&mut s, "sketch.point", json!({"point": [50, -45], "id": "p"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p"], "type": "horizontal", "value": "50 mm"}));
five_from(&mut s, &top);
run(&mut s, "sketch.finish", json!({}));
// (An edge found again by its picked point says so in a warning.)
for height in heights {
set_height(&mut s, *height);
assert!(p(&s).dist(Vec2::new(50.0, 5.0 - height)) < 1e-9, "{command} {link}, height {height}: {:?}", p(&s));
}
}
}
/// Another projection added after the source moved stores the sketch without solving it: the
/// sketch it stores is the one the model shows.
#[test]
fn a_projection_added_after_the_source_moved_leaves_the_side() {
let mut s = Session::default();
let rail = point_by_the_rail(&mut s, "sketch.project", 45.0);
five_from(&mut s, &rail);
run(&mut s, "sketch.finish", json!({}));
set_height(&mut s, 20.0);
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
run(&mut s, "sketch.project", json!({"refs": ["origin"]}));
run(&mut s, "sketch.finish", json!({}));
assert_p(&s, 15.0, "after the projection");
set_height(&mut s, 50.0);
assert_p(&s, 45.0, "back at 50");
}
/// The side is still the user's to choose, by where the point is when the dimension is added:
/// also when the source has moved past the point since it was drawn. That side is kept from
/// then on, and through an edit of the dimension's value.
#[test]
fn a_dimension_added_after_the_source_moved_takes_the_side_its_point_is_on() {
let mut s = Session::default();
let rail = point_by_the_rail(&mut s, "sketch.project", 45.0);
run(&mut s, "sketch.finish", json!({}));
// Drawn below the line; the line drops below it.
set_height(&mut s, 20.0);
assert_p(&s, 45.0, "no distance yet");
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
let distance = five_from(&mut s, &rail);
run(&mut s, "sketch.finish", json!({}));
assert_p(&s, 25.0, "dimensioned above the line");
for height in [50.0, 6.0, 400.0, 20.0] {
set_height(&mut s, height);
assert_p(&s, height + 5.0, &format!("height {height}"));
}
run(&mut s, "parameters.change", json!({"name": distance, "expression": "8 mm"}));
assert_p(&s, 28.0, "8 from the line");
set_height(&mut s, 6.0);
assert_p(&s, 14.0, "8 from the line, height 6");
}
@@ -0,0 +1,86 @@
//! `solid.move` with `copy: true`: the original body stays where it was, a new body is
//! pushed at the transform, and an older design file (no `copy` key at all) still loads.
use super::*;
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap()
}
fn cube20(s: &mut Session, name: &str) {
run(s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "body_name": name}));
}
fn volume(body: &solidcraft_doc::ModelBody) -> f64 {
solidcraft_kernel::measure(&body.body).unwrap().volume
}
#[test]
fn copy_keeps_the_original_and_adds_a_transformed_body() {
let mut s = Session::default();
cube20(&mut s, "Cube");
run(&mut s, "solid.move", json!({"bodies": ["Cube"], "translate": [30, 0, 0], "copy": true}));
let state = s.model.state();
assert_eq!(state.bodies.len(), 2, "a copy move must add one body, not replace the original");
let original = state.body("Cube").unwrap();
let original_bounds = original.mesh().bounds();
assert!(original_bounds.min.dist(Vec3::ZERO) < 1e-6, "the original body must stay at its own position: {original_bounds:?}");
assert!(original_bounds.max.dist(Vec3::new(20.0, 20.0, 20.0)) < 1e-6, "{original_bounds:?}");
let copy = state.bodies.iter().find(|b| b.name != "Cube").unwrap();
let copy_bounds = copy.mesh().bounds();
assert!(copy_bounds.min.dist(Vec3::new(30.0, 0.0, 0.0)) < 1e-6, "the copy must land at the translated position: {copy_bounds:?}");
assert!(copy_bounds.max.dist(Vec3::new(50.0, 20.0, 20.0)) < 1e-6, "{copy_bounds:?}");
let original_volume = volume(original);
let copy_volume = volume(copy);
assert!((original_volume - 8000.0).abs() < 1e-3, "original volume: {original_volume}");
assert!((copy_volume - 8000.0).abs() < 1e-3, "copy volume: {copy_volume}");
assert!((original_volume - copy_volume).abs() < 1e-6, "the copy must have the same mass properties as the source");
let total: f64 = state.bodies.iter().map(volume).sum();
assert!((total - 16000.0).abs() < 1e-3, "total volume across both bodies: {total}");
}
#[test]
fn without_copy_the_move_still_replaces_the_body_in_place() {
let mut s = Session::default();
cube20(&mut s, "Cube");
run(&mut s, "solid.move", json!({"bodies": ["Cube"], "translate": [30, 0, 0]}));
let state = s.model.state();
assert_eq!(state.bodies.len(), 1, "a plain move must not create a second body");
let moved = state.body("Cube").unwrap();
let bounds = moved.mesh().bounds();
assert!(bounds.min.dist(Vec3::new(30.0, 0.0, 0.0)) < 1e-6, "{bounds:?}");
}
#[test]
fn an_older_design_with_no_copy_key_at_all_still_loads_and_moves_in_place() {
let mut s = Session::default();
cube20(&mut s, "Cube");
run(&mut s, "solid.move", json!({"bodies": ["Cube"], "translate": [5, 0, 0]}));
let saved = s.doc.to_json();
assert!(!saved.contains("\"copy\""), "a move without the flag must serialize with no `copy` key, as an older file would read: {saved}");
// Simulate a design saved before this feature existed: strip the key even if present, then
// reopen through the same loader a real design file goes through.
let mut old: Value = serde_json::from_str(&saved).unwrap();
let features = old.get_mut("features").and_then(Value::as_array_mut).expect("features array");
let mut touched = false;
for f in features.iter_mut() {
if f.get("type").and_then(Value::as_str) == Some("move")
&& let Some(obj) = f.as_object_mut()
{
obj.remove("copy");
touched = true;
}
}
assert!(touched, "the move feature must be present in the saved design");
let migrated = solidcraft_doc::Document::from_json(&old.to_string()).unwrap();
let reopened = Session::new(migrated);
let state = reopened.model.state();
assert_eq!(state.bodies.len(), 1, "a pre-copy design must still move the one body in place, never duplicate it");
let moved = state.body("Cube").unwrap();
assert!(moved.mesh().bounds().min.dist(Vec3::new(5.0, 0.0, 0.0)) < 1e-6);
}
+126
View File
@@ -0,0 +1,126 @@
//! Issue #489: a sketch or an offset plane on the top face of an extrude that was itself
//! sketched on a face. Sketching a profile over the whole face draws curves on top of the face's
//! projected boundary; the extrude's faces must still carry an exact, certified planar source.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use crate::Session;
use serde_json::{Value, json};
use solidcraft_doc::FaceAttachmentStatus;
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command} {params}: {e}"))
}
fn profile(s: &mut Session, circle: bool, full: bool) {
if circle {
run(s, "sketch.circle.center", json!({"center": [20, 20], "diameter": if full { 40 } else { 16 }}));
} else if full {
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
} else {
run(s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [30, 30]}));
}
}
/// A 40 mm base (square or round) 10 mm tall, then a second extrude 10 mm up from a sketch on
/// its top face (or on a plane at that height), joined or as a new body.
fn two_extrudes(circle: bool, full: bool, on_face: bool, operation: &str) -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
profile(&mut s, circle, true);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "First"}));
let plane = if on_face { json!({"face": [20, 20, 10]}) } else { json!("XY") };
let mut params = json!({"plane": plane, "name": "Upper"});
if !on_face {
params["offset"] = json!(10);
}
run(&mut s, "sketch.create", params);
profile(&mut s, circle, full);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": operation, "name": "Second"}));
s
}
fn healthy(s: &Session, sketch: &str) -> solidcraft_geom::Plane {
let id = s.doc.find_feature(sketch).map(|f| f.id).unwrap();
match s.placed_document().sketch_face_attachment(id, &s.model) {
FaceAttachmentStatus::Healthy { plane, .. } => plane,
other => panic!("{sketch}: {other:?}"),
}
}
fn no_errors(s: &Session) {
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
}
#[test]
fn sketch_and_offset_plane_on_the_top_face_of_a_face_sketched_extrude() {
for operation in ["join", "new"] {
for circle in [false, true] {
for full in [true, false] {
for on_face in [true, false] {
let case = format!("{operation} circle={circle} full={full} on_face={on_face}");
let mut s = two_extrudes(circle, full, on_face, operation);
let top = [20., 20., 20.];
s.execute("sketch.create", &json!({"plane": {"face": top}, "name": "Top"})).unwrap_or_else(|e| panic!("{case}: {e}"));
run(&mut s, "sketch.finish", json!({}));
let plane = healthy(&s, "Top");
assert!((plane.origin.z - 20.).abs() < 1e-9 && (plane.normal().z - 1.).abs() < 1e-9, "{case}: {plane:?}");
s.execute("construct.plane.offset", &json!({"base": {"face": top}, "offset": 5, "name": "Above"}))
.unwrap_or_else(|e| panic!("{case}: {e}"));
// The second extrude's flat side attaches too, also where a full-face Join
// merged it with the first extrude's side (#491): that face is known by its
// exact history name.
if !circle {
let side = if full { [40., 20., 15.] } else { [30., 20., 15.] };
s.execute("sketch.create", &json!({"plane": {"face": side}, "name": "Side"})).unwrap_or_else(|e| panic!("{case} side: {e}"));
assert!((healthy(&s, "Side").normal().x - 1.).abs() < 1e-9, "{case}");
run(&mut s, "sketch.finish", json!({}));
}
s.refresh();
no_errors(&s);
}
}
}
}
}
/// Three extrudes, each sketched over the whole top face of the one before; editing the first
/// one's height moves every later sketch with its face.
#[test]
fn three_stacked_extrudes_follow_an_edit_of_the_first() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "E1"}));
for (k, top) in [(2, 10.), (3, 20.)] {
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 20, top]}, "name": format!("S{k}")}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": "join", "name": format!("E{k}")}));
}
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 20, 30]}, "name": "S4"}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 20], "diameter": 10}));
run(&mut s, "sketch.finish", json!({}));
s.refresh();
no_errors(&s);
assert_eq!(s.model.state().bodies.len(), 1);
for (name, z) in [("S2", 10.), ("S3", 20.), ("S4", 30.)] {
assert!((healthy(&s, name).origin.z - z).abs() < 1e-9, "{name}");
}
run(&mut s, "timeline.edit", json!({"feature": "E1", "set": {"extent": {"distance": "25"}}}));
s.refresh();
no_errors(&s);
for (name, z) in [("S2", 25.), ("S3", 35.), ("S4", 45.)] {
assert!((healthy(&s, name).origin.z - z).abs() < 1e-9, "{name} after edit");
}
let volume: f64 = s.model.state().bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum();
assert!((volume - 40. * 40. * 45.).abs() < 1e-6, "{volume}");
// Saved and reopened, every attachment still resolves.
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
reopened.refresh();
no_errors(&reopened);
assert!((healthy(&reopened, "S4").origin.z - 45.).abs() < 1e-9);
}
+21 -2
View File
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", patch)
.at("SURFACE", "CREATE")
.icon("patch")
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges) and body?; name?, body_name?"),
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; name?, body_name?"),
CommandSpec::new("surface.stitch", "Stitch", stitch)
.at("SURFACE", "MODIFY")
.icon("stitch")
@@ -129,7 +129,8 @@ mod tests {
solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume
}
/// A rectangle patched, trimmed by a plane and thickened; a box's faces stitched back.
/// A rectangle patched, trimmed by a plane and thickened; a box's open top patched; a rim
/// that is not planar patched.
#[test]
fn patch_trim_thicken_and_stitch() {
let mut s = Session::default();
@@ -148,6 +149,24 @@ mod tests {
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cup"}));
run(&mut s, "surface.patch", json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup", "body_name": "Lid"}));
assert!(s.world_state().body("Lid").unwrap().body.is_surface());
// A rim that is not planar: a box with its top cut away by a sideways cylinder.
run(&mut s, "solid.box", json!({"length": 28, "width": 28, "height": 20, "corner": [-14, -14, 0], "body_name": "Saddle"}));
run(
&mut s,
"solid.cylinder",
json!({"radius": 40, "height": 120, "base": [0, -60, 50], "axis": "Y", "operation": "cut", "targets": ["Saddle"]}),
);
let z = |x: f64| 50.0 - (1600.0 - x * x).sqrt();
let rim = json!([[14, 0, z(14.0)], [0, 14, z(0.0)], [-14, 0, z(-14.0)], [0, -14, z(0.0)]]);
run(&mut s, "surface.patch", json!({"edges": rim, "body": "Saddle", "body_name": "Cover"}));
let st = s.world_state();
let cover = &st.body("Cover").unwrap().body;
assert!(cover.is_surface() && cover.face_count() == 1);
// The cover is a smooth surface on the rim, close to the cylinder that cut it (its area
// within 1% of the cylinder's inside the rim).
let area = solidcraft_kernel::measure(cover).unwrap().area;
let want = 28.0 * 40.0 * 2.0 * (14.0f64 / 40.0).asin();
assert!((area - want).abs() < want * 1e-2, "{area} vs {want}");
assert!(s.execute("surface.thicken", &json!({"bodies": ["Nope"], "thickness": 1})).is_err());
assert!(s.execute("surface.trim", &json!({"body": "Sheet"})).is_err());
}
+153
View File
@@ -0,0 +1,153 @@
//! What the application draws of a large sheet with small bends and holes, and that what
//! reads the same mesh (bounds, picks, inside tests) answers as before.
use std::f64::consts::{FRAC_PI_2, TAU};
use serde_json::{Value, json};
use solidcraft_geom::{Mesh, Vec3};
use solidcraft_kernel::{DISPLAY_ANGLE, FaceSurface, face_surface};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
const HOLES: [[f64; 2]; 4] = [[30.0, 20.0], [1500.0, 20.0], [2970.0, 980.0], [30.0, 980.0]];
/// A 3000 x 1000 x 2 sheet with bend radius 1, four holes of diameter 4 and a 40 mm flange on
/// each long side.
fn large_sheet() -> Session {
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [3000, 1000]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Holes"}));
for c in HOLES {
run(&mut s, "sketch.circle.center", json!({"center": c, "diameter": 4}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Holes", "distance": 10, "through_all": true, "operation": "cut"}));
run(&mut s, "sheet.flange", json!({"edges": [[1500, 0, 2], [1500, 1000, 2]], "height": 40}));
s
}
/// The widest angle between neighbouring lines of vertices around a cylinder face, and how
/// many such steps the face has.
fn steps_around(m: &Mesh, face: usize, origin: Vec3, axis: Vec3) -> (usize, f64) {
let u = axis.any_perp();
let v = axis.cross(u);
let mut a: Vec<f64> = m
.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, f)| **f as usize == face)
.flat_map(|(t, _)| t.iter())
.map(|k| m.positions[*k as usize] - origin)
.map(|d| d.dot(v).atan2(d.dot(u)))
.collect();
a.sort_by(f64::total_cmp);
a.dedup_by(|x, y| (*x - *y).abs() < 1e-7);
// Leave out the widest gap: the part of the turn the face does not cover.
let n = a.len();
let mut gaps: Vec<f64> = (0..n).map(|i| (a[(i + 1) % n] - a[i]).rem_euclid(TAU)).collect();
gaps.sort_by(f64::total_cmp);
gaps.pop();
(gaps.len(), gaps.last().copied().unwrap_or(0.0))
}
#[test]
fn a_large_sheet_shows_its_small_bends_and_holes_round() {
let s = large_sheet();
let st = s.world_state();
let b = &st.bodies[0];
let m = b.mesh();
assert!(b.body.size() * 1e-3 > 3.0, "the chord tolerance is more than any radius here");
let (mut bends, mut walls) = (0, 0);
for face in 0..b.body.face_count() {
let Some(FaceSurface::Cylinder { origin, axis, radius }) = face_surface(&b.body, face) else { continue };
let (steps, widest) = steps_around(&m, face, origin, axis);
assert!(widest <= DISPLAY_ANGLE * 1.001, "radius {radius}: a facet spans {} degrees", widest.to_degrees());
if (radius - 2.0).abs() < 1e-6 {
// Half of a hole's wall.
walls += 1;
assert!(steps >= (TAU / 2.0 / DISPLAY_ANGLE).ceil() as usize, "{steps} facets around half a hole");
} else {
// A bend's inner (1 mm) or outer (3 mm) side: a quarter turn.
bends += 1;
assert!((radius - 1.0).abs() < 1e-6 || (radius - 3.0).abs() < 1e-6, "radius {radius}");
assert!(steps >= (FRAC_PI_2 / DISPLAY_ANGLE).ceil() as usize, "radius {radius}: {steps} facets across the bend");
}
}
assert_eq!((bends, walls), (4, 8));
// The edges the viewport draws: every hole's rim is round, on both sides of the sheet.
for [x, y] in HOLES {
for z in [0.0, 2.0] {
let on = |p: &Vec3| (p.z - z).abs() < 1e-6 && ((p.x - x).hypot(p.y - y) - 2.0).abs() < 1e-6;
let rim: Vec<&Vec<Vec3>> = m.edges.iter().filter(|e| e.iter().all(on)).collect();
let segments: usize = rim.iter().map(|e| e.len() - 1).sum();
assert!(segments >= (TAU / DISPLAY_ANGLE).ceil() as usize, "{segments} segments around the rim at {x}, {y}, {z}");
let widest = rim.iter().flat_map(|e| e.windows(2)).map(|w| 2.0 * (w[0].dist(w[1]) / 4.0).asin()).fold(0.0, f64::max);
assert!(widest <= DISPLAY_ANGLE * 1.001, "a rim segment spans {} degrees", widest.to_degrees());
}
}
// And the scene the renderers take draws those same polylines.
let scene = crate::view::scene(&s, &crate::view::home_camera(&s));
let drawn: usize =
scene.lines.iter().filter(|l| l.points.iter().all(|p| ((p.x - 30.0).hypot(p.y - 20.0) - 2.0).abs() < 1e-6)).map(|l| l.points.len() - 1).sum();
assert!(drawn >= 2 * (TAU / DISPLAY_ANGLE).ceil() as usize, "{drawn} rim segments in the scene");
}
#[test]
fn what_reads_the_display_mesh_of_a_large_sheet_answers_as_before() {
let s = large_sheet();
let st = s.world_state();
let b = &st.bodies[0];
let m = b.mesh();
let coarse = b.body.display_mesh((b.body.size() * 1e-3).max(1e-3)).unwrap();
// Bounds: the sheet's box is set by flat faces, so the rounder bends move nothing.
let (now, before) = (m.bounds(), coarse.bounds());
assert!(now.min.dist(before.min) < 1e-9 && now.max.dist(before.max) < 1e-9, "{now:?} {before:?}");
let exact = solidcraft_kernel::measure(&b.body).unwrap().bbox;
assert!(now.min.dist(exact.min) < 1e-6 && now.max.dist(exact.max) < 1e-6, "{now:?} {exact:?}");
// The cost: the sheet takes 2212 triangles against the chord mesh's 92 when measured, a factor of
// 24; what grows is the number of round faces and the facets across each, not the size of the body.
assert!(
m.triangles.len() <= 30 * coarse.triangles.len() && m.triangles.len() < 4000,
"{} triangles, {} at the chord tolerance",
m.triangles.len(),
coarse.triangles.len()
);
// The same faces and edges, in the same order, between the same faces, with the same ends.
assert_eq!(m.edge_faces, coarse.edge_faces);
assert_eq!(m.tri_face.iter().max(), coarse.tri_face.iter().max());
assert_eq!(m.edges.len(), coarse.edges.len());
for (fine, rough) in m.edges.iter().zip(&coarse.edges) {
assert!(fine[0].dist(rough[0]) < 1e-9 && fine[fine.len() - 1].dist(rough[rough.len() - 1]) < 1e-9);
}
// Picks: a point on a round face (on the true cylinder, which a flat strip across a bend
// misses by up to 0.9 mm) is found on that face, as is one in the middle of a flat face.
let mut round = 0;
for face in 0..b.body.face_count() {
let Some(FaceSurface::Cylinder { origin, axis, radius }) = face_surface(&b.body, face) else { continue };
let corners: Vec<Vec3> =
m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == face).flat_map(|(t, _)| t.map(|k| m.positions[k as usize])).collect();
let middle = corners.iter().fold(Vec3::ZERO, |a, p| a + *p) / corners.len() as f64;
// The middle of the face's corners, pushed out onto the cylinder.
let along = origin + axis * (middle - origin).dot(axis);
let p = along + (middle - along).normalized().unwrap() * radius;
assert_eq!(solidcraft_doc::appearance::face_index_at(b, p), Some(face), "radius {radius} at {p:?}");
round += 1;
}
assert_eq!(round, 12);
let top = solidcraft_doc::appearance::face_index_at(b, Vec3::new(1500.0, 500.0, 2.0)).unwrap();
assert!(matches!(face_surface(&b.body, top), Some(FaceSurface::Plane { normal, .. }) if normal.dist(Vec3::Z) < 1e-9));
// Inside and outside: in the sheet, in a flange's wall, in a hole and above the sheet.
assert!(m.contains(Vec3::new(1500.0, 500.0, 1.0)) && m.contains(Vec3::new(1500.0, now.min.y + 1.0, now.max.z - 10.0)));
assert!(!m.contains(Vec3::new(30.0, 20.0, 1.0)) && !m.contains(Vec3::new(1500.0, 500.0, 3.0)));
}
+34
View File
@@ -252,3 +252,37 @@ fn root_sketch_on_a_moved_components_face() {
let pts = sketch_world_points(&mut s, "Top");
assert!(pts.iter().any(|p| near(*p, Vec3::new(-30.0, 40.0, 25.0))), "{pts:?}");
}
/// (#558) With the root active, construction geometry, a hole and Press Pull picked on a moved
/// component land on it where it is shown, and so do picks on a pasted instance.
#[test]
fn root_picks_on_a_moved_component_build_where_it_is_shown() {
let mut s = moved_part();
run(&mut s, "component.activate", json!({"component": "root"}));
// The block spans world x 50…80, y 0…20, z 0…10.
run(&mut s, "construct.axis.edge", json!({"edge": {"edge": [65, 0, 0]}, "name": "FrontEdge"}));
run(
&mut s,
"construct.plane.three_points",
json!({"a": {"vertex": [50, 0, 10]}, "b": {"vertex": [80, 0, 10]}, "c": {"vertex": [50, 20, 10]}, "name": "Lid"}),
);
let lid = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Lid").unwrap().2;
assert!((lid.origin.z - 10.0).abs() < 1e-6 && lid.normal().z.abs() > 1.0 - 1e-9, "{lid:?}");
let st = s.model.state();
let axis = st.construct.iter().find(|c| c.name == "FrontEdge").map(|c| c.geom.clone()).unwrap();
let solidcraft_doc::construct::ConstructGeom::Axis { origin, dir } = axis else { panic!("{axis:?}") };
assert!(origin.y.abs() < 1e-6 && origin.z.abs() < 1e-6 && dir.x.abs() > 1.0 - 1e-9, "{origin:?} {dir:?}");
let v0 = volume(&mut s);
run(&mut s, "solid.hole", json!({"position": [60, 10, 10], "diameter": 4, "depth": 5}));
let v1 = volume(&mut s);
assert!(v1 < v0 - 1.0, "the hole cut the moved block: {v0} {v1}");
run(&mut s, "solid.press_pull", json!({"face": [80, 10, 5], "distance": 2}));
assert!(volume(&mut s) > v1 + 1.0, "press pull grew the moved block");
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
// A pasted instance 40 mm further along y.
run(&mut s, "occurrence.copy", json!({"component": "Part", "translate": [0, 40, 0]}));
run(&mut s, "construct.plane.along_path", json!({"path": {"edge": [65, 40, 0]}, "t": 0.5, "name": "Across"}));
let across = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Across").unwrap().2;
assert!((across.origin.y - 40.0).abs() < 1e-6, "{across:?}");
}
+89
View File
@@ -0,0 +1,89 @@
//! A hole placed on a face follows that face along the hole's axis, never sideways: when the face
//! moves the hole moves with it, and when the face no longer reaches the place the hole stays
//! where it was put and says so instead of sliding to the face's nearest point.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn change(s: &mut Session, name: &str, expression: &str) {
run(s, "parameters.change", json!({"name": name, "expression": expression}));
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
}
/// Whether the model holds material at (x, y, z).
fn solid_at(s: &Session, x: f64, y: f64, z: f64) -> bool {
s.model.state().bodies.iter().any(|b| b.body.contains(Vec3::new(x, y, z)))
}
/// What the hole feature says (its error and its warning); empty when it is silent.
fn said(s: &Session, feature: &str) -> String {
let id = s.doc.find_feature(feature).unwrap().id;
let r = s.model.result(id).unwrap();
format!("{}{}", r.error.clone().unwrap_or_default(), r.warning.clone().unwrap_or_default())
}
#[test]
fn a_hole_the_face_no_longer_reaches_stays_where_it_was_placed_and_says_so() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "length", "expression": "100 mm"}));
run(&mut s, "solid.box", json!({"length": "length", "width": 50, "height": 20, "name": "Plate"}));
run(&mut s, "solid.hole", json!({"position": [80, 25, 20], "diameter": "6 mm", "name": "Hole"}));
// The bore is at x = 80, 3 mm either way.
let drilled_at_80 = |s: &Session| !solid_at(s, 80.0, 25.0, 10.0) && solid_at(s, 76.0, 25.0, 10.0) && solid_at(s, 84.0, 25.0, 10.0);
assert!(drilled_at_80(&s));
// Longer: the place is still on the top face, and the hole stays at x = 80.
change(&mut s, "length", "150 mm");
assert_eq!(said(&s, "Hole"), "");
assert!(drilled_at_80(&s));
// Shorter than the place: nothing is drilled at the new end, and the hole says why. A hole
// moved to the face's nearest point would cut half a hole there (59 717 mm³ left).
change(&mut s, "length", "60 mm");
let left = volume(&mut s);
assert!((left - 60_000.0).abs() < 1.0, "half a hole was cut at the plate's end: {left} mm³ of 60000");
let msg = said(&s, "Hole");
assert!(msg.contains("no longer on face") && msg.contains("[80, 25, 20]"), "{msg}");
// Long again: the hole is back where it was put, with no message.
change(&mut s, "length", "100 mm");
assert_eq!(said(&s, "Hole"), "");
assert!(drilled_at_80(&s));
}
#[test]
fn a_slanted_hole_stays_on_its_axis_when_its_face_moves() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "height", "expression": "20 mm"}));
run(&mut s, "solid.box", json!({"length": 60, "width": 30, "height": "height", "name": "Block"}));
// Entering the top face at x = 20, running on in x as it goes down: 1 across for every 2 down.
run(&mut s, "solid.hole", json!({"position": [20, 15, 20], "direction": [1, 0, -2], "diameter": "2 mm", "name": "Hole"}));
// The axis is the line through the place along the direction, whatever the height of the block:
// x = 20 + (20 - z) / 2. At mid-height the bore is empty on it and solid 3 mm to either side
// (the bore is about 2.2 mm wide there).
let on_axis = |s: &Session, h: f64| {
let (x, z) = (20.0 + (20.0 - h / 2.0) / 2.0, h / 2.0);
assert!(!solid_at(s, x, 15.0, z), "block height {h}: the bore should pass through x = {x} at z = {z}");
assert!(solid_at(s, x - 3.0, 15.0, z) && solid_at(s, x + 3.0, 15.0, z), "block height {h}: material 3 mm beside the axis");
// (The kernel may note a boolean retry for a slanted bore; the hole's place is on its face.)
assert!(!said(s, "Hole").contains("no longer on face"), "block height {h}: {}", said(s, "Hole"));
};
on_axis(&s, 20.0);
// Taller: the top face moves up. Moved to the face's nearest point the hole would slide
// sideways onto another axis (5 mm further along x at mid-height); along its axis it enters
// higher up the same line.
change(&mut s, "height", "30 mm");
on_axis(&s, 30.0);
// Lower: the same line enters further along.
change(&mut s, "height", "12 mm");
on_axis(&s, 12.0);
}
+34 -20
View File
@@ -27,6 +27,7 @@ use solidcraft_geom::Vec3;
pub use cmd::{CommandInfo, CommandSpec, auto_operation, command_specs, find_command};
pub use solidcraft_doc as doc;
pub use solidcraft_doc::Visibility;
pub use solidcraft_geom as geom;
pub use solidcraft_io as io;
pub use solidcraft_kernel as kernel;
@@ -217,8 +218,15 @@ pub struct Session {
pub section: Option<(Vec3, Vec3)>,
/// Surface analysis shading the model (zebra, draft, curvature map).
pub analysis: Option<SurfaceAnalysis>,
/// What is hidden or shown in the view (`browser.visibility`).
/// What is hidden or shown in the view (`browser.visibility`): saved with the design, not an
/// undo step. A design brings its saved visibility when it opens (`Session::new`).
pub visibility: Visibility,
/// The camera `view.apply` asked the view to use (view state, not saved). The desktop app
/// moves its viewport there and takes it; headless renders keep using it until a standard
/// view is chosen.
pub camera: Option<render::Camera>,
/// The visibility the design file holds (what `is_dirty` compares with).
saved_visibility: Visibility,
/// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of
/// commands are undone however long the undo history is.
pub(crate) checkpoints: Vec<Checkpoint>,
@@ -252,22 +260,6 @@ pub(crate) struct Checkpoint {
pub redo: Vec<Snapshot>,
}
/// Items hidden or shown one by one (view state, not part of the design and not an undo step).
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Visibility {
/// Bodies by name.
pub hidden_bodies: Vec<String>,
/// Exact occurrence placements hidden with all descendants.
pub hidden_occurrences: Vec<Vec<u64>>,
/// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction geometry, by name.
pub hidden_origin: Vec<String>,
/// Sketches by feature id.
pub hidden_sketches: Vec<u64>,
/// Finished sketches shown although a feature uses them.
pub shown_sketches: Vec<u64>,
}
/// A way of shading the model's faces to judge their shape (view state, not part of the design).
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -297,7 +289,9 @@ impl Default for Session {
}
impl Session {
pub fn new(doc: Document) -> Self {
pub fn new(mut doc: Document) -> Self {
// The session keeps what is hidden (not an undo step); the design file carries it.
let visibility = std::mem::take(&mut doc.visibility);
let doc = Arc::new(doc);
let mut model = Model::new();
model.evaluate(&doc);
@@ -324,18 +318,32 @@ impl Session {
analysis: None,
checkpoints: Vec::new(),
next_checkpoint: 1,
visibility: Visibility::default(),
saved_visibility: visibility.clone(),
visibility,
camera: None,
#[cfg(test)]
evaluations: 0,
crash_journal: false,
}
}
/// The design has changes not in its file: in the document, or in what is hidden.
pub fn is_dirty(&self) -> bool {
!Arc::ptr_eq(&self.doc, &self.saved) && *self.doc != *self.saved
(!Arc::ptr_eq(&self.doc, &self.saved) && *self.doc != *self.saved) || self.visibility != self.saved_visibility
}
pub fn mark_saved(&mut self) {
self.saved = self.doc.clone();
self.saved_visibility = self.visibility.clone();
}
/// The design as a file holds it: the document with what is hidden in it.
pub fn design(&self) -> std::borrow::Cow<'_, Document> {
if self.visibility.is_empty() {
return std::borrow::Cow::Borrowed(&*self.doc);
}
let mut doc = (*self.doc).clone();
doc.visibility = self.visibility.clone();
std::borrow::Cow::Owned(doc)
}
/// The design has changes not in its file (a recovered design).
pub fn mark_unsaved(&mut self) {
@@ -667,6 +675,8 @@ impl Session {
checkpoints: Vec::new(),
next_checkpoint: 1,
visibility: self.visibility.clone(),
camera: self.camera,
saved_visibility: self.saved_visibility.clone(),
#[cfg(test)]
evaluations: 0,
crash_journal: false,
@@ -793,8 +803,12 @@ mod construct_tests;
#[cfg(test)]
mod delete_face_tests;
#[cfg(test)]
mod display_mesh_tests;
#[cfg(test)]
mod extent_tests;
#[cfg(test)]
mod hole_face_tests;
#[cfg(test)]
mod naming_history_tests;
#[cfg(test)]
mod pattern_suppress_tests;
+12 -8
View File
@@ -35,10 +35,13 @@ pub fn default_dir() -> Option<PathBuf> {
env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share")))?
};
let (dir, legacy) = dirs_under(&root, cfg!(windows) || cfg!(target_os = "macos"));
// Compatibility shim (docs/compatibility.md): autosaves made before the rename are copied
// to the new folder on first launch; the old folder is left as it was.
if let Err(e) = solidcraft_io::adopt_legacy_dir(&dir, &legacy) {
log::warn!("recovery: could not copy {} to {}: {e}", legacy.display(), dir.display());
// Compatibility shim (docs/compatibility.md): autosaves made before the rename (or, in the
// Flatpak sandbox, under the old app id) are copied to the new folder on first launch; the
// old folder is left as it was.
for old in solidcraft_io::legacy_flatpak_path(&dir).into_iter().chain([legacy]) {
if let Err(e) = solidcraft_io::adopt_legacy_dir(&dir, &old) {
log::warn!("recovery: could not copy {} to {}: {e}", old.display(), dir.display());
}
}
Some(dir)
}
@@ -143,7 +146,8 @@ pub struct Autosaver {
/// Autosave this often while there are unsaved changes.
pub interval: Duration,
lock: Option<File>,
last: Option<Arc<Document>>,
/// The design and what was hidden at the last autosave.
last: Option<(Arc<Document>, crate::Visibility)>,
last_at: Instant,
has_entry: bool,
}
@@ -189,17 +193,17 @@ impl Autosaver {
crate::crash::rebased(s, s.path.as_deref().map(Path::new), false);
return Ok(false);
}
if self.last.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.doc) || **l == *s.doc) {
if self.last.as_ref().is_some_and(|(l, v)| (Arc::ptr_eq(l, &s.doc) || **l == *s.doc) && *v == s.visibility) {
return Ok(false);
}
let io = |e: std::io::Error| EngineError::Other(format!("autosave: {e}"));
let bytes = solidcraft_io::write_design_container(&s.doc)?;
let bytes = solidcraft_io::write_design_container(&s.design())?;
solidcraft_io::write_atomic(&file(&self.dir, &self.id, solidcraft_io::DESIGN_EXT), &bytes, false).map_err(io)?;
let entry =
Entry { id: self.id.clone(), path: s.path.clone(), name: s.doc.name.clone(), saved_at: unix_now(), features: s.doc.features.len() };
let meta = serde_json::to_vec_pretty(&entry).map_err(|e| EngineError::Other(e.to_string()))?;
solidcraft_io::write_atomic(&file(&self.dir, &self.id, "json"), &meta, false).map_err(io)?;
self.last = Some(s.doc.clone());
self.last = Some((s.doc.clone(), s.visibility.clone()));
self.has_entry = true;
// Crash replays start from this autosave from here.
crate::crash::rebased(s, Some(&file(&self.dir, &self.id, solidcraft_io::DESIGN_EXT)), true);
+218 -17
View File
@@ -96,6 +96,20 @@ fn join_extrude_from_a_chamfered_top_face() {
}
}
#[test]
fn a_body_cut_in_pieces_twice_gets_pieces_of_their_own_names() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 5, "height": 5, "body_name": "Bar"}));
for x in [10, 25] {
run(&mut s, "solid.box", json!({"corner": [x, -1, -1], "length": 2, "width": 7, "height": 7, "body_name": "Gap"}));
run(&mut s, "solid.combine", json!({"target": "Bar", "tools": ["Gap"], "operation": "cut"}));
}
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
let unique: std::collections::BTreeSet<&String> = names.iter().collect();
assert_eq!(unique.len(), 3, "{names:?}");
assert!(names.contains(&"Bar (2)".to_string()), "{names:?}");
}
/// A join that bridges two bodies makes them one body, not an overlap of the first and the second.
#[test]
fn join_bridging_two_bodies_merges_them() {
@@ -331,6 +345,83 @@ fn revolve_and_primitives() {
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), PI * 125.0 * 5.0 / 2.0) < 5e-4);
}
/// A spherical shell (a half ring between two arcs, both radial lines on the axis) revolved on a
/// sketch plane that does not pass through the origin: the body is the shell, as it is on XY.
/// Before, built in place, it measured a third too large (truck tessellates the degenerate faces
/// on the axis reliably only with the axis through the origin).
#[test]
fn revolve_a_spherical_shell_off_the_origin() {
for (plane, z) in [(json!("XY"), 0.0), (json!({"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}), 10.0)] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": plane, "name": "Ring"}));
run(&mut s, "sketch.line", json!({"points": [[-40, 0], [40, 0]]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [40, 0], "end": [-40, 0]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [18.5, 0], "end": [-18.5, 0]}));
run(&mut s, "sketch.finish", json!({}));
// The half ring: the larger of the two regions above the axis.
let si = run(&mut s, "sketch.inspect", json!({"sketch": "Ring"}));
let ring = si["profiles"]
.as_array()
.unwrap()
.iter()
.max_by(|a, b| a["area_mm2"].as_f64().unwrap().total_cmp(&b["area_mm2"].as_f64().unwrap()))
.unwrap()["index"]
.clone();
run(&mut s, "solid.revolve", json!({"sketch": "Ring", "profiles": [ring], "axis": "l1", "body_name": "Shell"}));
let m = run(&mut s, "inspect.measure", json!({"bodies": ["Shell"]}));
let want = 4.0 / 3.0 * PI * (40.0f64.powi(3) - 18.5f64.powi(3));
let v = m["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(v, want) < 5e-4, "plane at z {z}: {v} vs {want}");
let b = &m["bodies"][0]["bbox"];
assert!((b["min"][2].as_f64().unwrap() - (z - 40.0)).abs() < 1e-6 && (b["max"][2].as_f64().unwrap() - (z + 40.0)).abs() < 1e-6, "{b}");
}
}
#[test]
fn extrude_of_adjacent_profiles_has_whole_edges() {
// A holder from two regions of one sketch (a disc and the ring around it, both cut off by a
// chord): the chord used to stay split where the inner circle met it, leaving vertices right
// on the surface of a handle of that radius, and cutting the handle out of the holder failed.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Holder"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 40}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 35}));
let x = (40.0f64 * 40.0 - 33.0 * 33.0).sqrt();
run(&mut s, "sketch.line", json!({"points": [[-x, -33], [x, -33]]}));
run(&mut s, "sketch.finish", json!({}));
let si = run(&mut s, "sketch.inspect", json!({"sketch": "Holder"}));
let below: Vec<Value> = si["profiles"]
.as_array()
.unwrap()
.iter()
.filter(|p| (500.0..4900.0).contains(&p["area_mm2"].as_f64().unwrap()))
.map(|p| p["index"].clone())
.collect();
assert_eq!(below.len(), 2, "{si}");
run(&mut s, "solid.extrude", json!({"sketch": "Holder", "profiles": below, "distance": 23, "body_name": "Holder"}));
let topo = run(&mut s, "model.faces", json!({"body": "Holder"}));
assert_eq!(topo["faces"].as_array().unwrap().len(), 5, "a cut-off cylinder: {topo}");
let lo = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["bodies"][0]["bbox"]["min"][1].as_f64().unwrap();
run(&mut s, "solid.move", json!({"bodies": ["Holder"], "translate": [0, -5.0 - lo, 0]}));
// The handle: r35 on the Y axis with a bevelled end.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Handle"}));
for pts in [
[[0.0, 0.0], [0.0, 230.0]],
[[0.0, 0.0], [25.0, 0.0]],
[[25.0, 0.0], [35.0, 11.5]],
[[35.0, 11.5], [35.0, 230.0]],
[[35.0, 230.0], [0.0, 230.0]],
] {
run(&mut s, "sketch.line", json!({ "points": pts }));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.revolve", json!({"sketch": "Handle", "profiles": [0], "axis": "Y", "body_name": "Handle"}));
let before = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["total"]["volume_mm3"].as_f64().unwrap();
run(&mut s, "solid.combine", json!({"target": "Holder", "tools": ["Handle"], "operation": "cut", "keep_tools": true}));
let after = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(after > 1000.0 && after < before * 0.5, "{before} -> {after}");
}
#[test]
fn sketch_tools_and_constraints() {
let mut s = Session::default();
@@ -739,12 +830,16 @@ fn step_open_then_model_on_it() {
run(&mut s, "solid.hole", json!({"position": at(28.0, 15.0, 20.0), "diameter": 6}));
want -= PI * 9.0 * 20.0;
assert!(rel(volume(&mut s), want) < 5e-4, "{} vs {want}", volume(&mut s));
// Modified imported faces no longer have an immutable construction certificate.
// The public attachment refuses them; the user can explicitly choose a fixed plane.
let before_fixed = s.doc.to_json();
assert!(s.execute("sketch.create", &json!({"plane":{"face":at(10.0,15.0,20.0)}})).is_err());
assert_eq!(s.doc.to_json(), before_fixed);
run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0), "fixed": true}}));
// Modified imported faces no longer have an immutable construction certificate; the top
// attaches by its exact history name instead.
let top = run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0)}}))["sketch"].as_u64().unwrap();
assert!(matches!(
s.placed_document().sketch_face_attachment(top, &s.model),
doc::FaceAttachmentStatus::Healthy {
target: doc::config::ConfigFaceTarget { certificate: Some(doc::config::FaceCertificate::Lineage), .. },
..
}
));
let c = [x0 + 10.0, y0 + 15.0];
// Sketch coordinates on the face are relative to the model origin projected onto it.
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [c[0] - 4.0, c[1] - 4.0], "p1": [c[0] + 4.0, c[1] + 4.0]}));
@@ -1882,7 +1977,8 @@ mod naming {
let mut legacy = Session::new(solidcraft_doc::Document::from_json(&old.to_json()).unwrap());
assert_eq!(attached(&legacy), Some(10.));
assert!((top(&mut legacy) - 15.).abs() < 1e-6);
// The upstream join consumes the certified body; new attachments cannot follow its substitute roof.
// An upstream join that trims the certified face: the attachment stays on what is left
// of it and never follows the substitute roof (see face_tests for a face covered whole).
for target in [&mut s, &mut legacy] {
run(target, "timeline.roll_to", json!({"feature": "Block"}));
run(target, "sketch.create", json!({"plane": "XY", "name": "B"}));
@@ -1903,19 +1999,14 @@ mod naming {
};
assert!(anchor.is_none(), "opening old face references must not infer a strict attachment");
let failed = |s: &Session, name: &str| s.model.results.iter().find(|r| r.id == s.doc.find_feature(name).unwrap().id).unwrap().error.is_some();
assert!(failed(&s, "Top"));
assert!(failed(&s, "Peg"), "lost sketch cannot supply downstream geometry");
assert_eq!(attached(&s), None);
assert!(!failed(&s, "Top"));
assert!(!failed(&s, "Peg"));
assert_eq!(attached(&s), Some(10.), "the strip of Block's top left beside the boss is still that face");
assert!((top(&mut s) - 20.0).abs() < 1e-6, "{}", top(&mut s));
run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true}));
assert!(!failed(&s, "Top"));
assert!(!failed(&s, "Peg"));
assert_eq!(attached(&s), Some(10.));
assert!((top(&mut s) - 15.).abs() < 1e-6);
run(&mut s, "edit.undo", json!({}));
assert!(failed(&s, "Top"));
assert!(failed(&s, "Peg"));
assert_eq!(attached(&s), None);
}
#[test]
@@ -2719,7 +2810,6 @@ fn a_pattern_repeats_a_fillet_on_the_edges_it_found() {
/// A copy added by raising the count after a rename takes the next number, not the freed one (#372).
#[test]
#[ignore = "fails until #372 is fixed"]
fn raising_a_pattern_count_after_a_rename_numbers_the_new_copy_after_the_others() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
@@ -2752,7 +2842,6 @@ fn a_profile_added_later_is_named_after_body_name() {
/// The reply names the bodies the feature created, apart from the ones already there (#374).
#[test]
#[ignore = "fails until #374 is fixed"]
fn extrude_reply_lists_the_bodies_it_created() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [0, 50, 0], "operation": "new_body", "body_name": "Base"}));
@@ -2820,6 +2909,33 @@ fn fillet_fit_reduce_uses_the_largest_radius_that_fits() {
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "reduce", "type": "chord"})).is_err());
}
#[test]
fn fillet_fit_reduce_keeps_the_radius_where_it_fits() {
// A 2 mm fin on a block: its top edge can't take 4, a corner of the block far from it can.
// Only the fin's edge is reduced; the reply gives each edge's radius.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.box", json!({"length": 2, "width": 30, "height": 10, "corner": [19, 0, 20], "operation": "join"}));
assert_eq!(s.model.state().bodies.len(), 1);
let edges = json!([[21, 15, 30], [40, 0, 10]]);
let out = run(&mut s, "solid.fillet", json!({"edges": edges, "radius": 4, "fit": "reduce"}));
let fin = out["radius_applied"].as_f64().unwrap();
assert!(fin < 4.0, "{out}");
let each: Vec<(Vec<f64>, f64)> = out["radii_applied"]
.as_array()
.unwrap()
.iter()
.map(|x| (x["edge"].as_array().unwrap().iter().filter_map(Value::as_f64).collect(), x["radius"].as_f64().unwrap()))
.collect();
assert_eq!(each, [(vec![21.0, 15.0, 30.0], fin), (vec![40.0, 0.0, 10.0], 4.0)], "{out}");
let removed = (1.0 - PI / 4.0) * (fin * fin * 30.0 + 16.0 * 20.0);
let want = 40.0 * 30.0 * 20.0 + 2.0 * 30.0 * 10.0 - removed;
assert!(rel(volume(&mut s), want) < 2e-4, "{} vs {want}", volume(&mut s));
let id = out["feature"].as_u64().unwrap();
let warning = s.model.result(id).and_then(|r| r.warning.clone()).unwrap_or_default();
assert!(warning.contains("on 1 of 2 edges"), "{warning}");
}
#[test]
fn a_pattern_of_a_reduced_fillet_rounds_each_copy_alike() {
// #293: a 6 mm wide post on a plate, its four vertical edges at 3.5 reduced to 3, patterned
@@ -2833,3 +2949,88 @@ fn a_pattern_of_a_reduced_fillet_rounds_each_copy_alike() {
let expect = 60.0 * 20.0 * 2.0 + 3.0 * (840.0 - (4.0 - PI) * 9.0 * 10.0);
assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s));
}
/// A taper on a profile of lines and an arc (#546). The D profile (back 9.2, sides 7.4, a
/// half-round front R 4.6 tangent to them) grows by d = z · tan 5° with mitred corners:
/// A(d) = A₀ + P·d + (2 + π/2)·d², integrated over the 10 mm height.
#[test]
fn tapered_extrude_of_a_profile_mixing_lines_and_an_arc() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "D"}));
run(&mut s, "sketch.line", json!({"points": [[0, 4.6], [-7.4, 4.6], [-7.4, -4.6], [0, -4.6]], "closed": false}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [0, -4.6], "end": [0, 4.6]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "D", "distance": 10, "taper": "5 deg"}));
let (t, h) = (5f64.to_radians().tan(), 10.0);
let (a0, p, k) = (7.4 * 9.2 + PI * 4.6 * 4.6 / 2.0, 2.0 * 7.4 + 9.2 + PI * 4.6, 2.0 + PI / 2.0);
let expect = a0 * h + p * t * h * h / 2.0 + k * t * t * h.powi(3) / 3.0;
assert!(rel(volume(&mut s), expect) < 1e-3, "{} vs {expect}", volume(&mut s));
}
/// A feature that joins into a body creates none, and its reply says so; a primitive's reply
/// names the body it made (#374).
#[test]
fn feature_replies_list_only_the_bodies_they_created() {
let mut s = Session::default();
let r = run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Base"}));
assert_eq!(r["created_bodies"], json!(["Base"]), "{r}");
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Boss"}));
run(&mut s, "sketch.circle.center", json!({"center": [5, 5], "radius": 2}));
run(&mut s, "sketch.finish", json!({}));
let r = run(&mut s, "solid.extrude", json!({"sketch": "Boss", "distance": "15", "operation": "join"}));
assert_eq!(r["created_bodies"], json!([]), "{r}");
assert_eq!(r["bodies"], json!(["Base"]), "{r}");
}
/// A feature reply also says which existing bodies it changed and which are gone, so a client
/// that skips the full body list (MCP `batch` with `verbose: false`, #530) still sees every effect.
#[test]
fn feature_replies_list_the_bodies_they_changed_and_removed() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new", "body_name": "B"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0], "operation": "new", "body_name": "C"}));
// A pocket in A changes A only.
let r = run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 10, "corner": [3, 3, 5], "operation": "cut", "targets": ["A"]}));
assert_eq!((&r["created_bodies"], &r["changed_bodies"], &r["removed_bodies"]), (&json!([]), &json!(["A"]), &json!([])), "{r}");
// Joining C into B changes B and removes C.
let r = run(&mut s, "solid.combine", json!({"target": "B", "tools": ["C"], "operation": "join"}));
assert_eq!((&r["changed_bodies"], &r["removed_bodies"]), (&json!(["B"]), &json!(["C"])), "{r}");
// A new body changes nothing else.
let r = run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [0, 50, 0], "operation": "new", "body_name": "D"}));
assert_eq!((&r["created_bodies"], &r["changed_bodies"], &r["removed_bodies"]), (&json!(["D"]), &json!([]), &json!([])), "{r}");
}
/// Drafting a cylinder's side face 5° from the base plane makes a cone frustum (#547): radius 10
/// at the base, 10 − 20 · tan 5° at the top.
#[test]
fn draft_on_a_cylinder_side_makes_a_frustum() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 20}));
run(&mut s, "solid.draft", json!({"faces": [[10, 0, 10]], "angle": "5 deg", "neutral": "XY"}));
let (big, h) = (10.0, 20.0);
let small = big - h * 5f64.to_radians().tan();
let expect = PI * h / 3.0 * (big * big + big * small + small * small);
assert!(rel(volume(&mut s), expect) < 1e-3, "{} vs {expect}", volume(&mut s));
}
/// The copies added after a rename keep their numbers through undo/redo, save/reopen and a
/// further count change; lowering the count drops the last copies (#372).
#[test]
fn pattern_copies_added_after_a_rename_keep_their_numbers() {
let mut s = Session::default();
let names = |s: &Session| -> Vec<String> { s.model.state().bodies.iter().map(|b| b.name.clone()).collect() };
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20, "name": "Pat"}));
run(&mut s, "body.rename", json!({"body": "X (2)", "name": "Second"}));
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "5"}}}));
assert_eq!(names(&s), ["X", "Second", "X (3)", "X (4)", "X (5)"]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&s), ["X", "Second", "X (3)"]);
run(&mut s, "edit.redo", json!({}));
assert_eq!(names(&s), ["X", "Second", "X (3)", "X (4)", "X (5)"]);
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(names(&reopened), ["X", "Second", "X (3)", "X (4)", "X (5)"]);
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "2"}}}));
assert_eq!(names(&s), ["X", "Second"]);
}
+35
View File
@@ -67,3 +67,38 @@ fn modelled_internal_threads_m3_to_m20() {
assert!((got - want).abs() < want * 0.03, "M{d}: removed {got}, ISO profile {want}");
}
}
/// Issue 602: rounding the edges of a hex boss on a threaded shaft froze the app. The thread's
/// thousands of facets were healed pair by pair, once per feature after it, and the fillet's
/// preview then compared each of them with the whole old body.
#[test]
fn fillet_beside_a_modelled_thread_finishes() {
let mut s = Session::default();
for (id, p) in [
("sketch.create", json!({"plane": "XY"})),
("sketch.circle.center", json!({"center": [0, 0], "diameter": 5})),
("sketch.finish", json!({})),
("solid.extrude", json!({"distance": 10, "body_name": "Screw"})),
("sketch.create", json!({"plane": {"face": [0, 0, 10]}})),
("sketch.polygon.circumscribed", json!({"center": [0, 0], "radius": 2, "sides": 6})),
("sketch.finish", json!({})),
("solid.extrude", json!({"distance": 3, "profiles": [{"point": [0, 0]}], "operation": "join", "targets": ["Screw"]})),
("solid.thread", json!({"face": [2.5, 0, 5], "designation": "M5", "length": 4, "modeled": true})),
] {
run(&mut s, id, p);
}
let faces_before = s.model.state().body("Screw").map(|b| b.body.face_count()).unwrap_or(0);
assert!(faces_before > 1000, "the thread is faceted: {faces_before} faces");
let volume_before = volume(&mut s, "Screw");
let top: Vec<Value> = (0..6)
.map(|i| {
let a = (30.0 + 60.0 * i as f64).to_radians();
json!([2.0 * a.cos(), 2.0 * a.sin(), 13])
})
.collect();
let started = std::time::Instant::now();
run(&mut s, "solid.fillet", json!({"edges": top, "radius": 0.3}));
assert!(started.elapsed().as_secs() < 30, "the fillet took {:?}", started.elapsed());
let volume_after = volume(&mut s, "Screw");
assert!(volume_after < volume_before && volume_after > volume_before * 0.99, "{volume_before} -> {volume_after}");
}
+1 -1
View File
@@ -25,7 +25,7 @@ fn sampler_keeps_style_precedence_hidden_references_text_and_disjoint_parts() {
let text = sk
.add_link(
LinkKind::Text,
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0. },
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0., font: None },
&[LinkGeom::Line(Vec2::new(0., 7.), Vec2::new(2., 7.))],
)
.unwrap();
+409
View File
@@ -0,0 +1,409 @@
//! SolidCraft-owned portable B-rep data, independent of a modelling backend.
//! Millimetres, radians, right-handed Z-up. See docs/native-brep.md for semantics.
//! Validation checks structure and resource budgets, not geometric equivalence or trust.
use serde::{Deserialize, Serialize};
pub mod envelope;
pub const VERSION: u32 = 2;
pub const MAX_ITEMS: usize = 100_000;
const MAX_DEPTH: usize = 16;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Brep {
pub version: u32,
pub shells: Vec<Shell>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Shell {
pub vertices: Vec<[f64; 3]>,
pub edges: Vec<Edge>,
pub faces: Vec<Face>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Edge {
pub vertices: [u32; 2],
pub curve: Curve,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Face {
pub loops: Vec<Vec<EdgeUse>>,
pub surface: Surface,
pub forward: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeUse {
pub edge: u32,
pub forward: bool,
}
/// Right-handed orthonormal frame; z = x_axis cross y_axis.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Frame {
pub origin: [f64; 3],
pub x_axis: [f64; 3],
pub y_axis: [f64; 3],
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Curve {
Line { start: [f64; 3], end: [f64; 3] },
CircleArc { frame: Frame, radius: f64, angle: [f64; 2] },
EllipseArc { frame: Frame, radii: [f64; 2], angle: [f64; 2] },
Polyline { points: Vec<[f64; 3]> },
Spline { knots: Vec<f64>, points: Vec<[f64; 3]> },
RationalSpline { knots: Vec<f64>, homogeneous: Vec<[f64; 4]> },
Intersection { knots: Vec<f64>, points: Vec<[f64; 3]>, fitting_tolerance: f64, faces: Option<[u32; 2]> },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Surface {
Plane { origin: [f64; 3], u_axis: [f64; 3], v_axis: [f64; 3] },
Cylinder { frame: Frame, radius: f64, angle: [f64; 2], height: [f64; 2] },
Cone { frame: Frame, radius_at_origin: f64, half_angle: f64, angle: [f64; 2], height: [f64; 2] },
Sphere { frame: Frame, radius: f64, longitude: [f64; 2], latitude: [f64; 2] },
Torus { frame: Frame, major_radius: f64, minor_radius: f64, longitude: [f64; 2], meridian: [f64; 2] },
Spline { u_knots: Vec<f64>, v_knots: Vec<f64>, points: Vec<Vec<[f64; 3]>> },
RationalSpline { u_knots: Vec<f64>, v_knots: Vec<f64>, homogeneous: Vec<Vec<[f64; 4]>> },
Revolution { profile: Box<Curve>, origin: [f64; 3], axis: [f64; 3], angle: [f64; 2] },
}
#[derive(Clone, Debug, thiserror::Error)]
#[error("portable B-rep: {0}")]
pub struct Invalid(pub &'static str);
type Result<T> = std::result::Result<T, Invalid>;
fn require(condition: bool, message: &'static str) -> Result<()> {
if condition { Ok(()) } else { Err(Invalid(message)) }
}
struct Budget(usize);
impl Budget {
fn add(&mut self, n: usize) -> Result<()> {
self.0 = self.0.checked_add(n).ok_or(Invalid("item budget"))?;
require(self.0 <= MAX_ITEMS, "item budget")
}
}
fn numbers(values: impl IntoIterator<Item = f64>) -> Result<()> {
require(values.into_iter().all(|v| v.is_finite() && v.abs() <= 1e12), "non-finite or excessive coordinate")
}
fn points<const N: usize>(values: &[[f64; N]], budget: &mut Budget) -> Result<()> {
budget.add(values.len())?;
numbers(values.iter().flatten().copied())
}
fn knots(values: &[f64], count: usize, budget: &mut Budget) -> Result<()> {
budget.add(values.len())?;
numbers(values.iter().copied())?;
let degree = values.len().checked_sub(count.saturating_add(1)).ok_or(Invalid("spline degree"))?;
require((2..=4096).contains(&count) && (1..=16).contains(&degree) && degree < count, "spline degree or size")?;
require(values.windows(2).all(|w| w.first() <= w.get(1)), "unordered knots")?;
require(values.get(degree) < values.get(count), "empty spline domain")?;
// No knot may have multiplicity greater than degree + 1.
let mut run = 1;
for pair in values.windows(2) {
run = if pair.first() == pair.get(1) { run + 1 } else { 1 };
require(run <= degree + 1, "knot multiplicity")?;
}
Ok(())
}
fn weights(values: &[[f64; 4]]) -> Result<()> {
require(values.iter().all(|p| p.get(3).is_some_and(|w| *w >= 1e-12)), "non-positive rational weight")
}
fn grid<const N: usize>(u: &[f64], v: &[f64], rows: &[Vec<[f64; N]>], budget: &mut Budget) -> Result<()> {
let columns = rows.first().map_or(0, Vec::len);
knots(u, rows.len(), budget)?;
knots(v, columns, budget)?;
for row in rows {
require(row.len() == columns, "ragged surface")?;
points(row, budget)?;
}
Ok(())
}
fn vector(p: [f64; 3]) -> crate::Vec3 {
crate::Vec3::new(p[0], p[1], p[2])
}
fn frame(f: &Frame, budget: &mut Budget) -> Result<()> {
points(&[f.origin, f.x_axis, f.y_axis], budget)?;
let (x, y) = (vector(f.x_axis), vector(f.y_axis));
require((x.len() - 1.).abs() <= 1e-8 && (y.len() - 1.).abs() <= 1e-8 && x.dot(y).abs() <= 1e-8, "nonorthonormal frame")
}
fn range(r: &[f64; 2]) -> Result<()> {
numbers(*r)?;
require(r[0] < r[1], "empty parameter range")
}
fn angle(r: &[f64; 2]) -> Result<()> {
range(r)?;
require(r[1] - r[0] <= std::f64::consts::TAU + 1e-12, "angular range exceeds one turn")
}
fn radius(r: f64) -> Result<()> {
require(r.is_finite() && (1e-12..=1e12).contains(&r), "radius")
}
fn curve(c: &Curve, depth: usize, budget: &mut Budget) -> Result<()> {
require(depth <= MAX_DEPTH, "geometry depth")?;
budget.add(1)?;
match c {
Curve::Line { start, end } => {
points(&[*start, *end], budget)?;
require(start != end, "collapsed line")
}
Curve::CircleArc { frame: f, radius: r, angle: a } => {
frame(f, budget)?;
radius(*r)?;
angle(a)
}
Curve::EllipseArc { frame: f, radii, angle: a } => {
frame(f, budget)?;
for r in radii {
radius(*r)?;
}
angle(a)
}
Curve::Polyline { points: p } => {
require(p.len() >= 2, "empty polyline")?;
points(p, budget)?;
require(p.windows(2).all(|p| p.first() != p.get(1)), "collapsed polyline segment")
}
Curve::Spline { knots: k, points: p } => {
knots(k, p.len(), budget)?;
points(p, budget)
}
Curve::RationalSpline { knots: k, homogeneous: p } => {
knots(k, p.len(), budget)?;
points(p, budget)?;
weights(p)
}
Curve::Intersection { knots: k, points: p, fitting_tolerance, .. } => {
require((1e-12..=1.0).contains(fitting_tolerance), "intersection fitting tolerance")?;
knots(k, p.len(), budget)?;
points(p, budget)
}
}
}
fn surface(s: &Surface, depth: usize, budget: &mut Budget) -> Result<()> {
require(depth <= MAX_DEPTH, "geometry depth")?;
budget.add(1)?;
match s {
Surface::Plane { origin, u_axis, v_axis } => {
points(&[*origin, *u_axis, *v_axis], budget)?;
require(vector(*u_axis).cross(vector(*v_axis)).len() > 1e-12, "degenerate plane")
}
Surface::Cylinder { frame: f, radius: r, angle: a, height } => {
frame(f, budget)?;
radius(*r)?;
angle(a)?;
range(height)
}
Surface::Cone { frame: f, radius_at_origin, half_angle, angle: a, height } => {
frame(f, budget)?;
numbers([*radius_at_origin, *half_angle])?;
require(*radius_at_origin >= 0. && *half_angle > 0. && *half_angle < std::f64::consts::FRAC_PI_2, "cone dimensions")?;
angle(a)?;
range(height)?;
require(height.iter().all(|h| radius_at_origin + h * half_angle.tan() >= 0.), "negative cone radius")
}
Surface::Sphere { frame: f, radius: r, longitude, latitude } => {
frame(f, budget)?;
radius(*r)?;
angle(longitude)?;
range(latitude)?;
require(latitude[0] >= -std::f64::consts::FRAC_PI_2 && latitude[1] <= std::f64::consts::FRAC_PI_2, "sphere latitude")
}
Surface::Torus { frame: f, major_radius, minor_radius, longitude, meridian } => {
frame(f, budget)?;
radius(*major_radius)?;
radius(*minor_radius)?;
require(major_radius > minor_radius, "ring torus radii")?;
angle(longitude)?;
angle(meridian)
}
Surface::Spline { u_knots, v_knots, points: p } => grid(u_knots, v_knots, p, budget),
Surface::RationalSpline { u_knots, v_knots, homogeneous: p } => {
grid(u_knots, v_knots, p, budget)?;
for row in p {
weights(row)?;
}
Ok(())
}
Surface::Revolution { profile, origin, axis, angle: a } => {
points(&[*origin, *axis], budget)?;
require((vector(*axis).len() - 1.0).abs() <= 1e-8, "revolution axis is not unit length")?;
angle(a)?;
curve(profile, depth + 1, budget)
}
}
}
impl Brep {
/// Validate portable structure without calling a kernel or evaluating geometry.
/// Does not certify watertightness, surface coincidence, or authoritative state.
pub fn validate(&self) -> Result<()> {
require(self.version == VERSION, "unsupported version")?;
require(!self.shells.is_empty() && self.shells.len() <= 1024, "shell count")?;
let mut budget = Budget(0);
for shell in &self.shells {
points(&shell.vertices, &mut budget)?;
budget.add(shell.edges.len())?;
budget.add(shell.faces.len())?;
require(!shell.faces.is_empty(), "empty shell")?;
for edge in &shell.edges {
require(edge.vertices.iter().all(|&v| (v as usize) < shell.vertices.len()), "edge vertices")?;
curve(&edge.curve, 0, &mut budget)?;
if let Curve::Intersection { faces: Some(hints), .. } = &edge.curve {
require(hints.iter().all(|&i| (i as usize) < shell.faces.len()), "intersection face hint")?;
}
}
for face in &shell.faces {
require(!face.loops.is_empty(), "face loops")?;
surface(&face.surface, 0, &mut budget)?;
for wire in &face.loops {
require(!wire.is_empty(), "empty wire")?;
budget.add(wire.len())?;
let mut first = None;
let mut last = None;
for use_ in wire {
let edge = shell.edges.get(use_.edge as usize).ok_or(Invalid("edge reference"))?;
let [a, b] = edge.vertices;
let (start, end) = if use_.forward { (a, b) } else { (b, a) };
require(last.is_none_or(|v| v == start), "disconnected wire")?;
first.get_or_insert(start);
last = Some(end);
}
require(first == last, "open wire")?;
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn triangle() -> Brep {
let vertices = vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]];
let edges = (0..3)
.map(|i| Edge { vertices: [i as u32, ((i + 1) % 3) as u32], curve: Curve::Line { start: vertices[i], end: vertices[(i + 1) % 3] } })
.collect();
Brep {
version: VERSION,
shells: vec![Shell {
vertices,
edges,
faces: vec![Face {
loops: vec![(0..3).map(|edge| EdgeUse { edge, forward: true }).collect()],
surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] },
forward: true,
}],
}],
}
}
#[test]
fn portable_open_face_round_trip_without_a_kernel() {
let body = triangle();
body.validate().unwrap();
let bytes = serde_json::to_vec(&body).unwrap();
let restored: Brep = serde_json::from_slice(&bytes).unwrap();
restored.validate().unwrap();
assert_eq!(body, restored);
assert!(!String::from_utf8(bytes).unwrap().contains("truck"));
}
#[test]
fn topology_references_and_wire_orientation_are_checked() {
let mut b = triangle();
b.shells[0].faces[0].loops[0][0].edge = u32::MAX;
assert!(b.validate().is_err());
let mut b = triangle();
b.shells[0].faces[0].loops[0][1].forward = false;
assert!(b.validate().is_err());
let mut b = triangle();
b.shells[0].edges[0].vertices[0] = u32::MAX;
assert!(b.validate().is_err());
let mut b = triangle();
b.version += 1;
assert!(b.validate().is_err());
let mut b = triangle();
b.shells[0].vertices[0][0] = f64::NAN;
assert!(b.validate().is_err());
}
#[test]
fn closed_edge_can_share_its_endpoint_vertex() {
let mut body = triangle();
body.shells[0].vertices.truncate(1);
body.shells[0].edges =
vec![Edge { vertices: [0, 0], curve: Curve::Polyline { points: vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 0.]] } }];
body.shells[0].faces[0].loops = vec![vec![EdgeUse { edge: 0, forward: true }]];
body.validate().unwrap();
}
#[test]
fn spline_domains_weights_and_nesting_are_bounded() {
let mut budget = Budget(0);
assert!(knots(&[0., 0., 1., 1.], 2, &mut budget).is_ok());
for bad in [vec![0., 0., 0., 0.], vec![0., 1., 0., 1.], vec![0., 0., f64::INFINITY, 1.]] {
assert!(knots(&bad, 2, &mut Budget(0)).is_err());
}
let c = Curve::RationalSpline { knots: vec![0., 0., 1., 1.], homogeneous: vec![[0., 0., 0., 0.], [1., 0., 0., 1.]] };
assert!(curve(&c, 0, &mut Budget(0)).is_err());
let p = Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [2., 0., 0.] };
assert!(surface(&p, 0, &mut Budget(0)).is_err());
assert!(curve(&Curve::Line { start: [0.; 3], end: [1.; 3] }, MAX_DEPTH + 1, &mut Budget(0)).is_err());
assert!(Budget(MAX_ITEMS).add(1).is_err());
}
#[test]
fn analytic_records_have_independent_frames_and_domains() {
use std::f64::consts::{FRAC_PI_2, TAU};
let f = Frame { origin: [1., 2., 3.], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] };
let surfaces = [
Surface::Cylinder { frame: f.clone(), radius: 2., angle: [0., TAU], height: [-1., 3.] },
Surface::Cone { frame: f.clone(), radius_at_origin: 2., half_angle: 0.2, angle: [0., TAU], height: [0., 3.] },
Surface::Sphere { frame: f.clone(), radius: 2., longitude: [0., TAU], latitude: [-FRAC_PI_2, FRAC_PI_2] },
Surface::Torus { frame: f.clone(), major_radius: 4., minor_radius: 1., longitude: [0., TAU], meridian: [0., TAU] },
Surface::Revolution {
profile: Box::new(Curve::Line { start: [2., 0., 0.], end: [2., 0., 3.] }),
origin: [0.; 3],
axis: [0., 0., 1.],
angle: [0., TAU],
},
];
for s in surfaces {
surface(&s, 0, &mut Budget(0)).unwrap();
let encoded = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<Surface>(&encoded).unwrap(), s);
assert!(!encoded.contains("columns") && !encoded.contains("forward"));
}
for c in [
Curve::CircleArc { frame: f.clone(), radius: 2., angle: [0., FRAC_PI_2] },
Curve::EllipseArc { frame: f.clone(), radii: [2., 3.], angle: [0., TAU] },
Curve::Intersection { knots: vec![0., 0., 1., 1.], points: vec![[0.; 3], [1.; 3]], fitting_tolerance: 1e-6, faces: None },
] {
curve(&c, 0, &mut Budget(0)).unwrap();
let encoded = serde_json::to_string(&c).unwrap();
assert_eq!(serde_json::from_str::<Curve>(&encoded).unwrap(), c);
assert!(!encoded.contains("leader") && !encoded.contains("surfaces"));
}
let mut bad = f;
bad.y_axis = bad.x_axis;
assert!(frame(&bad, &mut Budget(0)).is_err());
assert!(angle(&[0., TAU + 0.1]).is_err());
assert!(range(&[2., 1.]).is_err());
assert!(radius(f64::NAN).is_err());
let mut b = triangle();
b.shells[0].edges[0].curve =
Curve::Intersection { knots: vec![0., 0., 1., 1.], points: vec![[0.; 3], [1., 0., 0.]], fitting_tolerance: 1e-6, faces: Some([0, 1]) };
assert!(b.validate().is_err());
}
}
+118
View File
@@ -0,0 +1,118 @@
//! Stable version-one body metadata records. Runtime model types never define this wire contract.
use serde::{Deserialize, Serialize};
pub const VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Metadata {
pub version: u32,
pub color: Option<[f32; 3]>,
pub paint: Option<Paint>,
pub boundaries: Option<Vec<Vec<[f64; 3]>>>,
pub face_sources: Option<Vec<Option<FaceSource>>>,
pub history: Option<History>,
pub preserve_boundaries: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Plane {
pub origin: [f64; 3],
pub x_axis: [f64; 3],
pub y_axis: [f64; 3],
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Segment {
Line { start: [f64; 2], end: [f64; 2] },
Arc { center: [f64; 2], radius: f64, start: f64, sweep: f64 },
Cubic { points: [[f64; 2]; 4] },
Conic { points: [[f64; 2]; 3], middle_weight: f64 },
Bezier { points: Vec<[f64; 2]> },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Region {
pub outer: Vec<Segment>,
pub holes: Vec<Vec<Segment>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FaceSource {
ExtrusionSide { segment: Segment, plane: Option<Plane>, coalesced: bool },
CertifiedExtrusionSide { producer: u64, sketch: u64, curve: String, plane: Option<Plane> },
ExtrusionCap { high: bool, region: Region, plane: Plane },
CertifiedExtrusionCap { producer: u64, sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>>, plane: Plane },
CylinderCap { producer: u64, high: bool, plane: Plane },
BoxFace { producer: Option<u64>, axis: u8, high: bool, plane: Plane },
PlanarShell { producer: Option<u64>, source: Box<FaceSource>, inner: bool, plane: Plane },
Imported { item: u64, face: u64, payload_sha256: Option<String>, producer: Option<u64> },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Paint {
pub opacity: f32,
pub faces: Vec<FacePaint>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FacePaint {
pub face: u32,
pub color: [f32; 3],
pub opacity: f32,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct History {
pub operands: u32,
pub faces: Vec<FaceHistory>,
pub edges: Vec<EdgeHistory>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FaceHistory {
pub origins: Vec<FaceOrigin>,
pub change: Change,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeHistory {
pub faces: Vec<u32>,
pub origins: Vec<EdgeOrigin>,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FaceOrigin {
Face { operand: u32, face: u32 },
Profile { segment: u32 },
Cap { end: bool },
Edge { operand: u32, edge: u32 },
Offset { operand: u32, face: u32 },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum EdgeOrigin {
Edge { operand: u32, edge: u32 },
Meeting { sides: [Vec<FaceOrigin>; 2] },
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Change {
Kept,
Modified,
Split,
Merged,
Generated,
}
+111 -67
View File
@@ -154,6 +154,18 @@ pub fn check_knots(knots: &[f64]) -> SplineResult<()> {
/// A knot vector shorter than two knots gives zeros.
pub fn local_basis(knots: &[f64], u: f64, side: Side, n: usize) -> Vec<f64> {
let mut out = vec![0.0; n + 1];
let ders = local_basis_array(knots, u, side, n);
for (o, d) in out.iter_mut().zip(ders) {
*o = d;
}
out
}
/// [`local_basis`] into a fixed array (no allocation): element `k` is the `k`-th derivative,
/// for `k ≤ min(n, MAX_SPLINE_DEGREE)`, the rest zero.
pub(crate) fn local_basis_array(knots: &[f64], u: f64, side: Side, n: usize) -> [f64; MAX_SPLINE_DEGREE + 1] {
const W: usize = MAX_SPLINE_DEGREE + 1;
let mut out = [0.0; W];
let Some(p) = knots.len().checked_sub(2) else { return out };
if p > MAX_SPLINE_DEGREE || !u.is_finite() {
return out;
@@ -163,7 +175,6 @@ pub fn local_basis(knots: &[f64], u: f64, side: Side, n: usize) -> Vec<f64> {
// table[k][j]: k-th derivative of N_{j,q} for the current degree q (j = 0..=p−q), updated
// in place: k descending (level k reads level k−1 of the previous degree), j ascending
// (N_{j,q} reads N_{j+1,q−1}, not yet overwritten). Derivatives above p are zero.
const W: usize = MAX_SPLINE_DEGREE + 1;
let levels = n.min(p);
let mut table = [[0.0f64; W]; W];
for (j, slot) in table[0].iter_mut().enumerate().take(p + 1) {
@@ -286,15 +297,16 @@ pub fn find_span(knots: &[f64], p: usize, n: usize, u: f64) -> Option<usize> {
if u < lo || u > hi {
return None;
}
// The last non-empty span in [p, n−1] that starts at or before u.
let mut k = p;
for i in p..n {
if let (Some(&a), Some(&b)) = (knots.get(i), knots.get(i + 1))
&& a <= u
&& a < b
{
k = i;
}
// The last non-empty span in [p, n−1] that starts at or before u: by bisection (the knots
// are sorted), and at the domain end the last non-empty span.
let mut k = if u >= hi {
n - 1
} else {
let below = knots.get(p..n)?.partition_point(|&x| x <= u);
p + below.checked_sub(1)?
};
while k > p && !(knots.get(k)? < knots.get(k + 1)?) {
k -= 1;
}
Some(k)
}
@@ -331,16 +343,62 @@ pub fn insert_knot(p: usize, knots: &[f64], ctrl: &[HPoint], u: f64) -> SplineRe
Ok((nk, q))
}
/// The binomial coefficient `(n k)` (zero for `k > n`).
pub(crate) fn binomial(n: usize, k: usize) -> f64 {
if k > n {
return 0.0;
}
(0..k).fold(1.0, |c, m| c * (n - m) as f64 / (m + 1) as f64)
}
/// Bernstein polynomial `Bᵢ,ₚ(x)`.
pub fn bernstein(p: usize, i: usize, x: f64) -> f64 {
if i > p {
return 0.0;
}
let mut c = 1.0;
for m in 0..i {
c = c * (p - m) as f64 / (m + 1) as f64;
binomial(p, i) * x.powi(i as i32) * (1.0 - x).powi((p - i) as i32)
}
/// One row of a B-spline (degree `p`, knots, homogeneous control points) clamped on its domain
/// `[knots[p], knots[n]]`: each domain end inserted until it repeats `p` times (§5.2), then the
/// basis functions that vanish on the domain dropped (a basis function's last span does not
/// depend on its first knot, so that knot moves to the domain end). The curve on the domain is
/// unchanged.
pub(crate) fn clamp_row(p: usize, knots: &[f64], row: &[HPoint]) -> SplineResult<(Vec<f64>, Vec<HPoint>)> {
let n = row.len();
let (Some(&a), Some(&b)) = (knots.get(p), knots.get(n)) else { return invalid("too few knots") };
let (mut knots, mut row) = (knots.to_vec(), row.to_vec());
for (at_end, x) in [(false, a), (true, b)] {
let mult = |k: &[f64]| k.iter().filter(|&&y| y == x).count();
let mut guard = 0;
while mult(&knots) < p {
guard += 1;
if guard > MAX_SPLINE_DEGREE + 1 {
return invalid("clamping did not converge");
}
(knots, row) = insert_knot(p, &knots, &row, x)?;
}
let n = row.len();
let (first, last) = if at_end {
// Functions i with i ≥ (first index of x) start at or past the end.
(0, knots.iter().position(|&y| y == x).unwrap_or(n).min(n))
} else {
// Functions i with i + p + 1 ≤ (last index of x) end at or before the start.
(knots.iter().rposition(|&y| y == x).unwrap_or(p).saturating_sub(p), n)
};
if last <= first + p {
return invalid("too few control points remain after clamping");
}
let mut nk: Vec<f64> = knots.get(first..last + p + 1).map(<[f64]>::to_vec).unwrap_or_default();
if at_end {
nk.last_mut().into_iter().for_each(|y| *y = x);
} else {
nk.first_mut().into_iter().for_each(|y| *y = x);
}
row = row.get(first..last).map(<[HPoint]>::to_vec).unwrap_or_default();
knots = nk;
}
c * x.powi(i as i32) * (1.0 - x).powi((p - i) as i32)
Ok((knots, row))
}
/// A Bézier curve's point at `x ∈ [0, 1]` (de Casteljau, NURBS Book §1.4).
@@ -543,66 +601,37 @@ impl NurbsSurface {
}
/// The same surface on the same domain with clamped knot vectors (`p+1` equal knots at
/// each end, so the boundary curves are given by the boundary rows of control points):
/// insert each domain end until it has multiplicity `p`, then drop the basis functions
/// that vanish on the domain (NURBS Book §5.2 knot insertion; a basis function's last span
/// does not depend on its first knot, so that knot can be moved to the domain end).
/// each end, so the boundary curves are given by the boundary rows of control points): each
/// row of control points clamped in turn ([`clamp_row`]).
pub fn clamped(&self) -> SplineResult<NurbsSurface> {
let mut s = self.clone();
for dir in [Dir::S, Dir::T] {
let d = dir.pick(0, 1);
for d in 0..2 {
let p = s.degree[d];
for end in 0..2 {
let a = s.domain()[d][end];
while s.knots[d].iter().filter(|&&x| x == a).count() < p {
s = s.insert_knot(dir, a)?;
}
s = s.trim_end(d, end, a)?;
let [nu, nv] = s.count;
let (lines, len) = if d == 0 { (nv, nu) } else { (nu, nv) };
let mut knots = Vec::new();
let mut rows = Vec::with_capacity(lines);
for line in 0..lines {
let row: Vec<HPoint> = (0..len).map(|k| if d == 0 { s.point(k, line) } else { s.point(line, k) }).collect();
let (k, r) = clamp_row(p, &s.knots[d], &row)?;
knots = k;
rows.push(r);
}
let new_len = rows.first().map_or(0, Vec::len);
let mut ctrl = Vec::with_capacity(lines * new_len);
if d == 0 {
rows.iter().for_each(|r| ctrl.extend_from_slice(r));
} else {
for j in 0..new_len {
ctrl.extend(rows.iter().map(|r| r.get(j).copied().unwrap_or(HPoint::ZERO)));
}
}
let mut all = s.knots.clone();
all[d] = knots;
s = NurbsSurface::from_homogeneous(s.degree, all, ctrl)?;
}
Ok(s)
}
/// Drop the basis functions that vanish on the domain past the knot `a` (multiplicity ≥ p)
/// at the start (`end == 0`) or the end of direction `d`.
fn trim_end(&self, d: usize, end: usize, a: f64) -> SplineResult<NurbsSurface> {
let p = self.degree[d];
let kn = &self.knots[d];
let n = self.count[d];
let (first, last) = match end {
0 => {
let last_a = kn.iter().rposition(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
// Functions i with i + p + 1 ≤ last_a vanish on [a, …).
(last_a.saturating_sub(p), n)
}
_ => {
let first_a = kn.iter().position(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
// Functions i with i ≥ first_a vanish on (…, a].
(0, first_a.min(n))
}
};
if last <= first + p {
return invalid("clamp: too few control points remain");
}
let mut knots = self.knots.clone();
let mut nk: Vec<f64> = kn.get(first..last + p + 1).map(<[f64]>::to_vec).unwrap_or_default();
match end {
0 => nk.first_mut().into_iter().for_each(|x| *x = a),
_ => nk.last_mut().into_iter().for_each(|x| *x = a),
}
knots[d] = nk;
let [nu, nv] = self.count;
let mut ctrl = Vec::new();
for j in 0..nv {
for i in 0..nu {
let k = if d == 0 { i } else { j };
if k >= first && k < last {
ctrl.push(self.point(i, j));
}
}
}
NurbsSurface::from_homogeneous(self.degree, knots, ctrl)
}
}
#[cfg(test)]
@@ -666,6 +695,21 @@ pub(crate) mod tests {
assert!(worst < 1e-5, "derivative error {worst}");
}
#[test]
fn span_search_finds_the_last_non_empty_span_at_or_before_u() {
let p = 3;
let knots = [0.0, 0.0, 0.0, 0.0, 0.3, 0.3, 0.5, 0.9, 0.9, 0.9, 1.0, 1.0, 1.0, 1.0];
let n = knots.len() - p - 1;
for k in 0..=1000 {
let u = k as f64 / 1000.0;
// The plain search: the last span [knots[i], knots[i+1]) with knots[i] ≤ u.
let want = (p..n).filter(|&i| knots[i] <= u && knots[i] < knots[i + 1]).max().unwrap();
assert_eq!(find_span(&knots, p, n, u), Some(want), "u = {u}");
}
assert_eq!(find_span(&knots, p, n, 1.5), None);
assert_eq!(find_span(&knots, p, n, f64::NAN), None);
}
#[test]
fn single_function_knot_insertion_is_exact() {
let mut rng = Lcg(7);
+742
View File
@@ -0,0 +1,742 @@
//! Filling a closed loop of curves with one surface: a non-rational bicubic B-spline height
//! field over the loop's best-fit plane, fitted to the loop by least squares with a smoothness
//! term. A loop that cannot be seen as the boundary of a height field over that plane (it
//! crosses itself, folds back or runs along the plane's normal when seen along that normal) is
//! refused, never filled with wrong geometry.
//!
//! References: Piegl & Tiller, *The NURBS Book* (2nd ed.) — §9.4.1 and §9.4.3 (least squares
//! approximation: minimise the sum of squared residuals, eq. 9.62, through the normal
//! equations, eq. 9.65), §3.4 (tensor product B-spline surfaces), §3.3 (derivative of a
//! B-spline curve, eq. 3.4: control values at the knot averages reproduce a linear function),
//! A2.1 (span search).
use crate::Vec3;
use crate::bspline::{HPoint, NurbsSurface, Side, SplineError, SplineResult, find_span, invalid, local_basis_array};
/// Most loop points a fill takes.
pub const MAX_LOOP_POINTS: usize = 8192;
/// Most spans of the height field in each direction.
pub const MAX_FILL_SPANS: usize = 32;
/// Fewest spans of the height field in each direction (the first fit tried).
const MIN_FILL_SPANS: usize = 4;
/// The smoothness energy's terms: `(order in u, order in v, weight)` of `∫∫ (∂ h)²`.
const ENERGY: [(usize, usize, f64); 3] = [(2, 0, 1.0), (1, 1, 2.0), (0, 2, 1.0)];
/// Four-point Gauss–Legendre nodes and weights on `[−1, 1]` (exact to degree 7).
const GAUSS4: [(f64, f64); 4] = [
(-0.861_136_311_594_052_6, 0.347_854_845_137_453_8),
(-0.339_981_043_584_856_3, 0.652_145_154_862_546_2),
(0.339_981_043_584_856_3, 0.652_145_154_862_546_2),
(0.861_136_311_594_052_6, 0.347_854_845_137_453_8),
];
/// The smoothness term's weight per fitted point (see [`fill_loop`]).
const SMOOTHNESS: f64 = 1e-7;
/// A loop step shorter than this fraction of its length in space, seen along the normal, runs
/// along the normal (steeper than about 87° from the plane).
const MIN_STEP_SLOPE: f64 = 0.05;
/// Consecutive steps closer to reversed than this cosine fold the loop back on itself.
const FOLD_COS: f64 = -0.999;
/// A loop's fill: the surface (`S(u, v) = o + u·x + v·y + h(u, v)·n` over the plane with normal
/// `n`, so `∂S/∂u × ∂S/∂v` points along `n`), the plane's frame, whether the loop runs counter-
/// clockwise about it, the largest distance from a sampled loop point to the surface along `n`,
/// and how many points of the loop were evaluated.
#[derive(Clone, Debug, PartialEq)]
pub struct LoopFill {
pub surface: NurbsSurface,
/// The plane's origin and its axes `x`, `y` (the surface's parameter directions).
pub origin: Vec3,
pub axes: [Vec3; 2],
pub normal: Vec3,
pub counter_clockwise: bool,
pub deviation: f64,
pub evaluations: usize,
}
/// Fill the closed loop of `sides` curves (side `k` given by `point(k, t)`, `t` from 0 at its
/// start to 1 at its end, which is where side `k+1` starts) with one surface passing within
/// `tol` of the loop.
///
/// - Sampling: each side at `t = i/s` (fitted) and halfway between (checked), `s` from 16
/// doubling while the fit needs denser points, at most [`MAX_LOOP_POINTS`] points in all. The
/// sample parameters are the same run either way, so the fill does not depend on where the
/// loop starts or which way it runs.
/// - The plane: through the fitted points' centroid, normal by Newell's method, turned so that
/// its largest component is positive; its first axis points to the fitted point farthest from
/// the centroid in the plane (the highest of points as far). So the plane and its axes, and with them the grid and the fill,
/// move with the loop (a reflected axis gives the same grid).
/// - Refused ("not supported yet"): a loop that, seen along the normal, has a step much shorter
/// than in space (it runs along the normal), turns straight back, or crosses or touches
/// itself; such a loop does not bound a height field over the plane.
/// - The height field: a bicubic B-spline with uniform knots over the projected loop's box
/// (widened by 1%); its control points sit at the knot averages in the plane, so its surface
/// parameters are plane coordinates, and their heights are the unknowns. The heights minimise
/// `Σ (h(uₖ, vₖ) − hₖ)² + λ·A · ∫∫ h_uu² + 2·h_uv² + h_vv²` over the fitted points — least
/// squares (eq. 9.62, normal equations eq. 9.65) plus the thin-plate bending energy over the
/// box, `A` the area of one grid cell (so each cell's energy counts like a squared second
/// difference of heights, and the balance is the same at any size). The energy decides the
/// inside, where there are no points; it is zero only for planes, so a plane through the
/// loop is reproduced exactly, and any other rim gets the least-bending surface on it, which
/// is free to turn at the rim (it is not the surface that made the rim). It does not depend
/// on the grid's directions. `λ = 10⁻⁷ ·` (fitted points) `/` (control points): just enough
/// to fix the inside, small enough not to pull the surface off the loop.
/// - Grids of 4, 8, 16, 32 spans across the box's longer side are tried, with as many across
/// the shorter side as keep the cells about square; a grid whose cells are under twice the
/// fitted points' largest step in a direction (cells between points would be left to the
/// energy alone) waits for denser sampling. The first grid within `tol` of every sampled
/// point (measured along the normal, which bounds the distance) is the fill; otherwise the
/// sampling doubles, and when that runs out, "not supported yet" with the closest deviation
/// reached.
pub fn fill_loop(sides: usize, point: &mut dyn FnMut(usize, f64) -> Option<Vec3>, tol: f64) -> SplineResult<LoopFill> {
if sides == 0 || sides.saturating_mul(16) > MAX_LOOP_POINTS / 2 {
return Err(SplineError::TooLarge(format!("a loop of {sides} sides (at most {})", MAX_LOOP_POINTS / 32)));
}
if !(tol.is_finite() && tol > 0.0) {
return invalid("the fill tolerance must be positive");
}
let mut evaluations = 0usize;
let mut eval = |k: usize, t: f64| -> SplineResult<Vec3> {
evaluations += 1;
point(k, t).filter(|p| p.is_finite()).ok_or_else(|| SplineError::Invalid(format!("side {k} has no finite point at {t}")))
};
let mut per = 16usize;
// Fitted points: side by side, per side at i/per (i < per).
let mut fit: Vec<Vec3> = Vec::with_capacity(sides * per);
for k in 0..sides {
for i in 0..per {
fit.push(eval(k, i as f64 / per as f64)?);
}
}
let mut deviation = f64::INFINITY;
loop {
let mut check = Vec::with_capacity(fit.len());
for k in 0..sides {
for i in 0..per {
check.push(eval(k, (i as f64 + 0.5) / per as f64)?);
}
}
// The loop in order: fitted and checked points alternating.
let all: Vec<Vec3> = fit.iter().zip(&check).flat_map(|(a, b)| [*a, *b]).collect();
match fit_once(&fit, &all, tol)? {
Fit::Done(mut fill) => {
fill.evaluations = evaluations;
return Ok(*fill);
}
Fit::Denser(dev) => deviation = deviation.min(dev),
Fit::Exhausted(dev) => {
deviation = deviation.min(dev);
break;
}
}
if all.len() * 2 > MAX_LOOP_POINTS {
break;
}
fit = all;
per *= 2;
}
Err(SplineError::Unsupported(format!("filling a loop this wavy with one surface (it stays {deviation:e} off the loop, tolerance {tol:e})")))
}
/// What one round of [`fill_loop`] gave.
enum Fit {
Done(Box<LoopFill>),
/// Within tolerance only with cells finer than the points allow: sample denser.
Denser(f64),
/// The finest grid tried is not within tolerance.
Exhausted(f64),
}
/// One round of [`fill_loop`]: fit the points `fit`, check every point of `all` (the loop in
/// order, `fit` among them).
fn fit_once(fit: &[Vec3], all: &[Vec3], tol: f64) -> SplineResult<Fit> {
let m = fit.len();
let o = fit.iter().fold(Vec3::ZERO, |a, p| a + *p) / m as f64;
let at = |k: usize| fit.get(k % m).copied().unwrap_or(Vec3::ZERO);
let area = (0..m).fold(Vec3::ZERO, |a, k| a + (at(k) - o).cross(at(k + 1) - o));
let Some(normal) = area.normalized() else { return invalid("the loop encloses no area") };
let largest = [normal.x, normal.y, normal.z].into_iter().fold(0.0f64, |a, c| if c.abs() > a.abs() { c } else { a });
let counter_clockwise = largest > 0.0;
let n = if counter_clockwise { normal } else { normal * -1.0 };
// The first axis towards the fitted point farthest from the centroid in the plane: the axes,
// the grid and so the fill move with the loop.
let flat = |p: &Vec3| {
let d = *p - o;
d - n * d.dot(n)
};
// Points as far as each other (within rounding) are told apart by their height, and points
// alike in both by their coordinates: the same point is chosen however the loop is given,
// and as the loop moves, but for ties its symmetry makes.
let key = |p: &Vec3| (flat(p).len(), (*p - o).dot(n));
let reach = fit.iter().map(|p| key(p).0).fold(0.0, f64::max);
let near = 1e-9 * reach;
let far = fit.iter().fold(None::<&Vec3>, |best, p| match best {
None => Some(p),
Some(b) => {
let ((db, hb), (dp, hp)) = (key(b), key(p));
let better = if (dp - db).abs() > near {
dp > db
} else if (hp - hb).abs() > near {
hp > hb
} else {
(p.x, p.y, p.z) > (b.x, b.y, b.z)
};
Some(if better { p } else { b })
}
});
let far = far.map(flat).unwrap_or(Vec3::ZERO);
let x = far.normalized().ok_or_else(|| SplineError::Invalid("the loop has no extent in its plane".into()))?;
let y = n.cross(x);
let project = |pts: &[Vec3]| -> (Vec<[f64; 2]>, Vec<f64>) {
(pts.iter().map(|p| [(*p - o).dot(x), (*p - o).dot(y)]).collect(), pts.iter().map(|p| (*p - o).dot(n)).collect())
};
let (uv, h) = project(all);
check_projectable(all, &uv)?;
let (fuv, fh) = project(fit);
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
for q in &uv {
for d in 0..2 {
lo[d] = lo[d].min(q[d]);
hi[d] = hi[d].max(q[d]);
}
}
for d in 0..2 {
let pad = 0.01 * (hi[d] - lo[d]);
lo[d] -= pad;
hi[d] += pad;
}
if !(lo[0] < hi[0] && lo[1] < hi[1] && (hi[0] - lo[0]).is_finite() && (hi[1] - lo[1]).is_finite()) {
return invalid("the loop's extent is not finite");
}
// Spans per direction: `level` across the longer side of the box, as many across the
// shorter as keep the cells about square (at least one). A grid finer than the fitted
// points can hold in a direction (cells under twice their largest step along it) is tried
// only after denser sampling; the coarsest is always tried.
let extent = [hi[0] - lo[0], hi[1] - lo[1]];
let mut step = [0.0f64; 2];
for k in 0..m {
let (p, q) = (fuv.get(k).copied().unwrap_or([0.0; 2]), fuv.get((k + 1) % m).copied().unwrap_or([0.0; 2]));
for d in 0..2 {
step[d] = step[d].max((q[d] - p[d]).abs());
}
}
let longer = extent[0].max(extent[1]);
let mut deviation = f64::INFINITY;
let mut level = MIN_FILL_SPANS;
while level <= MAX_FILL_SPANS {
let spans = [0, 1].map(|d| ((extent[d] / longer * level as f64).ceil() as usize).clamp(1, level));
let holds = (0..2).all(|d| extent[d] / spans[d] as f64 >= 2.0 * step[d]);
if !holds && level > MIN_FILL_SPANS {
return Ok(Fit::Denser(deviation));
}
let grid = Grid::new(spans, lo, hi);
let heights = grid.fit(&fuv, &fh)?;
let dev = uv.iter().zip(&h).map(|(q, hk)| (grid.height(&heights, *q) - hk).abs()).fold(0.0, f64::max);
if !dev.is_finite() {
return invalid("the fit is not finite");
}
deviation = deviation.min(dev);
if dev <= tol && holds {
let surface = grid.surface(&heights, o, x, y, n)?;
let fill = LoopFill { surface, origin: o, axes: [x, y], normal: n, counter_clockwise, deviation: dev, evaluations: 0 };
return Ok(Fit::Done(Box::new(fill)));
}
if !holds {
return Ok(Fit::Denser(deviation));
}
level *= 2;
}
Ok(Fit::Exhausted(deviation))
}
/// Refuse a loop that does not bound a height field over its plane (see [`fill_loop`]).
fn check_projectable(points: &[Vec3], uv: &[[f64; 2]]) -> SplineResult<()> {
let m = uv.len();
let q = |k: usize| uv.get(k % m).copied().unwrap_or([0.0; 2]);
let p = |k: usize| points.get(k % m).copied().unwrap_or(Vec3::ZERO);
let step = |k: usize| {
let (a, b) = (q(k), q(k + 1));
[b[0] - a[0], b[1] - a[1]]
};
for k in 0..m {
let d = step(k);
let flat = d[0].hypot(d[1]);
let space = p(k).dist(p(k + 1));
if space > 0.0 && flat < MIN_STEP_SLOPE * space {
return Err(SplineError::Unsupported("filling a loop that runs along its plane's normal".into()));
}
let e = step(k + 1);
let fe = e[0].hypot(e[1]);
if flat > 0.0 && fe > 0.0 && (d[0] * e[0] + d[1] * e[1]) < FOLD_COS * flat * fe {
return Err(SplineError::Unsupported("filling a loop that folds back on itself".into()));
}
}
// Non-adjacent segments must not meet: a sweep over the segments' u extents.
let mut segs: Vec<(f64, f64, usize)> = (0..m)
.map(|k| {
let (a, b) = (q(k), q(k + 1));
(a[0].min(b[0]), a[0].max(b[0]), k)
})
.collect();
segs.sort_by(|a, b| a.0.total_cmp(&b.0));
for (i, &(_, hi, k)) in segs.iter().enumerate() {
for &(lo2, _, l) in segs.get(i + 1..).into_iter().flatten() {
if lo2 > hi {
break;
}
let adjacent = (k + 1) % m == l || (l + 1) % m == k;
if !adjacent && segments_meet(q(k), q(k + 1), q(l), q(l + 1)) {
return Err(SplineError::Unsupported("filling a loop that crosses itself seen along its plane's normal".into()));
}
}
}
Ok(())
}
/// Whether the closed segments `ab` and `cd` share a point.
fn segments_meet(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
let orient = |p: [f64; 2], q: [f64; 2], r: [f64; 2]| (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
let within =
|p: [f64; 2], q: [f64; 2], r: [f64; 2]| r[0] >= p[0].min(q[0]) && r[0] <= p[0].max(q[0]) && r[1] >= p[1].min(q[1]) && r[1] <= p[1].max(q[1]);
let (d1, d2, d3, d4) = (orient(c, d, a), orient(c, d, b), orient(a, b, c), orient(a, b, d));
if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) {
return true;
}
(d1 == 0.0 && within(c, d, a)) || (d2 == 0.0 && within(c, d, b)) || (d3 == 0.0 && within(a, b, c)) || (d4 == 0.0 && within(a, b, d))
}
/// A bicubic height field's grid: uniform clamped knots over `[lo, hi]` in each direction,
/// `count` control points along each.
struct Grid {
knots: [Vec<f64>; 2],
count: [usize; 2],
}
impl LoopFill {
/// The surface's point over (or under) `p`, along the normal.
pub fn point_over(&self, p: Vec3) -> SplineResult<Vec3> {
let d = p - self.origin;
let [x, y] = self.axes;
self.surface.eval(d.dot(x), d.dot(y))
}
}
impl Grid {
fn new(spans: [usize; 2], lo: [f64; 2], hi: [f64; 2]) -> Grid {
let knots = [0, 1].map(|d| {
let (a, b, s) = (lo[d], hi[d], spans[d]);
let mut k = vec![a; 4];
k.extend((1..s).map(|i| a + (b - a) * i as f64 / s as f64));
k.extend([b; 4]);
k
});
Grid { knots, count: spans.map(|s| s + 3) }
}
/// The length of one span in direction `d`.
fn cell(&self, d: usize) -> f64 {
let (Some(k), Some(&c)) = (self.knots.get(d), self.count.get(d)) else { return 0.0 };
(k.last().copied().unwrap_or(0.0) - k.first().copied().unwrap_or(0.0)) / (c - 3) as f64
}
/// The knot average `(kᵢ₊₁ + kᵢ₊₂ + kᵢ₊₃)/3` of control point `i` in direction `d`.
fn average(&self, d: usize, i: usize) -> f64 {
self.knots.get(d).map_or(0.0, |k| (1..=3).map(|t| k.get(i + t).copied().unwrap_or(0.0)).sum::<f64>() / 3.0)
}
/// The four non-zero basis functions' `r`-th derivatives and the first index, at `x` in
/// direction `d`.
fn basis_derivative(&self, d: usize, x: f64, r: usize) -> Option<(usize, [f64; 4])> {
let kn = self.knots.get(d)?;
let x = x.clamp(*kn.first()?, *kn.last()?);
let span = find_span(kn, 3, *self.count.get(d)?, x)?;
let side = if x >= *kn.last()? { Side::Left } else { Side::Right };
let first = span.checked_sub(3)?;
let mut out = [0.0; 4];
for (a, o) in out.iter_mut().enumerate() {
*o = kn.get(first + a..first + a + 5).map_or(0.0, |l| local_basis_array(l, x, side, r).get(r).copied().unwrap_or(0.0));
}
Some((first, out))
}
/// The height at `q` of the net `heights` (i fastest).
fn height(&self, heights: &[f64], q: [f64; 2]) -> f64 {
let (Some((i0, bu)), Some((j0, bv))) = (self.basis_derivative(0, q[0], 0), self.basis_derivative(1, q[1], 0)) else { return f64::NAN };
let mut s = 0.0;
for (b, wv) in bv.iter().enumerate() {
for (a, wu) in bu.iter().enumerate() {
s += wu * wv * heights.get((j0 + b) * self.count[0] + i0 + a).copied().unwrap_or(f64::NAN);
}
}
s
}
/// The control heights fitting the points (see [`fill_loop`]): the normal equations, banded
/// (unknowns i fastest, so coupled unknowns are at most `3·count[0] + 3` apart), solved by
/// Cholesky factorisation.
fn fit(&self, uv: &[[f64; 2]], h: &[f64]) -> SplineResult<Vec<f64>> {
let [cu, cv] = self.count;
let n = cu * cv;
let mut band = Band::new(n, 3 * cu + 3);
let mut rhs = vec![0.0; n];
let mut fitted = 0usize;
for (q, hk) in uv.iter().zip(h) {
let (Some((i0, bu)), Some((j0, bv))) = (self.basis_derivative(0, q[0], 0), self.basis_derivative(1, q[1], 0)) else {
return invalid("a loop point is off the grid");
};
let mut terms = [(0usize, 0.0f64); 16];
for (t, slot) in terms.iter_mut().enumerate() {
let (a, b) = (t % 4, t / 4);
*slot = ((j0 + b) * cu + i0 + a, bu.get(a).copied().unwrap_or(0.0) * bv.get(b).copied().unwrap_or(0.0));
}
for &(r, wr) in &terms {
if let Some(slot) = rhs.get_mut(r) {
*slot += wr * hk;
}
for &(s, ws) in &terms {
band.add(r, s, wr * ws);
}
}
fitted += 1;
}
// The thin-plate energy ∫∫ h_uu² + 2·h_uv² + h_vv² over the grid's box, times the area of
// one cell: per cell that is the squared second difference of the heights, so it weighs
// against the data term (squared heights) alike at any size. Each term is a product of
// one-direction Gram matrices of the basis functions' derivatives.
let lambda = SMOOTHNESS * fitted as f64 / n as f64 * self.cell(0) * self.cell(1);
let gram = |d: usize, r: usize| -> Vec<f64> {
let c = self.count.get(d).copied().unwrap_or(0);
let mut g = vec![0.0; c * c];
let Some(k) = self.knots.get(d) else { return g };
for w in k.windows(2) {
let &[a, b] = w else { continue };
if !(b > a) {
continue;
}
for (gx, gw) in GAUSS4 {
let x = 0.5 * (a + b) + 0.5 * (b - a) * gx;
let Some((first, vals)) = self.basis_derivative(d, x, r) else { continue };
for (i, vi) in vals.iter().enumerate() {
for (j, vj) in vals.iter().enumerate() {
if let Some(slot) = g.get_mut((first + i) * c + first + j) {
*slot += 0.5 * (b - a) * gw * vi * vj;
}
}
}
}
}
g
};
let grams: [[Vec<f64>; 3]; 2] = [0, 1].map(|d| [0, 1, 2].map(|r| gram(d, r)));
let g = |d: usize, r: usize, i: usize, k: usize| {
let c = self.count.get(d).copied().unwrap_or(0);
grams.get(d).and_then(|m| m.get(r)).and_then(|m| m.get(i * c + k)).copied().unwrap_or(0.0)
};
for j in 0..cv {
for i in 0..cu {
let r = j * cu + i;
for l in j.saturating_sub(3)..(j + 4).min(cv) {
for k in i.saturating_sub(3)..(i + 4).min(cu) {
let e: f64 = ENERGY.iter().map(|&(ru, rv, w)| w * g(0, ru, i, k) * g(1, rv, j, l)).sum();
band.add(r, l * cu + k, lambda * e);
}
}
}
}
band.solve(rhs)
}
/// The surface of the net `heights` over the plane `o`, `x`, `y`, `n`: control point `(i, j)`
/// at the knot averages `(ξᵢ, ηⱼ)` in the plane, `heights[j·count[0] + i]` along `n`.
fn surface(&self, heights: &[f64], o: Vec3, x: Vec3, y: Vec3, n: Vec3) -> SplineResult<NurbsSurface> {
let [cu, cv] = self.count;
let mut ctrl = Vec::with_capacity(cu * cv);
for j in 0..cv {
for i in 0..cu {
let hij = heights.get(j * cu + i).copied().unwrap_or(f64::NAN);
ctrl.push(HPoint::weighted(o + x * self.average(0, i) + y * self.average(1, j) + n * hij, 1.0));
}
}
NurbsSurface::from_homogeneous([3, 3], self.knots.clone(), ctrl)
}
}
/// A symmetric banded matrix: the upper band, row `r` holding columns `r ..= r + bw`.
struct Band {
n: usize,
bw: usize,
a: Vec<f64>,
}
impl Band {
fn new(n: usize, bw: usize) -> Band {
Band { n, bw, a: vec![0.0; n * (bw + 1)] }
}
fn index(&self, r: usize, s: usize) -> Option<usize> {
let (r, s) = if r <= s { (r, s) } else { (s, r) };
(s - r <= self.bw && s < self.n).then_some(r * (self.bw + 1) + (s - r))
}
fn get(&self, r: usize, s: usize) -> f64 {
self.index(r, s).and_then(|i| self.a.get(i)).copied().unwrap_or(0.0)
}
fn set(&mut self, r: usize, s: usize, v: f64) {
if let Some(x) = self.index(r, s).and_then(|i| self.a.get_mut(i)) {
*x = v;
}
}
/// Add `v` at `(r, s)` with `r ≤ s` (the lower half is the same by symmetry: callers adding
/// a full outer product reach each stored entry once through `r ≤ s`).
fn add(&mut self, r: usize, s: usize, v: f64) {
if r <= s
&& let Some(x) = self.index(r, s).and_then(|i| self.a.get_mut(i))
{
*x += v;
}
}
/// Solve `A·x = b` by banded Cholesky factorisation `A = Uᵀ·U`; an error when `A` is not
/// positive definite.
fn solve(mut self, mut b: Vec<f64>) -> SplineResult<Vec<f64>> {
let (n, bw) = (self.n, self.bw);
for r in 0..n {
let mut d = self.get(r, r);
for k in r.saturating_sub(bw)..r {
let u = self.get(k, r);
d -= u * u;
}
if !(d > 0.0) {
return invalid("the fit's equations are singular");
}
let d = d.sqrt();
self.set(r, r, d);
for s in r + 1..(r + bw + 1).min(n) {
let mut v = self.get(r, s);
for k in s.saturating_sub(bw)..r {
v -= self.get(k, r) * self.get(k, s);
}
self.set(r, s, v / d);
}
}
// Uᵀ·y = b, then U·x = y.
for r in 0..n {
let mut v = b.get(r).copied().unwrap_or(0.0);
for k in r.saturating_sub(bw)..r {
v -= self.get(k, r) * b.get(k).copied().unwrap_or(0.0);
}
if let Some(x) = b.get_mut(r) {
*x = v / self.get(r, r);
}
}
for r in (0..n).rev() {
let mut v = b.get(r).copied().unwrap_or(0.0);
for s in r + 1..(r + bw + 1).min(n) {
v -= self.get(r, s) * b.get(s).copied().unwrap_or(0.0);
}
if let Some(x) = b.get_mut(r) {
*x = v / self.get(r, r);
}
}
if b.iter().any(|x| !x.is_finite()) {
return invalid("the fit is not finite");
}
Ok(b)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The closed polygon through `corners` in space, side `k` from corner `k` to `k+1`.
fn polygon3(corners: &[Vec3]) -> impl FnMut(usize, f64) -> Option<Vec3> + '_ {
move |k, t| {
let n = corners.len();
Some(corners.get(k)?.lerp(*corners.get((k + 1) % n)?, t))
}
}
/// The polygon `corners` in the plane, lifted by `z(x, y)` all along.
fn lifted<'a>(corners: &'a [[f64; 2]], z: &'a dyn Fn(f64, f64) -> f64) -> impl FnMut(usize, f64) -> Option<Vec3> + 'a {
move |k, t| {
let n = corners.len();
let (a, b) = (corners.get(k)?, corners.get((k + 1) % n)?);
let (x, y) = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t);
Some(Vec3::new(x, y, z(x, y)))
}
}
fn ring(n: usize, r: f64) -> Vec<[f64; 2]> {
(0..n).map(|k| k as f64 * std::f64::consts::TAU / n as f64).map(|a| [r * a.cos(), r * a.sin()]).collect()
}
/// The farthest the surface is from `z` over the polygon's inside (sampled on a grid).
fn off_inside(f: &LoopFill, corners: &[[f64; 2]], z: &dyn Fn(f64, f64) -> f64) -> f64 {
let inside = |x: f64, y: f64| {
let n = corners.len();
let mut w = false;
for k in 0..n {
let (a, b) = (corners[k], corners[(k + 1) % n]);
if (a[1] > y) != (b[1] > y) && x < a[0] + (y - a[1]) / (b[1] - a[1]) * (b[0] - a[0]) {
w = !w;
}
}
w
};
let s = &f.surface;
let [du, dv] = s.domain();
let mut worst = 0.0f64;
for i in 0..=40 {
for j in 0..=40 {
let (u, v) = ((du[0] + (du[1] - du[0]) * i as f64 / 40.0).min(du[1]), (dv[0] + (dv[1] - dv[0]) * j as f64 / 40.0).min(dv[1]));
let p = s.eval(u, v).unwrap();
if inside(p.x, p.y) {
worst = worst.max((p.z - z(p.x, p.y)).abs());
}
}
}
worst
}
#[test]
fn planes_are_reproduced_and_rims_of_quadrics_are_filled_closely() {
// A tilted plane over a hexagon: the smoothness energy vanishes on it, so the fill is it.
let hex = ring(6, 10.0);
let plane = |x: f64, y: f64| 0.3 * x - 0.2 * y + 1.0;
let f = fill_loop(6, &mut lifted(&hex, &plane), 1e-6).unwrap();
let inside = off_inside(&f, &hex, &plane);
eprintln!("plane: deviation {:e}, inside off by {inside:e}, {} evaluations", f.deviation, f.evaluations);
assert!(f.deviation <= 1e-6 && inside < 1e-9);
// A saddle and a dome: the least-bending fill through the rim is not the quadric (only
// planes are reproduced), but stays near it (the plate is free to turn at the rim).
let saddle = |x: f64, y: f64| 0.05 * x * y;
let dome = |x: f64, y: f64| 3.0 - 0.02 * (x * x + y * y);
for (name, z, bound) in [("saddle", &saddle as &dyn Fn(f64, f64) -> f64, 0.1), ("dome", &dome, 1.0)] {
let f = fill_loop(6, &mut lifted(&hex, z), 2e-5).unwrap();
let inside = off_inside(&f, &hex, z);
eprintln!("{name}: deviation {:e}, inside off by {inside:e}", f.deviation);
assert!(f.deviation <= 2e-5 && inside < bound, "{name}: {inside}");
}
}
#[test]
fn concave_loops_fill_and_the_result_does_not_depend_on_where_the_loop_starts() {
// A dart, an arrow-head pentagon and a plus, on a cylinder.
let z = |x: f64, _: f64| 50.0 - (1600.0 - x * x).sqrt();
let dart = vec![[0.0, 0.0], [20.0, -10.0], [8.0, 0.0], [20.0, 10.0]];
let arrow = vec![[0.0, 0.0], [10.0, -8.0], [10.0, -3.0], [20.0, 0.0], [10.0, 3.0]];
let plus = vec![
[-3.0, -9.0],
[3.0, -9.0],
[3.0, -3.0],
[9.0, -3.0],
[9.0, 3.0],
[3.0, 3.0],
[3.0, 9.0],
[-3.0, 9.0],
[-3.0, 3.0],
[-9.0, 3.0],
[-9.0, -3.0],
[-3.0, -3.0],
];
let flat_corner = vec![[-10.0, -10.0], [0.0, -10.0], [10.0, -10.0], [10.0, 10.0], [-10.0, 10.0]];
for (name, c) in [("dart", dart), ("arrow", arrow), ("plus", plus), ("flat corner", flat_corner)] {
let n = c.len();
let f = fill_loop(n, &mut lifted(&c, &z), 1e-4).unwrap();
let inside = off_inside(&f, &c, &z);
eprintln!("{name}: deviation {:e}, inside off the cylinder by {inside:e}, {} evaluations", f.deviation, f.evaluations);
// The least-bending fill, not the cylinder: within a few tenths of a millimetre of it.
assert!(f.deviation <= 1e-4 && inside < 0.2, "{name}: {inside}");
// The same loop from another corner and the other way round.
let mut back = c.clone();
back.rotate_left(2);
back.reverse();
let g = fill_loop(n, &mut lifted(&back, &z), 1e-4).unwrap();
assert_ne!(f.counter_clockwise, g.counter_clockwise);
for (x, y) in [(4.0, 0.0), (6.0, 1.0), (5.0, -1.0)] {
let p = Vec3::new(x, y, z(x, y));
let gap = f.point_over(p).unwrap().dist(g.point_over(p).unwrap());
assert!(gap < 1e-7, "{name}: {gap}");
}
}
}
#[test]
fn the_fill_is_the_same_at_any_size_and_place() {
// The arrow on the cylinder, scaled by 100 and by 1/100, turned and moved: the same fill,
// scaled and moved alike.
let z = |x: f64, _: f64| 50.0 - (1600.0 - x * x).sqrt();
let arrow = [[0.0, 0.0], [10.0, -8.0], [10.0, -3.0], [20.0, 0.0], [10.0, 3.0]];
let f = fill_loop(5, &mut lifted(&arrow, &z), 1e-4).unwrap();
let axis = Vec3::new(1.0, -2.0, 0.5).normalized().unwrap();
let shift = Vec3::new(30.0, -12.0, 7.0);
for scale in [100.0, 0.01] {
let place = |p: Vec3| {
// Scaled, turned by 0.9 about `axis` (Rodrigues), moved.
let p = p * scale;
let (c, s) = (0.9f64.cos(), 0.9f64.sin());
p * c + axis.cross(p) * s + axis * (axis.dot(p) * (1.0 - c)) + shift * scale
};
let mut moved = |k: usize, t: f64| lifted(&arrow, &z)(k, t).map(place);
let g = fill_loop(5, &mut moved, 1e-4 * scale).unwrap();
let mut worst = 0.0f64;
for (x, y) in [(4.0, 0.0), (6.0, 1.0), (5.0, -1.0), (9.0, -2.0), (12.0, 0.5)] {
let p = Vec3::new(x, y, z(x, y));
worst = worst.max(g.point_over(place(p)).unwrap().dist(place(f.point_over(p).unwrap())) / scale);
}
eprintln!("arrow scaled by {scale}, turned and moved: {} evaluations, surfaces apart by {worst:e} (relative)", g.evaluations);
assert!(worst < 1e-7, "scale {scale}: {worst}");
}
}
#[test]
fn a_long_narrow_slot_fills() {
// A 200 × 1 mm slot on a cylinder of radius 300 running along it.
let z = |x: f64, _: f64| 300.0 - (90_000.0 - (x - 100.0) * (x - 100.0)).sqrt();
let slot = [[0.0, 0.0], [200.0, 0.0], [200.0, 1.0], [0.0, 1.0]];
let f = fill_loop(4, &mut lifted(&slot, &z), 2e-4).unwrap();
let inside = off_inside(&f, &slot, &z);
eprintln!("slot: deviation {:e}, inside off the cylinder by {inside:e}, {} evaluations", f.deviation, f.evaluations);
assert!(f.deviation <= 2e-4 && inside < 1e-2);
}
#[test]
fn loops_that_do_not_bound_a_height_field_are_refused() {
let unsupported = |r: SplineResult<LoopFill>| matches!(r, Err(SplineError::Unsupported(_)));
let flat = |_: f64, _: f64| 0.0;
// A figure eight (lobes of different sizes).
let eight = [[0.0, 0.0], [10.0, 10.0], [10.0, 0.0], [0.0, 6.0]];
assert!(unsupported(fill_loop(4, &mut lifted(&eight, &flat), 1e-4)));
// A loop with a vertical spike in it (up along the normal and down again).
let spike = [
Vec3::ZERO,
Vec3::new(10.0, 0.0, 0.0),
Vec3::new(10.0, 10.0, 0.0),
Vec3::new(5.0, 10.0, 0.0),
Vec3::new(5.0, 10.0, 3.0),
Vec3::new(5.0, 10.0, 0.0),
Vec3::new(0.0, 10.0, 0.0),
];
assert!(unsupported(fill_loop(7, &mut polygon3(&spike), 1e-4)));
// A loop that runs back over itself.
let back = [[0.0, 0.0], [10.0, 0.0], [4.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
assert!(unsupported(fill_loop(5, &mut lifted(&back, &flat), 1e-4)));
// A loop too wavy for one surface.
let wavy = |x: f64, y: f64| (x * 9.0).sin() * (y * 7.0).cos();
assert!(unsupported(fill_loop(64, &mut lifted(&ring(64, 10.0), &wavy), 1e-6)));
}
#[test]
fn hostile_loops_are_refused() {
let flat = |_: f64, _: f64| 0.0;
let five = ring(5, 10.0);
assert!(fill_loop(0, &mut lifted(&five, &flat), 1e-4).is_err());
assert!(fill_loop(5, &mut |_, _| Some(Vec3::X), 1e-4).is_err(), "no area");
assert!(fill_loop(5, &mut |_, _| None, 1e-4).is_err());
assert!(fill_loop(5, &mut |k, t| Some(Vec3::new(k as f64, t, f64::NAN)), 1e-4).is_err());
assert!(fill_loop(5, &mut lifted(&five, &flat), 0.0).is_err() && fill_loop(5, &mut lifted(&five, &flat), f64::NAN).is_err());
assert!(fill_loop(10_000, &mut lifted(&five, &flat), 1e-4).is_err());
let huge = |x: f64, y: f64| (x * y) * 1e300;
let _ = fill_loop(5, &mut lifted(&five, &huge), 1e-4);
let tiny: Vec<[f64; 2]> = five.iter().map(|p| [p[0] * 1e-300, p[1] * 1e-300]).collect();
let _ = fill_loop(5, &mut lifted(&tiny, &flat), 1e-4);
let big: Vec<[f64; 2]> = five.iter().map(|p| [p[0] * 1e300, p[1] * 1e300]).collect();
let _ = fill_loop(5, &mut lifted(&big, &flat), 1e-4);
}
}
+5 -3
View File
@@ -1,15 +1,17 @@
//! Geometry foundation for SolidCraft: 2D/3D vectors, sketch planes, axis-aligned boxes,
//! triangle meshes with their measures (volume, area, centroid), and freeform surface maths
//! (B-spline basis and NURBS surfaces in [`bspline`], T-splines in [`tspline`]). Units are
//! millimetres.
//! (B-spline basis and NURBS surfaces in [`bspline`], one-surface fills of closed loops in
//! [`fill`], T-splines in [`tspline`]). Units are millimetres.
//!
//! This crate has no knowledge of sketches, documents or the B-rep kernel; every other crate
//! This crate has no knowledge of sketches, documents or a modelling backend; every other crate
//! builds on it.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod brep;
pub mod bspline;
pub mod env;
pub mod fill;
mod mesh;
mod plane;
mod profile;
+114
View File
@@ -76,6 +76,40 @@ pub struct Mesh {
pub edge_faces: Vec<Vec<u32>>,
}
fn boxes_meet(a: &Aabb3, b: &Aabb3) -> bool {
a.min.x <= b.max.x && b.min.x <= a.max.x && a.min.y <= b.max.y && b.min.y <= a.max.y && a.min.z <= b.max.z && b.min.z <= a.max.z
}
/// Two triangles that pass through each other (see [`Mesh::crosses`]).
fn triangles_cross(t: &[Vec3; 3], u: &[Vec3; 3], tol: f64) -> bool {
// Signed distances of one triangle's corners from the other's plane, when they lie more
// than `tol` to both sides of it.
let sides = |tri: &[Vec3; 3], plane: &[Vec3; 3]| -> Option<(Vec3, [f64; 3])> {
let n = (plane[1] - plane[0]).cross(plane[2] - plane[0]).normalized()?;
let d = [n.dot(tri[0] - plane[0]), n.dot(tri[1] - plane[0]), n.dot(tri[2] - plane[0])];
let (lo, hi) = d.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
(lo < -tol && hi > tol).then_some((n, d))
};
let (Some((nu, dt)), Some((nt, du))) = (sides(t, u), sides(u, t)) else { return false };
let Some(line) = nt.cross(nu).normalized() else { return false };
// Where a triangle's edges pass through the other's plane, measured along the line the
// two planes share.
let reach = |tri: &[Vec3; 3], d: &[f64; 3]| -> (f64, f64) {
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for (i, j) in [(0, 1), (1, 2), (2, 0)] {
let (p, q, dp, dq) = (tri[i], tri[j], d[i], d[j]);
if (dp < 0.0) != (dq < 0.0) && dp != dq {
let at = line.dot(p + (q - p) * (dp / (dp - dq)));
lo = lo.min(at);
hi = hi.max(at);
}
}
(lo, hi)
};
let ((t0, t1), (u0, u1)) = (reach(t, &dt), reach(u, &du));
t1.min(u1) - t0.max(u0) > tol
}
/// Mass properties of a closed mesh.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct MeshMeasure {
@@ -281,6 +315,86 @@ impl Mesh {
dirs.iter().filter(|d| self.ray_crossings(p, **d) % 2 == 1).count() >= 2
}
/// Whether the surfaces of two meshes pass through each other by more than `tol`: some
/// triangle of one has corners more than `tol` to both sides of a triangle of the other,
/// and the reverse, and they share a stretch of the line where their planes meet longer
/// than `tol`. Faces that only touch, lie on each other or stop on the other surface don't
/// count, nor do two facetings of one curved surface that are within `tol` of it. Bodies
/// can still overlap without crossing: one inside the other, or sharing a slab between
/// faces that are parallel or in one plane.
pub fn crosses(&self, o: &Mesh, tol: f64) -> bool {
if !tol.is_finite() {
return false;
}
let tol = tol.max(0.0);
let (ba, bb) = (self.bounds(), o.bounds());
let region = Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) };
if region.is_empty() {
return false;
}
let near = |m: &Mesh| -> Vec<([Vec3; 3], Aabb3)> {
m.triangles
.iter()
.filter_map(|t| {
let t = m.tri(t)?;
let mut bx = Aabb3::EMPTY;
t.iter().for_each(|p| bx.add(*p));
(!bx.is_empty() && boxes_meet(&bx, &region)).then_some((t, bx))
})
.collect()
};
let (ta, tb) = (near(self), near(o));
if ta.is_empty() || tb.is_empty() {
return false;
}
// A grid over the shared box holds the other mesh's triangles.
let n = ((tb.len() as f64).cbrt().ceil() as usize).clamp(1, 24);
let size = region.size();
let cell = |v: f64, lo: f64, s: f64| -> usize { if s > 0.0 { (((v - lo) / s * n as f64).floor().max(0.0) as usize).min(n - 1) } else { 0 } };
let span = |bx: &Aabb3| -> [(usize, usize); 3] {
[
(cell(bx.min.x, region.min.x, size.x), cell(bx.max.x, region.min.x, size.x)),
(cell(bx.min.y, region.min.y, size.y), cell(bx.max.y, region.min.y, size.y)),
(cell(bx.min.z, region.min.z, size.z), cell(bx.max.z, region.min.z, size.z)),
]
};
let mut cells: Vec<Vec<u32>> = vec![Vec::new(); n * n * n];
for (i, (_, bx)) in tb.iter().enumerate() {
let [x, y, z] = span(bx);
for ix in x.0..=x.1 {
for iy in y.0..=y.1 {
for iz in z.0..=z.1 {
if let Some(c) = cells.get_mut((ix * n + iy) * n + iz) {
c.push(i as u32);
}
}
}
}
}
// Per triangle of `o`: the last triangle of `self` tested against it, plus one.
let mut seen = vec![0usize; tb.len()];
for (i, (t, bx)) in ta.iter().enumerate() {
let [x, y, z] = span(bx);
for ix in x.0..=x.1 {
for iy in y.0..=y.1 {
for iz in z.0..=z.1 {
for j in cells.get((ix * n + iy) * n + iz).into_iter().flatten() {
let (Some(mark), Some((u, ub))) = (seen.get_mut(*j as usize), tb.get(*j as usize)) else { continue };
if *mark == i + 1 {
continue;
}
*mark = i + 1;
if boxes_meet(bx, ub) && triangles_cross(t, u, tol) {
return true;
}
}
}
}
}
}
false
}
/// Closest intersection of a ray with the mesh: (distance along `dir`, triangle index).
pub fn raycast(&self, o: Vec3, dir: Vec3) -> Option<(f64, usize)> {
let mut best: Option<(f64, usize)> = None;
+52
View File
@@ -233,3 +233,55 @@ fn split_at_and_closest_param() {
assert!(l.end().dist(q) < 1e-12 && r.start().dist(q) < 1e-12);
assert!((quarter.closest_param(Vec2::new(20.0, 20.0)) - 0.5).abs() < 1e-9);
}
/// An axis-aligned box as a closed mesh, with `n` x `n` quads on each side.
fn box_mesh(min: Vec3, max: Vec3, n: usize) -> Mesh {
let mut m = Mesh::default();
let s = max - min;
let at = |c: [f64; 3]| min + Vec3::new(s.x * c[0], s.y * c[1], s.z * c[2]);
for axis in 0..3 {
for side in [0.0, 1.0] {
for i in 0..n {
for j in 0..n {
let corner = |a: usize, b: usize| {
let mut c = [0.0; 3];
c[axis] = side;
c[(axis + 1) % 3] = a as f64 / n as f64;
c[(axis + 2) % 3] = b as f64 / n as f64;
at(c)
};
let k = m.positions.len() as u32;
m.positions.extend([corner(i, j), corner(i + 1, j), corner(i + 1, j + 1), corner(i, j + 1)]);
m.triangles.extend(if side > 0.0 { [[k, k + 1, k + 2], [k, k + 2, k + 3]] } else { [[k, k + 2, k + 1], [k, k + 3, k + 2]] });
}
}
}
}
m
}
#[test]
fn meshes_cross_only_where_their_surfaces_pass_through_each_other() {
let cube = |x: f64, y: f64, n: usize| box_mesh(Vec3::new(x, y, 0.0), Vec3::new(x + 10.0, y + 10.0, 10.0), n);
let a = cube(0.0, 0.0, 1);
// A corner 0.15 deep in the other cube (0.02 % of its volume), and a deep overlap.
for (x, y, n) in [(9.85, 3.0, 1), (9.85, 3.0, 7), (9.85, 9.85, 2), (5.0, 5.0, 3)] {
assert!(a.crosses(&cube(x, y, n), 0.01), "{x} {y} {n}");
assert!(cube(x, y, n).crosses(&a, 0.01), "{x} {y} {n}");
}
// Apart, touching on a face or an edge, and the same body.
for (x, y, n) in [(10.5, 0.0, 1), (10.0, 0.0, 1), (10.0, 3.0, 5), (10.0, 10.0, 2), (0.0, 0.0, 1), (0.0, 0.0, 4)] {
assert!(!a.crosses(&cube(x, y, n), 0.01), "{x} {y} {n}");
}
// One inside the other, standing on a face, and sharing a face plane from inside.
let inner = box_mesh(Vec3::new(2.0, 2.0, 2.0), Vec3::new(8.0, 8.0, 8.0), 2);
let on_top = box_mesh(Vec3::new(2.0, 2.0, 10.0), Vec3::new(8.0, 8.0, 14.0), 2);
let flush = box_mesh(Vec3::new(2.0, 2.0, 4.0), Vec3::new(8.0, 8.0, 10.0), 2);
assert!(!a.crosses(&inner, 0.01) && !inner.crosses(&a, 0.01));
assert!(!a.crosses(&on_top, 0.01) && !a.crosses(&flush, 0.01));
// A slab overlap between faces that are parallel or in one plane has no crossing.
assert!(!a.crosses(&cube(9.85, 0.0, 1), 0.01));
// Passing through by less than the tolerance doesn't count.
assert!(!a.crosses(&cube(9.995, 3.0, 1), 0.01));
assert!(!a.crosses(&cube(9.85, 3.0, 1), f64::NAN));
}
+418 -16
View File
@@ -1,7 +1,13 @@
//! Versioned design packages. Geometry is rebuilt from authoritative inputs; no
//! evaluated cache is trusted here. All archive access stays in memory, never on disk.
//! evaluated cache is trusted here. Entries are never extracted to filesystem paths.
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read, Write};
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use std::sync::Arc;
mod native_cache;
mod thumbnail;
pub use native_cache::{DesignGeometry, read_design_geometry};
pub use thumbnail::{THUMBNAIL_SIZE, read_design_thumbnail, read_design_thumbnail_file};
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
@@ -96,6 +102,29 @@ pub fn write_design_container_with_compression(doc: &Document, compression: Desi
/// Optional display cache failure never prevents saving the authoritative design.
pub fn write_design_container_with_preview(doc: &Document, compression: DesignCompression, preview: Option<&DesignPreview>) -> Result<Vec<u8>> {
write_design_container_with_cache(doc, compression, preview, None)
}
/// Save an optional warm native cache without making document saving depend on it.
/// Display meshes remain separately opt-in; no evaluation or meshing happens here.
pub fn write_design_container_with_cache(
doc: &Document,
compression: DesignCompression,
preview: Option<&DesignPreview>,
geometry: Option<&[DesignGeometry]>,
) -> Result<Vec<u8>> {
write_design_container_with_thumbnail(doc, compression, preview, geometry, None)
}
/// Include a bounded portable PNG picture when one is already available.
/// Unsupported headers or oversized pictures are omitted without changing the design.
pub fn write_design_container_with_thumbnail(
doc: &Document,
compression: DesignCompression,
preview: Option<&DesignPreview>,
geometry: Option<&[DesignGeometry]>,
thumbnail: Option<&[u8]>,
) -> Result<Vec<u8>> {
let mut writer = PackageWriter { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0 };
let mut features = Vec::with_capacity(doc.features.len());
for feature in &doc.features {
@@ -143,6 +172,19 @@ pub fn write_design_container_with_preview(doc: &Document, compression: DesignCo
let metadata = serde_json::to_vec(&metadata).map_err(invalid)?;
writer.part("document.json", &metadata, true)?;
writer.part("container.json", &serde_json::to_vec(&json!({"format": VERSION})).map_err(invalid)?, false)?;
if let Some((manifest, data)) = geometry.and_then(|g| native_cache::encode_geometry(g, &metadata).ok())
&& writer.names.len().saturating_add(2) <= MAX_ENTRIES
&& writer
.expanded
.saturating_add(manifest.len())
.saturating_add(data.len())
.saturating_add(writer.names.len().saturating_mul(256))
.saturating_add(65_536)
<= MAX_DESIGN_BYTES
{
writer.part("cache/key.json", &manifest, false)?;
writer.part("cache/brep.bin", &data, compression == DesignCompression::Compact)?;
}
if let Some((manifest, data)) = preview.and_then(|p| encode_preview(p, &metadata).ok())
&& writer.names.len().saturating_add(2) <= MAX_ENTRIES
&& writer
@@ -156,6 +198,9 @@ pub fn write_design_container_with_preview(doc: &Document, compression: DesignCo
writer.part("cache/preview.json", &manifest, false)?;
writer.part("cache/preview.bin", &data, false)?;
}
if let Some(png) = thumbnail {
thumbnail::write(&mut writer, png, &metadata)?;
}
let bytes = writer.zip.finish().map_err(invalid)?.into_inner();
if bytes.len() > MAX_DESIGN_BYTES {
return Err(invalid("compressed file too large"));
@@ -219,18 +264,26 @@ fn decode_mesh(bytes: &[u8], mesh: &mut MeshData) -> Result<()> {
validate_mesh(mesh)
}
struct PackageReader<'a> {
zip: zip::ZipArchive<Cursor<&'a [u8]>>,
struct PackageReader<R: Read + Seek> {
zip: zip::ZipArchive<R>,
blobs: BTreeMap<String, Vec<u8>>,
hydrated: usize,
document: Option<Arc<[u8]>>,
}
impl<'a> PackageReader<'a> {
fn new(bytes: &'a [u8]) -> Result<Self> {
impl<R: Read + Seek> PackageReader<R> {
fn new(mut source: R) -> Result<Self> {
let length = source.seek(SeekFrom::End(0)).map_err(invalid)?;
if length > MAX_DESIGN_BYTES as u64 {
return Err(invalid("compressed file too large"));
}
let tail_length = length.min(65_557) as usize;
source.seek(SeekFrom::Start(length - tail_length as u64)).map_err(invalid)?;
let mut tail = vec![0; tail_length];
source.read_exact(&mut tail).map_err(invalid)?;
// zip's name index can coalesce duplicate central-directory entries. Our
// bounded format does not need ZIP64/multiple disks: verify the declared
// entry count as well so duplicate names cannot silently select a payload.
let tail = bytes.get(bytes.len().saturating_sub(65_557)..).ok_or_else(|| invalid("missing ZIP directory"))?;
let eocd = tail.windows(4).rposition(|v| v == b"PK\x05\x06").ok_or_else(|| invalid("missing ZIP directory"))?;
let end = tail.get(eocd..).filter(|v| v.len() >= 22).ok_or_else(|| invalid("truncated ZIP directory"))?;
let u16_at = |offset| -> Result<u16> {
@@ -241,7 +294,7 @@ impl<'a> PackageReader<'a> {
if declared > MAX_ENTRIES || u16_at(4)? != 0 || u16_at(6)? != 0 || u16_at(8)? as usize != declared || end.len() != 22 + u16_at(20)? as usize {
return Err(invalid("too many entries or unsupported ZIP directory"));
}
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(invalid)?;
let mut zip = zip::ZipArchive::new(source).map_err(invalid)?;
if u16_at(4)? != 0
|| u16_at(6)? != 0
|| u16_at(8)? as usize != zip.len()
@@ -267,7 +320,15 @@ impl<'a> PackageReader<'a> {
return Err(invalid("expanded package too large"));
}
}
Ok(Self { zip, blobs: BTreeMap::new(), hydrated: 0 })
Ok(Self { zip, blobs: BTreeMap::new(), hydrated: 0, document: None })
}
// Bind provisional caches and authoritative reconstruction to identical metadata,
// even if another process overwrites the open file in place. Blobs remain hash-checked.
fn document(&mut self, limit: usize) -> Result<Arc<[u8]>> {
if self.document.is_none() {
self.document = Some(self.limited_part("document.json", limit)?.into());
}
self.document.as_ref().filter(|d| d.len() <= limit).cloned().ok_or_else(|| invalid("metadata budget"))
}
fn part(&mut self, name: &str) -> Result<Vec<u8>> {
self.limited_part(name, MAX_DESIGN_BYTES)
@@ -342,11 +403,10 @@ pub fn read_design_preview(bytes: &[u8]) -> Option<DesignPreview> {
if bytes.len() > MAX_DESIGN_BYTES || !is_design_container(bytes) {
return None;
}
read_preview(bytes).ok()
DesignContainerReader::new(Cursor::new(bytes)).ok()?.preview()
}
fn read_preview(bytes: &[u8]) -> Result<DesignPreview> {
let mut reader = PackageReader::new(bytes)?;
fn read_preview<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<DesignPreview> {
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
if !matches!(container.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
return Err(invalid("unsupported container version"));
@@ -357,7 +417,7 @@ fn read_preview(bytes: &[u8]) -> Result<DesignPreview> {
{
return Err(invalid("incompatible preview"));
}
if manifest.get("document").and_then(Value::as_str) != Some(digest(&reader.part("document.json")?).as_str()) {
if manifest.get("document").and_then(Value::as_str) != Some(digest(&reader.document(16 << 20)?).as_str()) {
return Err(invalid("stale preview"));
}
let data = reader.limited_part("cache/preview.bin", MAX_PREVIEW_BYTES)?;
@@ -398,13 +458,103 @@ fn read_preview(bytes: &[u8]) -> Result<DesignPreview> {
Ok(preview)
}
/// A bounded container reader over a seekable source.
///
/// Opening inspects the directory, not the source blobs. `preview` reads only the
/// optional display entries and document metadata; `read_document` then resolves
/// and validates authoritative blobs against the same retained metadata snapshot.
/// Concurrent source changes either retain that document or fail validation.
/// This type does not turn untrusted cached data into editable geometry.
pub struct DesignContainerReader<R: Read + Seek> {
reader: PackageReader<R>,
}
impl<R: Read + Seek> DesignContainerReader<R> {
/// Open a container (not plain JSON), enforcing the same budgets as byte IO.
pub fn new(source: R) -> Result<Self> {
Ok(Self { reader: PackageReader::new(source)? })
}
/// Optional display-only data. Corruption or incompatibility is a cache miss.
pub fn preview(&mut self) -> Option<DesignPreview> {
// An explicitly saved mesh avoids tessellation. Native cache is the default
// fallback, generating a bounded provisional mesh without source imports.
read_preview(&mut self.reader).ok().or_else(|| native_cache::geometry_preview(self.geometry()?).ok())
}
/// Portable PNG picture only; no source blobs or geometry are evaluated.
pub fn thumbnail(&mut self) -> Option<Vec<u8>> {
thumbnail::read(&mut self.reader).ok()
}
/// Native geometry is provisional, never a substitute for rebuilding the document.
pub fn geometry(&mut self) -> Option<Vec<DesignGeometry>> {
let document = self.reader.document(16 << 20).ok()?;
native_cache::read_geometry_with(&mut self.reader, &document).ok()
}
/// Consume the reader, resolving every required blob before returning a document.
pub fn read_document(mut self) -> Result<Document> {
read_document(&mut self.reader)
}
}
pub(super) fn read(bytes: &[u8]) -> Result<Document> {
let mut reader = PackageReader::new(bytes)?;
DesignContainerReader::new(Cursor::new(bytes))?.read_document()
}
/// Open from disk (or browser VFS), publishing only provisional display before
/// authoritative blob hydration. Desktop keeps one seekable file handle instead
/// of buffering the complete archive. JSON continues through the normal migrations.
pub fn read_design_file(path: &str, notify: Option<impl FnOnce(DesignPreview)>) -> Result<Document> {
#[cfg(not(target_arch = "wasm32"))]
let mut source = {
let metadata = std::fs::metadata(path).map_err(invalid)?;
if !metadata.is_file() {
return Err(invalid("not a regular file"));
}
if metadata.len() > MAX_DESIGN_BYTES as u64 {
return Err(invalid("file too large"));
}
let file = std::fs::File::open(path).map_err(invalid)?;
if !file.metadata().map_err(invalid)?.is_file() {
return Err(invalid("not a regular file"));
}
file
};
#[cfg(target_arch = "wasm32")]
let mut source = Cursor::new(crate::vfs::read(path, MAX_DESIGN_BYTES).map_err(invalid)?);
let size = source.seek(SeekFrom::End(0)).map_err(invalid)?;
if size > MAX_DESIGN_BYTES as u64 {
return Err(invalid("file too large"));
}
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
let mut prefix = [0u8; 4];
if size >= 4 {
source.read_exact(&mut prefix).map_err(invalid)?;
}
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
if is_design_container(&prefix) {
let mut reader = DesignContainerReader::new(source)?;
if let Some(notify) = notify
&& let Some(preview) = reader.preview()
{
notify(preview);
}
reader.read_document()
} else {
let mut bytes = Vec::new();
source.take(MAX_DESIGN_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
crate::read_design(&bytes)
}
}
fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Document> {
let version: Value = serde_json::from_slice(&reader.part("container.json")?).map_err(invalid)?;
if !matches!(version.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
return Err(invalid("unsupported container version"));
}
let bytes = reader.part("document.json")?;
let bytes = reader.document(MAX_DESIGN_BYTES)?;
let metadata: Value = serde_json::from_slice(&bytes).map_err(invalid)?;
let text = std::str::from_utf8(&bytes).map_err(invalid)?;
// Migrations and all document validation run on the small parametric metadata.
@@ -450,6 +600,203 @@ mod tests {
use super::*;
use solidcraft_geom::Vec2;
struct CountingReader {
source: Cursor<Vec<u8>>,
count: std::rc::Rc<std::cell::Cell<usize>>,
}
impl Read for CountingReader {
fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
let count = self.source.read(bytes)?;
self.count.set(self.count.get() + count);
Ok(count)
}
}
impl Seek for CountingReader {
fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
self.source.seek(position)
}
}
#[test]
fn seekable_reader_rejects_oversized_source_before_reading() {
struct Oversized;
impl Read for Oversized {
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
panic!("oversized input must be rejected before any allocation or read");
}
}
impl Seek for Oversized {
fn seek(&mut self, _: SeekFrom) -> std::io::Result<u64> {
Ok(MAX_DESIGN_BYTES as u64 + 1)
}
}
assert!(DesignContainerReader::new(Oversized).is_err());
}
#[test]
fn seekable_preview_defers_large_source_blob_until_document_read() {
let mut doc = Document::new("Deferred source");
let step = "ISO-10303-21;".repeat(200_000);
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone(), components: Vec::new() }, None).unwrap();
let preview = DesignPreview { meshes: vec![mesh()] };
let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
let count = std::rc::Rc::new(std::cell::Cell::new(0));
let source = CountingReader { source: Cursor::new(bytes), count: count.clone() };
let mut reader = DesignContainerReader::new(source).unwrap();
assert_eq!(reader.preview().unwrap().meshes[0].positions, preview.meshes[0].positions);
// ZIP directory scans may touch a bounded suffix of a stored blob. They
// must not load its multi-megabyte payload to obtain the saved picture.
assert!(count.get() < 256 * 1024, "read {} bytes before display", count.get());
let restored = reader.read_document().unwrap();
assert_eq!(restored.to_json(), doc.to_json());
assert!(count.get() >= step.len());
}
#[test]
fn seekable_reader_validates_required_blobs_after_preview() {
let doc = document();
let preview = DesignPreview { meshes: vec![mesh()] };
let mut bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
let marker = b"ISO-10303-21;";
let offset = bytes.windows(marker.len()).position(|part| part == marker).unwrap();
bytes[offset] ^= 1;
let mut reader = DesignContainerReader::new(Cursor::new(bytes)).unwrap();
assert!(reader.preview().is_some());
assert!(reader.read_document().is_err());
}
#[test]
fn seekable_reader_without_preview_preserves_authoritative_document() {
let doc = document();
let bytes = write_design_container(&doc).unwrap();
let mut source = Cursor::new(bytes);
source.set_position(17); // Constructor starts from the whole source, not its cursor.
let mut reader = DesignContainerReader::new(source).unwrap();
assert!(reader.preview().is_none());
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
assert!(DesignContainerReader::new(Cursor::new(vec![0u8; 1])).is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn file_reader_refuses_nonfiles_before_notifying_preview() {
let directory = std::env::temp_dir();
let result = read_design_file(directory.to_str().unwrap(), Some(|_| panic!("non-file supplied a preview")));
assert!(result.is_err_and(|e| e.to_string().contains("not a regular file")));
}
fn native_geometry() -> Vec<DesignGeometry> {
vec![DesignGeometry {
name: "Cube".into(),
body: solidcraft_kernel::box_solid(solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::new(10., 20., 30.)).unwrap(),
}]
}
#[test]
fn native_cache_round_trip_and_preview_defer_original_sources() {
let mut doc = Document::new("Native cache");
let step = "ISO-10303-21;".repeat(200_000);
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone(), components: Vec::new() }, None).unwrap();
let bodies = native_geometry();
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&bodies)).unwrap();
let entries = parts(&bytes);
assert!(entries.contains_key("cache/brep.bin"));
assert!(!entries.contains_key("cache/preview.bin"));
assert_eq!(read_design_geometry(&bytes).unwrap()[0].body.face_count(), bodies[0].body.face_count());
assert_eq!(bytes, write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&bodies)).unwrap());
let count = std::rc::Rc::new(std::cell::Cell::new(0));
let mut reader = DesignContainerReader::new(CountingReader { source: Cursor::new(bytes), count: count.clone() }).unwrap();
assert!(!reader.preview().unwrap().meshes.is_empty());
assert!(count.get() < 256 * 1024, "read {} bytes before native display", count.get());
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
assert!(count.get() >= step.len());
}
#[test]
fn native_cache_mismatch_is_only_a_cache_miss() {
let doc = document();
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&native_geometry())).unwrap();
let original = parts(&bytes);
for key in ["format", "producer", "app", "document", "payload"] {
let mut entries = original.clone();
let mut manifest: Value = serde_json::from_slice(&entries["cache/key.json"]).unwrap();
manifest[key] = json!("unsupported-or-stale");
entries.insert("cache/key.json".into(), serde_json::to_vec(&manifest).unwrap());
let bytes = package(&entries);
assert!(read_design_geometry(&bytes).is_none());
assert!(read_design_preview(&bytes).is_none());
assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json());
}
let mut entries = original;
entries.get_mut("cache/brep.bin").unwrap()[0] ^= 1;
let bytes = package(&entries);
assert!(read_design_geometry(&bytes).is_none());
assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json());
}
#[test]
fn metadata_snapshot_survives_in_place_changes_after_preview() {
use std::{cell::RefCell, rc::Rc};
struct Shared {
bytes: Rc<RefCell<Vec<u8>>>,
position: u64,
}
impl Read for Shared {
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
let bytes = self.bytes.borrow();
let mut cursor = Cursor::new(bytes.as_slice());
cursor.set_position(self.position);
let n = cursor.read(out)?;
self.position = cursor.position();
Ok(n)
}
}
impl Seek for Shared {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
let bytes = self.bytes.borrow();
let mut cursor = Cursor::new(bytes.as_slice());
cursor.set_position(self.position);
self.position = cursor.seek(pos)?;
Ok(self.position)
}
}
let doc = document();
let preview = DesignPreview { meshes: vec![mesh()] };
let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
let offset = zip::ZipArchive::new(Cursor::new(&bytes)).unwrap().by_name("document.json").unwrap().data_start() as usize;
let bytes = Rc::new(RefCell::new(bytes));
let mut reader = DesignContainerReader::new(Shared { bytes: bytes.clone(), position: 0 }).unwrap();
assert!(reader.preview().is_some());
bytes.borrow_mut()[offset] ^= 0xff;
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
assert!(crate::read_design(&bytes.borrow()).is_err());
}
#[test]
fn native_cache_prefixes_and_mutations_never_panic() {
let bytes = write_design_container_with_cache(&document(), DesignCompression::Fast, None, Some(&native_geometry())).unwrap();
for n in (0..bytes.len()).step_by((bytes.len() / 128).max(1)) {
assert!(
std::panic::catch_unwind(|| {
let _ = read_design_preview(&bytes[..n]);
let _ = read_design_geometry(&bytes[..n]);
})
.is_ok()
);
let mut changed = bytes.clone();
changed[n] ^= 0xff;
assert!(
std::panic::catch_unwind(|| {
let _ = read_design_preview(&changed);
let _ = read_design_geometry(&changed);
})
.is_ok()
);
}
}
fn mesh() -> MeshData {
MeshData {
name: "Precise mesh".into(),
@@ -512,6 +859,53 @@ mod tests {
zip.finish().unwrap().into_inner()
}
#[test]
fn portable_thumbnail_is_keyed_bounded_and_does_not_read_sources() {
// IO validates an opaque PNG header/hash; UI tests exercise real PNG decoding.
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
png.extend(240u32.to_be_bytes());
png.extend(150u32.to_be_bytes());
let mut doc = Document::from_json(&document().to_json()).unwrap();
if let FeatureKind::Import { step, .. } = &mut doc.features[0].kind {
*step = "source data".repeat(250_000);
}
let bytes = write_design_container_with_thumbnail(&doc, DesignCompression::Fast, None, None, Some(&png)).unwrap();
let count = std::rc::Rc::new(std::cell::Cell::new(0));
let mut reader = DesignContainerReader::new(CountingReader { source: Cursor::new(bytes.clone()), count: count.clone() }).unwrap();
assert_eq!(reader.thumbnail(), Some(png.clone()));
assert!(count.get() < 256 * 1024);
assert_eq!(reader.read_document().unwrap(), doc);
let original = parts(&bytes);
for field in ["format", "app", "document", "payload"] {
let mut entries = original.clone();
let mut key: Value = serde_json::from_slice(&entries["cache/thumbnail.json"]).unwrap();
key[field] = json!("mismatch");
entries.insert("cache/thumbnail.json".into(), serde_json::to_vec(&key).unwrap());
let changed = package(&entries);
assert!(read_design_thumbnail(&changed).is_none());
assert_eq!(crate::read_design(&changed).unwrap(), doc);
}
let mut entries = original.clone();
let source = entries.keys().find(|k| k.ends_with(".step")).unwrap().clone();
entries.insert(source, b"corrupt source".to_vec());
let changed = package(&entries);
assert_eq!(read_design_thumbnail(&changed), Some(png));
assert!(crate::read_design(&changed).is_err());
for n in [0, 1, 8, bytes.len() / 2, bytes.len() - 1] {
assert!(read_design_thumbnail(&bytes[..n]).is_none());
}
for i in (0..bytes.len()).step_by((bytes.len() / 128).max(1)) {
let mut changed = bytes.clone();
changed[i] ^= 0xff;
assert!(std::panic::catch_unwind(|| read_design_thumbnail(&changed)).is_ok());
}
for invalid in [vec![], vec![0; 300_000]] {
let bytes = write_design_container_with_thumbnail(&doc, DesignCompression::Fast, None, None, Some(&invalid)).unwrap();
assert!(read_design_thumbnail(&bytes).is_none());
assert_eq!(crate::read_design(&bytes).unwrap(), doc);
}
}
#[test]
fn optional_preview_is_bounded_keyed_and_never_authoritative() {
let doc = Document::from_json(&document().to_json()).unwrap();
@@ -676,7 +1070,7 @@ mod tests {
let mut entries = parts(&bytes);
let mut metadata: Value = serde_json::from_slice(&entries["document.json"]).unwrap();
let reference = metadata["features"][0].clone();
let mut reader = PackageReader::new(&bytes).unwrap();
let mut reader = PackageReader::new(Cursor::new(&bytes)).unwrap();
reader.hydrated = MAX_EXPANDED;
assert!(reader.blob(&reference, "step_blob", "step").unwrap_err().to_string().contains("resolved payloads too large"));
for name in ["../escape.step", "blobs/not-a-hash.step", "blobs/00.png"] {
@@ -696,6 +1090,10 @@ mod tests {
std::panic::catch_unwind(|| {
let _ = crate::read_design(&bytes[..end]);
let _ = read_design_preview(&bytes[..end]);
if let Ok(mut reader) = DesignContainerReader::new(Cursor::new(&bytes[..end])) {
let _ = reader.preview();
let _ = reader.read_document();
}
})
.is_ok(),
"truncated at {end}"
@@ -713,6 +1111,10 @@ mod tests {
std::panic::catch_unwind(|| {
let _ = crate::read_design(&changed);
let _ = read_design_preview(&changed);
if let Ok(mut reader) = DesignContainerReader::new(Cursor::new(&changed)) {
let _ = reader.preview();
let _ = reader.read_document();
}
})
.is_ok()
);
+128
View File
@@ -0,0 +1,128 @@
//! Optional native cache. Its SolidCraft schema is independent of the producing kernel.
use super::*;
const PRODUCER: &str = "solidcraft-truck-adapter/2";
/// Provisional evaluated geometry. Never an editable document state.
#[derive(Clone)]
pub struct DesignGeometry {
pub name: String,
pub body: solidcraft_kernel::Body,
}
pub(super) fn encode_geometry(bodies: &[DesignGeometry], document: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
if bodies.is_empty() || bodies.len() > 4096 {
return Err(invalid("native body count"));
}
let mut data = Vec::new();
let mut entries = Vec::new();
let mut faces = 0usize;
for entry in bodies {
faces = faces.saturating_add(entry.body.face_count());
if faces > 100_000 || entry.name.len() > 1024 {
return Err(invalid("native geometry budget"));
}
let bytes = solidcraft_kernel::encode_native_body(&entry.body).map_err(invalid)?;
let offset = data.len();
if offset.saturating_add(bytes.len()) > 64 << 20 {
return Err(invalid("native cache size"));
}
entries.push(json!({"name":entry.name,"offset":offset,"length":bytes.len()}));
data.extend(bytes);
}
let manifest = serde_json::to_vec(&json!({
"format":"solidcraft-brep-cache/1", "producer":PRODUCER,
"app":env!("CARGO_PKG_VERSION"), "document":digest(document), "payload":digest(&data), "bodies":entries
}))
.map_err(invalid)?;
if manifest.len() > 1 << 20 {
return Err(invalid("native manifest size"));
}
Ok((manifest, data))
}
/// Read only metadata and the native cache. Original blobs are not read/decompressed
/// until the authoritative document reader needs them for background reconstruction.
/// Any optional geometry failure is a miss, never a document error.
pub fn read_design_geometry(bytes: &[u8]) -> Option<Vec<DesignGeometry>> {
if bytes.len() > MAX_DESIGN_BYTES || !is_design_container(bytes) {
return None;
}
DesignContainerReader::new(Cursor::new(bytes)).ok()?.geometry()
}
pub(super) fn read_geometry_with<R: Read + Seek>(reader: &mut PackageReader<R>, document: &[u8]) -> Result<Vec<DesignGeometry>> {
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
if container.get("format").and_then(Value::as_str) != Some(VERSION) {
return Err(invalid("container version"));
}
let manifest: Value = serde_json::from_slice(&reader.limited_part("cache/key.json", 1 << 20)?).map_err(invalid)?;
if manifest.get("format").and_then(Value::as_str) != Some("solidcraft-brep-cache/1")
|| manifest.get("producer").and_then(Value::as_str) != Some(PRODUCER)
|| manifest.get("app").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
{
return Err(invalid("native cache version"));
}
if manifest.get("document").and_then(Value::as_str) != Some(digest(document).as_str()) {
return Err(invalid("native document key"));
}
// Validate the small parametric metadata before showing a provisional model.
Document::from_json(std::str::from_utf8(document).map_err(invalid)?)?;
let data = reader.limited_part("cache/brep.bin", 64 << 20)?;
if manifest.get("payload").and_then(Value::as_str) != Some(digest(&data).as_str()) {
return Err(invalid("native payload key"));
}
let entries =
manifest.get("bodies").and_then(Value::as_array).filter(|b| !b.is_empty() && b.len() <= 4096).ok_or_else(|| invalid("native bodies"))?;
let mut bodies = Vec::new();
let mut end = 0usize;
let mut faces = 0usize;
for entry in entries {
let n = |key| entry.get(key).and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or_else(|| invalid("native slice"));
let offset = n("offset")?;
let length = n("length")?;
if offset != end {
return Err(invalid("overlapping native bodies"));
}
end = end.checked_add(length).ok_or_else(|| invalid("native length overflow"))?;
let name = entry.get("name").and_then(Value::as_str).filter(|n| n.len() <= 1024).ok_or_else(|| invalid("native body name"))?.to_owned();
let body = solidcraft_kernel::decode_native_body(data.get(offset..end).ok_or_else(|| invalid("truncated native body"))?).map_err(invalid)?;
faces = faces.saturating_add(body.face_count());
if faces > 100_000 {
return Err(invalid("native face budget"));
}
bodies.push(DesignGeometry { name, body });
}
if end != data.len() {
return Err(invalid("trailing native data"));
}
Ok(bodies)
}
pub(super) fn geometry_preview(bodies: Vec<DesignGeometry>) -> Result<DesignPreview> {
let mut preview = DesignPreview::default();
let mut triangles = 0usize;
let mut display_bytes = 0usize;
let faces: usize = bodies.iter().map(|b| b.body.face_count()).sum();
let detail = if faces > 20_000 {
1
} else if faces > 4_000 {
4
} else {
8
};
let mut work = 1_000_000usize;
for entry in bodies {
let mesh = solidcraft_kernel::native_preview_mesh(&entry.body, detail, &mut work).map_err(invalid)?;
triangles = triangles.saturating_add(mesh.triangles.len());
display_bytes = display_bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(12));
if triangles > 1_000_000 || display_bytes > 128 << 20 {
return Err(invalid("regenerated display budget"));
}
let displayed = MeshData {
name: entry.name,
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
triangles: mesh.triangles.iter().flatten().copied().collect(),
color: entry.body.color(),
};
validate_mesh(&displayed)?;
preview.meshes.push(displayed);
}
Ok(preview)
}
+77
View File
@@ -0,0 +1,77 @@
//! Portable, disposable PNG pictures. Reading never resolves source blobs.
use super::*;
pub const THUMBNAIL_SIZE: [usize; 2] = [240, 150];
const MAX_BYTES: usize = 256 << 10;
const FORMAT: &str = "solidcraft-thumbnail/1";
fn valid_header(bytes: &[u8]) -> bool {
bytes.len() <= MAX_BYTES
&& bytes.get(..8) == Some(b"\x89PNG\r\n\x1a\n")
&& bytes.get(8..16) == Some(b"\0\0\0\rIHDR")
&& bytes.get(16..20) == Some((THUMBNAIL_SIZE[0] as u32).to_be_bytes().as_slice())
&& bytes.get(20..24) == Some((THUMBNAIL_SIZE[1] as u32).to_be_bytes().as_slice())
}
pub(super) fn write(writer: &mut PackageWriter, png: &[u8], document: &[u8]) -> Result<()> {
if !valid_header(png) {
return Ok(());
}
let key = serde_json::to_vec(&json!({"format": FORMAT, "app": env!("CARGO_PKG_VERSION"), "document": digest(document), "payload": digest(png)}))
.map_err(invalid)?;
if writer.names.len().saturating_add(2) > MAX_ENTRIES
|| writer
.expanded
.saturating_add(key.len())
.saturating_add(png.len())
.saturating_add(writer.names.len().saturating_mul(256))
.saturating_add(65_536)
> MAX_DESIGN_BYTES
{
return Ok(());
}
writer.part("cache/thumbnail.json", &key, false)?;
writer.part("cache/thumbnail.png", png, false)
}
pub(super) fn read<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Vec<u8>> {
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
if container.get("format").and_then(Value::as_str) != Some(VERSION) {
return Err(invalid("thumbnail container version"));
}
let key: Value = serde_json::from_slice(&reader.limited_part("cache/thumbnail.json", 4096)?).map_err(invalid)?;
let document = reader.document(16 << 20)?;
if key.get("format").and_then(Value::as_str) != Some(FORMAT)
|| key.get("app").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
|| key.get("document").and_then(Value::as_str) != Some(digest(&document).as_str())
{
return Err(invalid("thumbnail key"));
}
let png = reader.limited_part("cache/thumbnail.png", MAX_BYTES)?;
if !valid_header(&png) || key.get("payload").and_then(Value::as_str) != Some(digest(&png).as_str()) {
return Err(invalid("thumbnail payload"));
}
// Consumers must also use a bounded PNG decoder; the header is not a decode.
Ok(png)
}
/// Optional PNG bytes, bounded and keyed to this exact document. Consumers must
/// decode with dimension/allocation limits; invalid pictures are cache misses.
pub fn read_design_thumbnail(bytes: &[u8]) -> Option<Vec<u8>> {
DesignContainerReader::new(Cursor::new(bytes)).ok()?.thumbnail()
}
/// Read a portable picture without importing or reading the source blobs.
pub fn read_design_thumbnail_file(path: &str) -> Option<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
let source = {
let metadata = std::fs::metadata(path).ok()?;
if !metadata.is_file() || metadata.len() > MAX_DESIGN_BYTES as u64 {
return None;
}
let source = std::fs::File::open(path).ok()?;
let metadata = source.metadata().ok()?;
if !metadata.is_file() || metadata.len() > MAX_DESIGN_BYTES as u64 {
return None;
}
source
};
#[cfg(target_arch = "wasm32")]
let source = Cursor::new(crate::vfs::read(path, MAX_DESIGN_BYTES).ok()?);
DesignContainerReader::new(source).ok()?.thumbnail()
}
+36 -1
View File
@@ -3,7 +3,12 @@
//! the legacy folder is copied over. The legacy folder is never changed or deleted, so an older
//! release installed side by side keeps working.
use std::path::Path;
use std::path::{Path, PathBuf};
/// The Linux app id (Flatpak, desktop entry, AppStream, Wayland `app_id`).
pub const APP_ID: &str = "ai.storyteller.solidcraft";
/// The app id of releases before 0.8 (compatibility shim, docs/compatibility.md).
pub const LEGACY_APP_ID: &str = "ai.storyteller.solvecraft";
/// Most files and nesting copied from a legacy folder (it holds preferences, autosaves and
/// small caches; anything bigger is not ours).
@@ -22,6 +27,25 @@ pub fn adopt_legacy_dir(current: &Path, legacy: &Path) -> std::io::Result<bool>
Ok(true)
}
/// In the Flatpak sandbox, the same `path` under the pre-rename app's data folder: Flatpak keeps
/// each app's XDG folders under `~/.var/app/<app id>`, so after the app id changed, settings and
/// autosaves of earlier Flatpak releases sit under the old id. `None` outside the sandbox or for
/// paths outside our own `~/.var/app` folder. Flatpak moves that folder itself when an install
/// follows the end-of-life rebase; this covers installs made next to the old app, which reach it
/// read-only through the manifest's `--filesystem` grant.
pub fn legacy_flatpak_path(path: &Path) -> Option<PathBuf> {
flatpak_twin(path, &std::env::var_os("FLATPAK_ID")?, Path::new(&std::env::var_os("HOME")?))
}
fn flatpak_twin(path: &Path, flatpak_id: &std::ffi::OsStr, home: &Path) -> Option<PathBuf> {
if flatpak_id != APP_ID {
return None;
}
let apps = home.join(".var").join("app");
let rel = path.strip_prefix(apps.join(APP_ID)).ok()?;
Some(apps.join(LEGACY_APP_ID).join(rel))
}
fn copy_tree(from: &Path, to: &Path, depth: usize, budget: &mut usize) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
if depth >= MAX_DEPTH {
@@ -75,6 +99,17 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn flatpak_twin_maps_our_sandbox_data_to_the_old_app_id() {
let home = Path::new("/home/u");
let ours = home.join(".var/app/ai.storyteller.solidcraft/data/solidcraft");
let id = std::ffi::OsStr::new(APP_ID);
assert_eq!(flatpak_twin(&ours, id, home), Some(home.join(".var/app/ai.storyteller.solvecraft/data/solidcraft")));
// Outside our sandbox folder, or in another app's sandbox, there is nothing to map.
assert_eq!(flatpak_twin(&home.join(".local/share/solidcraft"), id, home), None);
assert_eq!(flatpak_twin(&ours, std::ffi::OsStr::new("org.example.Other"), home), None);
}
#[test]
fn nothing_to_adopt() {
let d = scratch("none");
+5 -3
View File
@@ -10,8 +10,10 @@ mod design;
mod design_metadata;
mod dxf;
pub use design::{
DesignCompression, DesignPreview, is_design_container, read_design_preview, write_design_container, write_design_container_with_compression,
write_design_container_with_preview,
DesignCompression, DesignContainerReader, DesignGeometry, DesignPreview, THUMBNAIL_SIZE, is_design_container, read_design_file,
read_design_geometry, read_design_preview, read_design_thumbnail, read_design_thumbnail_file, write_design_container,
write_design_container_with_cache, write_design_container_with_compression, write_design_container_with_preview,
write_design_container_with_thumbnail,
};
mod legacy_dir;
pub mod safe_write;
@@ -21,7 +23,7 @@ mod threemf;
pub mod vfs;
pub use dxf::{read_dxf, write_dxf};
pub use legacy_dir::adopt_legacy_dir;
pub use legacy_dir::{APP_ID, LEGACY_APP_ID, adopt_legacy_dir, legacy_flatpak_path};
pub use safe_write::{backup_path, write_atomic};
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
pub use svg::read_svg;
+4 -1
View File
@@ -114,7 +114,10 @@ fn attrs(e: &BytesStart) -> Vec<(String, String)> {
.flatten()
.map(|a| {
let k = String::from_utf8_lossy(a.key.local_name().as_ref()).to_string();
let v = a.unescape_value().map(|v| v.to_string()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
let v = a
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.map(|v| v.to_string())
.unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
(k, v)
})
.collect()
+4 -1
View File
@@ -319,7 +319,10 @@ fn attrs(e: &BytesStart) -> HashMap<String, String> {
let mut m = HashMap::new();
for a in e.attributes().flatten() {
let k = String::from_utf8_lossy(a.key.local_name().as_ref()).to_string();
let v = a.unescape_value().map(|v| v.to_string()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
let v = a
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.map(|v| v.to_string())
.unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
m.insert(k, v);
}
m

Some files were not shown because too many files have changed in this diff Show More