Compare commits

...

173 Commits

Author SHA1 Message Date
bherbruck 200a83a629 Release 0.9.0 (#832)
Bumps the workspace to 0.9.0 and writes the release notes. Minor bump: designs are now format 17, and there are many new features.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:47:06 -04:00
William Dunn d302aa1a00 Construction Geometries, a Sketch Palette row that hides construction geometry (#804)
A Sketch Palette toggle (sketch.options show_construction) that hides construction geometry from drawing and picking; saved with the sketch's view options under format 17.

Co-authored-by: William Dunn <william.dunn@quasarke.com>
2026-10-13 15:15:13 -04:00
NOWN-DEV 9184994721 fix: coincident faces are pushed apart only where that changes nothing (#817)
A coincident face is pushed apart only when the space it moves through holds nothing that changes the result; otherwise the boolean falls through to the exact strategies.

Co-authored-by: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com>
2026-10-13 15:10:31 -04:00
bherbruck d2676da789 perf: imprint partial face contacts only near the contact (#825)
The partial-contact imprint (#704, #751) split every face in the contact plane against every tool face, one call per face, and measured all the tool's cylinders for each pair: the sensor enclosure's Case shell went from 6.6 s to 76 s and timed out in the CI matrix. Only faces near the contact are split now, each surface once, in one call: 7.0 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:06:55 -04:00
bherbruck 6a6a7e2865 test: give an action-matrix design 300 s to load (#812)
Loading a design (from a worker's start or restart to its first output) gets 300 s; each action keeps 90 s. oracle/41-tube-radial-holes (about 23 s here) timed out loading on a loaded CI runner, and reloads after a hung action were charged to the next action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:59:36 -04:00
William Dunn 3ea8562c5e A tool window for the rectangular sketch pattern (#799)
sketch.pattern.rectangular gets a tool window with both quantities and spacings and an optional direction, instead of a click flow with a fixed count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-13 14:54:48 -04:00
Irakli Kourtis e540832626 Surface Extrude: sheets from open curves and profile walls (#815)
surface.extrude sweeps open curve chains and profile boundaries along the sketch normal into a surface body (walls only, no caps). Saved under format 17.

Refs #757

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:54:38 -04:00
Irakli Kourtis 8dd583930e Display tessellation plans (2/4): size rules and the facet angle (#803)
Per-face target side lengths, the caller's facet angle, refined grid lines, and fixes for underflowing coordinates and straight edges ending exactly on their vertex. Nothing drawn changes yet.

Refs #303

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:45:54 -04:00
HiroKenEz 9bb18842e9 Convert Mesh says how big a mesh is, the limit, and what converts (#797)
A mesh over the conversion budget is refused with its triangle (or vertex) count, the limit, and that only constant-section parts convert.

Refs #346

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:43:29 -04:00
Mateusz Sych 9cabc3532d STL export welds shared vertices like the 3MF (#816)
STL (binary and ASCII) now welds each body's export mesh with the same tolerance as 3MF, so filleted and drilled bodies are closed. The weld grid's neighbour lookup no longer overflows for bodies far from the origin.

Fixes #784
2026-10-13 14:39:20 -04:00
William Dunn bdcfe3ff1d One tool window for sketch Fillet and the three sketch Chamfers (#789)
Sketch Fillet and the three sketch Chamfers share one tool window, with its own icons, labels and a scenario.
2026-10-13 14:32:13 -04:00
semmelrinde dbfb689f6c Single-hole snapping takes only shown points and checks the face once per pick (#798)
Follow-up to #718: a face place snaps only to sketch points that are shown, and the face under the cursor is looked up once per pick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:28:54 -04:00
Irakli Kourtis 17b4b9a32c Reference case for a fillet with a B-spline radius (#810)
Adds oracle case 81 (a box edge rounded at a clamped cubic B-spline radius) with analytic expected values, and passes spline fillets through recipes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:28:49 -04:00
Irakli Kourtis c8d668108a Surface toolset: icons and planned commands (#808)
Icons for the surface toolset, and its planned commands registered and disabled with the issue that tracks each one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:28:38 -04:00
NOWN-DEV dd084ec9e1 Sheet metal: tear as a fourth corner relief shape (#809)
A sheet metal rule's corner relief shape can now be tear; saved under format 17 with a note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:28:28 -04:00
Irakli Kourtis bfbf5f9432 Display tessellation plans (1/4): planner with shared edge nodes (#802)
Adds TessPlan and the kernel planner (shared edge nodes, structured and Coons grids, constrained Delaunay for trimmed faces) behind Body::display_plan. Nothing drawn changes yet.

Refs #303

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:27:44 -04:00
William Dunn facff1050e Put Move/Copy Sketch Entities on the toolbar, behind a window with distances, an angle and a copy flag (#735)
Sketch Move gets a toolbar placement in Sketch > Modify and a Move/Copy tool window with X and Y distances, an angle, an optional centre point and a Create Copy flag, replacing the context-menu route that ran the command with no parameters.
2026-10-13 13:48:13 -04:00
William Dunn de1479029d A sketch dialog and the Sketch Palette on screen at the same time (#791)
A command dialog opened while sketching no longer hides the Sketch Palette: the dialog docks at the left of the viewport and the palette keeps the right. A view too narrow for both keeps the dialog alone.
2026-10-13 13:41:46 -04:00
William Dunn 16766bdbc7 Edit Sketch in the viewport menu over a sketch curve (#795)
Right-clicking a curve or point of a sketch that isn't being edited now opens that sketch's own menu, with Edit Sketch, instead of the body menu. Solid geometry in the selection keeps the body menu.
2026-10-13 13:37:54 -04:00
William Dunn d4fd760b0d A Mirror window for sketch entities, holding both picks until OK (#734)
Sketch Mirror now opens a tool window with Entities and Mirror line inputs, so both picks stay visible and editable until OK, replacing the old click-then-right-click flow.
2026-10-13 13:29:26 -04:00
Mithril_MEX d437c21368 feat(i18n): complete Japanese localization and eliminate UI hardcoded strings (#753)
Completes the Japanese catalog (all commands, panels, dialogs and preferences) and routes the remaining hard-coded UI labels through the translation lookup. English stays the default.
2026-10-13 13:24:24 -04:00
Alexander Nutz a8b3b21fd0 Feature history on the left side, with contiguous folders (#449)
The feature history can sit in a panel on the left, with folders that hold one unbroken run of features, drag and drop as one undo step, and a resizable divider. Timeline folders save under format 17.
2026-10-13 13:01:31 -04:00
Mateusz Sych be55a49b04 docs: agent skills for modeling and editing through MCP (#728)
Two agent skills for modeling and editing through the MCP server, with a command reference and model-only screenshot examples.
2026-10-13 12:49:02 -04:00
selune 293d660d6c fix(kernel): preserve intentional Split Face lines in contact joins (#782)
Intentional Split Face lines survive a join with partial planar contact, so the far face stays in two pieces. Follow-up to #704 / #674.
2026-10-13 12:46:34 -04:00
William Dunn beca7eb126 A tool window for sketch Offset, with a typed Distance, Flip and Chain (#731)
Sketch Offset gets its own tool window with a typed Distance, Flip and Chain, as a variant of the sketch modify window.
2026-10-13 12:46:24 -04:00
bherbruck 1a0100cdd9 test: record the action-matrix outcomes that changed on main (#781)
26 baseline actions that fail on main: 17 are the retry guard's refusals of geometry that used to delete a body or fill past an edge (#644), 9 vary between runs (#707). Load timeouts left as they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:37:05 -04:00
Irakli Kourtis 5267c96ab2 feat: drag a fillet's radius spline by its handles on the edge (#752)
A spline fillet's radius can now be dragged by handles on the edge, and the spline radius saves under format 17.
2026-10-13 12:35:42 -04:00
semmelrinde 159f9913c1 fix(sketch): a side put onto a projected line keeps its closed profile (#635) (#780)
A side constrained onto a projected line keeps the sketch's closed profile. Fixes #635.
2026-10-13 12:35:33 -04:00
semmelrinde dfa9e094cd fix: a size worked out from a bare diameter keeps its unit when the design's units change (#716) (#779)
A size typed as a bare diameter now keeps the design's length unit, so it no longer changes when the design's units change. Fixes #716.
2026-10-13 12:35:25 -04:00
Irakli Kourtis 8cdefd4cd5 test: reference cases for variable, chord-length and tapered fillets (#776)
Four analytic reference cases for variable, to-zero, chord-length and taper-to-fit fillets, with the formulas in tools/oracle-analytic.
2026-10-13 12:35:16 -04:00
Mqrius 7b0a1b296c perf(kernel): a refused edge is not rolled again after blends far from it (#777)
A blend no longer re-rolls an edge it already refused unless a later blend came near it, so long curved chains stop paying for the same refusal over and over.
2026-10-13 12:35:09 -04:00
Irakli Kourtis 25a3dc501b docs(roadmap): list the proposed surface toolset under M8 (#773)
List the proposed surface toolset under M8 in the roadmap and link the scope discussion.
2026-10-13 12:35:02 -04:00
bherbruck a3dfbaa0b0 Booleans build the same body every time: face order no longer follows heap addresses (#748)
truck's face and edge IDs are their addresses, so walking maps keyed by them followed heap layout, and the ribbed lid's side-wall hole passed or failed by chance. The planar boolean's rebuild and glue now take truck's connected components in the shell's order, and the planar boolean's own maps are ordered. Healing's union-find keeps the lesser root and takes a run's ends in order of use. The face classification after an intersection orders each component's faces by the shell first. The lid now builds the same body on every run, with a test that builds it six times in one process. Its 0.5 mm side-wall holes, and one sensor enclosure hole of the same kind, now fail every time and are marked failing in the baseline.

Fixes #707.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:31:23 -04:00
NOWN-DEV 58af8accfa fix: a projected edge stays the edge that was picked when its body changes (#730)
A sketch's link to a projected edge now keeps the edge's persistent name and finds it by that name when the body changes, instead of taking whichever edge ends up nearest its point.
2026-10-13 12:06:13 -04:00
William Dunn 4a989f6e2d Three readable line type icons in the Sketch Palette, with construction orange moved to a caller contract (#747)
The Sketch Palette's line types are three drawn icons (solid, one gap, gap with a dot) instead of text glyphs, and the construction orange is now chosen by the caller (`fixed_accent` on the toolbar) rather than hardcoded in the icons.
2026-10-13 11:53:59 -04:00
William Dunn aae70053ee Let the conic tool take a typed Rho, and preview the curve it is about to draw (#736)
The conic tool takes a typed Rho in its canvas value box and previews the curve it's about to draw, with the tangent legs as a faint guide.
2026-10-13 11:47:28 -04:00
William Dunn 9b5e39844a A Sketch Scale window, replacing a toolbar button that only raised an engine error (#733)
Sketch Scale now opens a tool window with the entities, an optional base point and a typed factor, instead of running the command with no parameters and raising an engine error.
2026-10-13 11:46:00 -04:00
Irakli Kourtis 1d30e4e1d2 feat: fillet radius tapers along an edge to the room there (#749)
Adds `fit: taper` to Fillet: where a constant radius doesn't fit along a straight edge, it follows the room there as a smooth spline, down to zero where a face closes to a point. Taper to Fit is in the Fillet dialog, and the feature's warning says how far it tapered.
2026-10-13 11:45:33 -04:00
Diogo Soares Rodrigues 1a5e961362 feat(ui): sketch tool shortcuts open a sketch on the selected plane (#737)
Outside a sketch, a sketch tool's shortcut opens Create Sketch on the selected plane or planar face (or asks for one), then starts that tool in the new sketch.
2026-10-13 11:45:15 -04:00
William Dunn ff609bc320 Make SolveApp::start honour a command's enabled flag, and disable solid.move inside a sketch (#740)
`SolveApp::start` now honours a command's enabled flag, so shortcuts, the command palette and the control channel can't start a disabled command, and Move is disabled while a sketch is open.
2026-10-13 11:43:45 -04:00
Mateusz Sych 62a7f903f2 fix: MCP instructions name body_topology, not a list_edges tool (#729)
The MCP server instructions now point to `body_topology` for finding edge points (there is no `list_edges` tool), with a test that every tool the instructions name exists.
2026-10-13 11:38:54 -04:00
bherbruck 11c03fb0ab fix: imprint each surface once when fusing partial face contacts (#751)
Robustness seed 16 failed in most runs: the imprint split a block's side along both halves of a cylinder's round side, and the second split re-traced the first circle, sometimes leaving a sliver the glue could not match. The imprint now splits along each surface once (10/10 runs pass, 3/12 before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:35:27 -04:00
William Dunn 6f4a9d96fb Give the Move dialog the canvas value box every other dialog already has (#739)
The Move dialog gets the same canvas value box as the other dialogs: X for translating moves, the angle for Rotate, placed clear of the triad.
2026-10-13 11:28:50 -04:00
William Dunn 4112189551 A Feature Options section in the Sketch Palette showing the running tool's modes (#750)
Adds a Feature Options section to the Sketch Palette with the running tool's modes as icon buttons, so you can switch between e.g. circle or rectangle variants mid-tool.
2026-10-13 11:28:37 -04:00
Sahen 2bb24d1d2a perf: prism pairs before the mesh, bodies measured once, and a STEP import that copies less (#714)
A cut or an intersection between prisms (a plate and the tools through it) is now decided in 2D before either operand is meshed. A body's size and volume are measured once, a placement reads its orientation from its matrix, the evaluation cache is bounded to 64 MiB, and STEP import copies fewer shells. Hole plates evaluate 20-30% faster with less memory, and models are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:55:56 -04:00
NOWN-DEV a1cb8d4d36 feat: Convert to Sheet Metal takes a solid or a shelled body by its bend edges (#725)
Convert to Sheet Metal now takes a solid or a shelled body by its bend edges (`bends`): the faces reached from the fixed face across those edges become the sheet, keeping the part's envelope, with the flat pattern by the rule's K-factor and radius. Other edges between sheet faces are cut open. The result works with edge flanges, cuts, Unfold/Refold, the flat pattern and DXF. A conversion without `bends` is the plate conversion as before. The optional `bends` field is noted under format 17. Fixes #647.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:52:28 -04:00
Mqrius a513304917 fix(engine): pick the face of a named body where bodies meet flush (face_body) (#710)
Extrude and Revolve from a body face take `face_body` to say whose face is meant where faces of several bodies meet flush at the picked point. Without it, an ambiguous pick now names the bodies that meet there instead of saying there is no face. Fixes #703.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:51:57 -04:00
semmelrinde d7f5b2435e fix(ui): the wheel over a dialog's list scrolls only the list (#678) (#713)
The mouse wheel over a dialog's list, a drop-down or a menu that hangs over the model now scrolls only that list; the view zooms only when the pointer is really over the model. Fixes #678.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:44:29 -04:00
semmelrinde ce5762d873 fix(ui): Box, Cylinder, Sphere, Torus and Coil dialogs show the unit of their sizes (#630) (#709)
The Box, Cylinder, Sphere, Torus and Coil dialogs now start their sizes in the design's units with the unit written out ("20 mm", "20 in"), like the other dialogs. OK makes the same sizes as before. Fixes #630.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:44:10 -04:00
semmelrinde 831fa34752 fix(ui): a Single hole picked at a sketch circle's centre goes to the centre (#657) (#718)
A Single hole picked on or near a sketch point that lies on the face (a circle's centre) now goes to that point, as a Multiple pick does. Picks away from sketch points, or near one that isn't on the face under the cursor, stay where they were clicked. Fixes #657.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:43:47 -04:00
selune 11084a5963 fix(sketch): move and copy editable text as a whole source (#715)
Dragging a text outline now moves the whole editable text, and Move/Copy shifts or rotates it as one piece (a copy stays independently editable). Content, font, height and outline identities are kept; a drag is one undo step and Esc restores the start.
2026-10-13 10:29:02 -04:00
semmelrinde 2c4d197bf4 fix(ui): Extrude and Revolve leave Profiles empty when the last sketch is already used (#684) (#705)
With nothing selected, Extrude and Revolve no longer pre-pick the last sketch's profile when a feature already builds from it, so OK no longer cuts into a part nobody picked. Fixes #684.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:28:17 -04:00
NOWN-DEV 4ff8f3e14d feat: free-form cut-outs stay exact in sheet-metal flat patterns (#692)
Sheet-metal cut-outs and base flange outlines drawn with ellipses, splines or text stay exact curves in the flat pattern and the unfolded body; the folded body follows them with tangent arcs within 0.001 mm (#659).
2026-10-13 10:18:15 -04:00
Irakli Kourtis eac4d66adc feat: variable fillet radius may run down to zero at one end (#675)
A variable fillet's radius may run down to zero at one end, where the round closes to the corner.
2026-10-13 10:17:55 -04:00
Lasse Lian cb071bb93b fix: axis or tangent plane on a drill tip no longer fails with "no face at NaN" (#567) (#655)
The nearest point on a triangle with a zero-length edge (a cone's tip) is no longer NaN, so an axis or tangent plane on a drill tip works again (#567).
2026-10-13 10:17:49 -04:00
selune 0e500cacd3 Render: add backend capability discovery and strict request resolution (#650)
Adds render.backends, a metadata-only command that lists the render backends, their executors and profiles, with strict request resolution in the render crate (docs/render-backends.md).
2026-10-13 10:17:41 -04:00
bherbruck 1e4387f3ea fix: cuts no longer split walls they don't touch (#670)
A cut built slab by slab (the exact prism boolean) sewed the result from horizontal
slabs and only joins merged the coplanar pieces back. So a floor vent cut through the
Raspberry Pi case split the long wall into strips at the ports' tops and bottoms
(87 faces instead of 79 at 32 mm), and at 24 mm the end opening split the end wall.

- Prism booleans merge coplanar neighbours for every operation (faces only: edges,
  curves and surfaces stay exact), keeping each face's construction source where all
  its pieces share it, Split Face lines and regularization protection. Painted or
  protected operands keep their authored pieces as before.
- The prism history now tells apart a wall's several edges with its caps (a wall
  with openings meets each opening's floor), so cuts beside existing openings keep
  exact history and faces keep their names.
- Tests: the Pi case's faces pinned after every feature at 24, at 24 changed to 32,
  and built at 32 (each outer side one face); random openings and bosses on a
  shelled case never split another side; every corpus design rebuilds a parameter
  edit like a fresh build and reverts exactly.
- Action matrix baseline: demo/pi-case re-blessed (fewer, merged faces; no
  regressions), demo/pi-case-0.8 recorded, and 88 new passes on
  regressions/lid_ribs_flush_with_window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:14:52 -04:00
bherbruck 9ba72e118b Sketch: accept redundant but consistent constraints (#687) (#699)
A clover of same-size rectangles drawn around a shared corner was refused as over-constrained.

Constraints that remove no degree of freedom but still hold (zero residual) are now kept and shown dimmed as redundant. Conflicting ones are still refused. A redundant driving dimension is refused with "already determined by ...". Typed sizes become driven (reference) dimensions. Drawing no longer adds Horizontal/Vertical to a side that is already implied through two coincident corners. The solver reports redundant constraints using a Jacobian row-dependence test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:12:55 -04:00
bherbruck add4a18961 fix: fuse bodies that touch back to back over part of a face (#679, #674) (#704)
Two L's extruded up and down from one 2 x 2 grid now join into one solid (the planar rebuild gives each fan of faces around a pinched vertex its own vertex copy), and a pad whose end lies half past the edge of the face it stands on fuses exactly: contact faces are imprinted with each other's outline and the common faces glued away, still held to the retry guard. Face splitting handles a line crossing an edge twice; glue matches differently parametrised curves and keeps several pieces apart. Robustness seed 16 now fuses with the exact volume.

Fixes #679, fixes #674.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:02:44 -04:00
bherbruck 17e42e7b94 Action matrix: mark the ribbed lid's side-wall hole as failing while it varies between runs (#708)
The ratchet has no flaky marker, so regressions/lid_ribs_flush_with_window Lid/F11:+y face.hole is marked as failing until the boolean is made deterministic (#707).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:56:43 -04:00
NOWN-DEV 812669e861 fix: an edge flange runs along what cut-outs leave of its edge (#702)
An edge flange picked on an edge that cut-outs have partly removed is built along what is left of that edge (the stretch nearest the pick), so each side of a notch can get its own flange.
2026-10-13 09:53:53 -04:00
semmelrinde c166c64b8e test: measure a scaling pair again when the load of other test binaries spikes (#649)
The two scaling tests measure a pair again (up to three times, keeping each size's fastest run) when other test binaries slow one down.
2026-10-13 09:38:28 -04:00
bherbruck 0b1e61504d File format 17 for qualified piece names and sheet metal rim names (#700)
0.8.0 shipped format 16. Lineage names of a piece qualified by the faces around it, and sheet face names of a rim (rim.<edge>) or a bend end (rim.bend0/1), now come under format 17. Format 16 designs need no transformation, and the version stamp makes 0.8.0 ask for an update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:34:20 -04:00
bherbruck 7249623855 Home and Fit frame what is shown now; middle double-click goes Home (#701)
Home, Fit, the orbit pivot and the scene's depth range counted sketches the view doesn't draw (ones used by an extrude or revolve, ones in hidden components), so after a body moved away from its sketch Home framed the empty spot it came from. They now use the world-space bounds of what is shown now (view::visible_bounds / view::sketch_drawn, viewport::view_bounds). The pick-box cache holds its world state instead of its address. A middle-button double-click goes Home in every preset (Preferences › Navigation: Home, Fit or Off).

Fixes #689

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:33:55 -04:00
Karl Von Richter 7b34d57b25 feat(ui): offer a cube navigator alongside signed axes (#664)
Adds a View navigator preference (Navigation page) with an optional cube navigator alongside the signed axes, which stay the default (#165).
2026-10-13 09:28:52 -04:00
lucas-aguilar89 aa24d9f071 Pasted features act on the pasted bodies (#613) (#682)
Pasted features now act on the bodies pasted with them instead of the originals (#613).
2026-10-13 09:28:27 -04:00
semmelrinde 319d360c85 fix(ui): a refused OK's error clears once the dialog's inputs change (#641) (#666)
A dialog's error from a refused OK now clears once its inputs, picks or the design change (#641).
2026-10-13 09:28:15 -04:00
selune e10b0b7d22 fix(ui): edit sketch text from letter interiors (#694)
Edit Text now works from a letter's interior as well as its outline, from the tool and the context menu, and highlights the whole text while editing.
2026-10-13 09:28:05 -04:00
lucas-aguilar89 d1a773a684 Save changes prompt: arrow keys pick the button, Enter gives it (#683)
In the save-changes prompt, Left and Right pick a button and Enter gives it.
2026-10-13 09:27:46 -04:00
Jonas Wrede fe1e5f1b95 test: regression test for #623: blind bore into a joined boss fails to mesh (fails on v0.7.0, passes on main) (#654)
Adds a regression test for the blind bore into a joined boss (#623).
2026-10-13 09:27:36 -04:00
bherbruck 1e18ca9a46 Sketch Dimension: D, click, type (#695)
As soon as a pick settles the dimension (one line, circle or arc, or the second point or line of a distance or angle), its value box takes the keyboard with the measured value selected. The text follows the cursor while typing and Enter places it there (or in its default place), as one undo step. A click before typing pins the text and keeps the box focused, a click on another entity makes a distance or angle, Tab hands back to placing first, Esc cancels. Letters go into the box, not to tool shortcuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:23:41 -04:00
bherbruck 5ef1dcba8e Sheet metal rims and holes, plastic features, lofts, raised pieces and merged walls take face attachments (#677)
Sheet metal rims and bend ends attach by name (<panel>:rim.<edge>, <flange>:rim.bend0/1), as do face profiles from sheet faces. A plain or counterbored Hole square to a panel is machined into the flat pattern. Boss, Lip, Rest and Snap Fit keep the body's history and name their own faces. Hole tools name their faces by the part they cut, and pattern copies as copies. A pattern's repeated fillets keep the body's history. Loft sides are named by their section curves. A piece Offset Face moved off its face's plane attaches by its own qualified name. Sides of a profile grown to overlap a flush body keep their sketch line's name, so walls a Join merged take a sketch (#681). Face sketch 75% -> 90%, offset plane 75% -> 90%, press-pull/extrude 64% -> 85% in the action matrix; baseline blessed.

Fixes #494, fixes #495, fixes #560, fixes #561, fixes #563, fixes #564, fixes #681.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:13:09 -04:00
bherbruck e30f3c9ebe fix: Keep the loading card's Cancel button still; deterministic Cancel test (#686)
The loading card was centred on its height, so Cancel moved when the progress bar or the saved preview appeared, and a click could miss it. That's what made the Cancel test flaky on slow runners. The card is now anchored at its top, Cancel sits in the heading row and the progress bar is always shown. Progress::hold_at lets the test hold a load at a feature boundary until it's cancelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:05:13 -04:00
bherbruck 738d532ce6 README: add the SELENE rover by @0xlillith as a showcase (#690)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 08:55:12 -04:00
bherbruck 434fbc2d73 test: seed 16's partial face contact is refused, not built wrong (#676)
Main was red since #577 and #644 landed together. Seed 16's pattern copy rests half past the block's edge; the join used to add 72 mm³ past the edge silently and is now correctly refused. The test records that; proper fusion is tracked separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 08:28:04 -04:00
bherbruck 302dd7e343 README demo for 0.8: the Raspberry Pi 4 case again, with a rounded rim, engraved text and PETG (#671)
The `pi` story of examples/demo/enclosure_ui_mcp.py keeps the 0.6 flow and adds the rim
rounded in one pick (a tangent chain), "Pi 4" engraved in the floor in DejaVu Sans Bold, and a
PETG material defined in the design, with its mass in Properties, before the height change.
The 0.6 story stays as `pi-0.6` (it built demo/pi-case). The center rectangle now comes from
the Rectangle drop-down. The finished case is in the action matrix corpus as
demo/pi-case-0.8.

docs/screenshots/demo-0.8.gif (9.3 MB) and demo-0.8.mp4 (9.9 MB) replace the 0.6 files: 41 s
at 9x playback.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 08:23:27 -04:00
bherbruck d2086835c1 flatpak-site: publish a release from before the app id change as it is (#668)
The Pages deploy failed while the latest published release (0.7.0) still carries ai.storyteller.solvecraft: the bundle check only accepted the new id and exited without a message. Accept either id (both architectures must agree), mark the old refs end-of-life only once the new app is in the repository, show the published id in the install instructions, and name the failing command on any error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:53:10 -04:00
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
421 changed files with 75931 additions and 3259 deletions
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: [bherbruck]
ko_fi: bherbruck
+99 -51
View File
@@ -5,58 +5,104 @@ 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.9:** this is a minor bump because the design file format moved to version 17, 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 about 90% of flat faces.** Sketches, offset planes and press-pull now
attach to sheet metal rims and holes, plastic features (Boss, Lip, Rest, Snap Fit), lofts,
revolved faces, pieces Offset Face moved and walls a Join merged
([#677](https://github.com/bherbruck/solidcraft/pull/677), fixing
[#681](https://github.com/bherbruck/solidcraft/issues/681)). Across the test models: sketch
75% → 90%, offset plane 75% → 90%, press-pull 64% → 85%.
- **Joins:** a logo-style part with pieces extruded up and down from one sketch now joins
([#679](https://github.com/bherbruck/solidcraft/issues/679)), faces that only partly overlap
fuse correctly ([#704](https://github.com/bherbruck/solidcraft/pull/704), fixing
[#674](https://github.com/bherbruck/solidcraft/issues/674)), and booleans build the same body
every time ([#748](https://github.com/bherbruck/solidcraft/pull/748)), so a design rebuilds
identically on every run.
- **Sketch:** type a dimension right after picking (D, click, type;
[#695](https://github.com/bherbruck/solidcraft/pull/695)); redundant but consistent
constraints are accepted, such as a rectangle clover
([#699](https://github.com/bherbruck/solidcraft/pull/699)); tool windows for Scale, Offset,
Mirror, Move/Copy, Fillet and Chamfer and the rectangular pattern (thanks @wdunn001), shown
next to the Sketch Palette, which now lists the running tool's options; a typed Rho for the
conic; a Construction Geometries row in the Sketch Palette that hides construction geometry
([#804](https://github.com/bherbruck/solidcraft/pull/804)); sketch tool shortcuts open a sketch on the selected plane; Edit Sketch from the viewport
menu.
- **Features:** feature history on the left side with folders (opt in under Preferences;
[#449](https://github.com/bherbruck/solidcraft/pull/449)); Convert to Sheet Metal from a solid
or shelled body by its bend edges ([#725](https://github.com/bherbruck/solidcraft/pull/725));
a tear corner relief ([#809](https://github.com/bherbruck/solidcraft/pull/809)); tapered and
variable fillet radii ([#675](https://github.com/bherbruck/solidcraft/pull/675),
[#749](https://github.com/bherbruck/solidcraft/pull/749)) with a radius spline you drag on the
edge ([#752](https://github.com/bherbruck/solidcraft/pull/752)); free-form cut-outs stay exact
in sheet-metal flat patterns ([#692](https://github.com/bherbruck/solidcraft/pull/692)) and edge
flanges run along what cut-outs leave of an edge
([#702](https://github.com/bherbruck/solidcraft/pull/702)); and Surface Extrude, sheets from
open curves and profile walls ([#815](https://github.com/bherbruck/solidcraft/pull/815)).
- **View:** Home and Fit frame only what is shown, and a middle double-click goes Home
([#701](https://github.com/bherbruck/solidcraft/pull/701)); a cube navigator option
(thanks @Karl-VonRichter, [#664](https://github.com/bherbruck/solidcraft/pull/664)); the window
stays responsive and draggable while a design opens, with a Cancel button that stays put
([#643](https://github.com/bherbruck/solidcraft/pull/643),
[#686](https://github.com/bherbruck/solidcraft/pull/686)).
- **Quality:** cuts no longer split walls they don't touch
([#670](https://github.com/bherbruck/solidcraft/pull/670)); booleans are faster and use less
memory ([#714](https://github.com/bherbruck/solidcraft/pull/714)); STL export welds shared
vertices ([#816](https://github.com/bherbruck/solidcraft/pull/816)); complete Japanese
translation ([#753](https://github.com/bherbruck/solidcraft/pull/753)).
- **Fixes:** pasted features act on the pasted bodies (#613); a refused OK's error clears when
you change the inputs (#641); dialogs show the units of their sizes (#630); the wheel over a
dialog's list scrolls only the list (#678); a Single hole picked at a circle's centre lands on
the centre (#657); axis or tangent plane on a drill tip works (#567); sketch text edits from
letter interiors and moves as a whole; a projected edge stays the edge you picked; a size from
a bare diameter keeps its unit (#716); Convert Mesh says how big a mesh is and what converts;
the Save changes prompt takes arrow keys and Enter.
- **MCP:** agent skills for modeling and editing over MCP
([#728](https://github.com/bherbruck/solidcraft/pull/728)).
- A new README demo and the SELENE rover showcase.
**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 17.** Designs are now format 17 (`solidcraft/17`). SolidCraft 0.8 and older can't
open designs saved by 0.9, as a container or as JSON; they ask you to update instead. Keep a
copy of a file you may still open in 0.8. Files from every earlier version still open in 0.9.
**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).
**Upgrading from 0.8.** Nothing to do: open your designs and save (they're saved as format 17
from then on). Feature-history folders on
the left are off by default; turn them on in Preferences.
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 through-all holes along a wall or beside curved faces still fail
([#559](https://github.com/bherbruck/solidcraft/issues/559)), as does a hole crossing an
equal-radius hole ([#788](https://github.com/bherbruck/solidcraft/issues/788)).
- Some fillets are still refused, such as along an outline of lines and arcs
([#431](https://github.com/bherbruck/solidcraft/issues/431)).
- Some faces still can't take an attached sketch: a revolve's flat faces before a later feature
([#726](https://github.com/bherbruck/solidcraft/issues/726)) and faces after Split Body or
Combine Intersect ([#565](https://github.com/bherbruck/solidcraft/issues/565)).
- Fillet and chamfer accept an edge pick far from any edge and change the nearest one
([#783](https://github.com/bherbruck/solidcraft/issues/783)); Press Pull fails on faces beside
rounds ([#562](https://github.com/bherbruck/solidcraft/issues/562)).
- `sheet.fold` refuses a fixed point exactly on the sheet face
([#685](https://github.com/bherbruck/solidcraft/issues/685)), and some tabs on L-shaped or
collinear blanks can't be folded ([#638](https://github.com/bherbruck/solidcraft/issues/638),
[#639](https://github.com/bherbruck/solidcraft/issues/639)); 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.7 or earlier.** 0.8 changed the Linux app ID to `ai.storyteller.solidcraft`:
`flatpak update` offers to replace the old app, and the `.deb`, `.rpm` and AppImage remove the old
menu entry.
**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 +124,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 +138,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
+6 -3
View File
@@ -37,8 +37,11 @@ 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.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
| docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon (2× playback) | MIT OR Apache-2.0 |
| docs/screenshots/rover-build.mp4 | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon | 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 +96,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
+19 -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.9.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.9.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3316,7 +3307,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.6.0"
version = "0.9.0"
dependencies = [
"log",
"serde",
@@ -3331,7 +3322,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.6.0"
version = "0.9.0"
dependencies = [
"log",
"serde",
@@ -3347,7 +3338,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.6.0"
version = "0.9.0"
dependencies = [
"serde",
"serde_json",
@@ -3356,9 +3347,9 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.6.0"
version = "0.9.0"
dependencies = [
"quick-xml 0.38.4",
"quick-xml",
"serde",
"serde_json",
"sha2",
@@ -3371,11 +3362,14 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.6.0"
version = "0.9.0"
dependencies = [
"log",
"rayon",
"serde",
"serde_json",
"solidcraft-geom",
"spade",
"thiserror 2.0.21",
"truck-meshalgo",
"truck-modeling",
@@ -3386,7 +3380,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.6.0"
version = "0.9.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3394,7 +3388,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.6.0"
version = "0.9.0"
dependencies = [
"image",
"serde",
@@ -3404,7 +3398,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.6.0"
version = "0.9.0"
dependencies = [
"serde",
"serde_json",
@@ -3415,7 +3409,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.6.0"
version = "0.9.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -3430,7 +3424,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.6.0"
version = "0.9.0"
dependencies = [
"eframe",
"egui",
@@ -4181,7 +4175,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 +4847,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.6.0"
version = "0.9.0"
dependencies = [
"serde_json",
]
+6 -2
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.6.0"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -37,10 +37,14 @@ truck-shapeops = { version = "0.4", default-features = false }
truck-meshalgo = { version = "0.4", default-features = false, features = ["tessellation", "analyzers"] }
truck-stepio = { version = "0.3", default-features = false }
truck-topology = "0.6"
# constrained Delaunay + Ruppert refinement for trimmed faces (MIT OR Apache-2.0); already in the
# tree through truck-meshalgo.
spade = { version = "2.15", default-features = false, features = ["std"] }
rayon = "1.12"
# 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"] }
+24 -9
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.8: model a Raspberry Pi 4 case from an empty design: a sketch dimensioned with parameters, extrude, fillets and shell, a rim rounded in one pick, standoffs patterned on the board's mounting holes, port cut-outs, vent slots, "Pi 4" engraved in a font, a PETG material with its mass in Properties, and a height change in Parameters](docs/screenshots/demo-0.8.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, the rim rounded in one pick, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots, "Pi 4" engraved in DejaVu Sans Bold and a PETG material defined in the design, with its mass in Properties, then height 24 → 32 mm in Parameters and it all rebuilds. 9× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.8.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
## Download
@@ -68,6 +68,16 @@ are listed by area in [docs/features.md](docs/features.md); status and plans are
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solidcraft/issues),
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
## Built with SolidCraft
<a href="https://github.com/0xlillith/solidcraft-rover"><img src="docs/screenshots/rover-build.gif" alt="SELENE, a scientific rover by 0xlillith, assembling itself as the timeline plays through its 3,323 steps in SolidCraft: tub, wheels, electronics, suspension, sampling arm, solar wings, dish and camera turret" loading="lazy"></a>
*SELENE, a scientific rover built entirely through SolidCraft's MCP server by
[@0xlillith](https://github.com/0xlillith/solidcraft-rover): 3,323 editable timeline steps that
rebuild with no errors, replayed here at 2× speed.
[Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/rover-build.mp4)
· [download the editable model](https://github.com/0xlillith/solidcraft-rover/releases/tag/v1.0.0).*
## What works
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
@@ -81,13 +91,17 @@ 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.
step; with Sketch Dimension it's D, click, type, Enter. Fit keeps sketch geometry clear of the palette and navigation controls.
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
Newly captured complete adaptive boundaries retain their outer and hole source identities
when spline span counts change. Partial or ambiguous boundaries do not qualify.
@@ -108,7 +122,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
+65 -11
View File
@@ -36,7 +36,7 @@ In scope, in priority order:
metal rules (thickness, K-factor, bend radius) (M10).
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need, PCB / 3D
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need (scope proposed in #772), PCB / 3D
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Milestones
@@ -49,27 +49,57 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | 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 | — |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, 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, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
| 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 |
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
## Render foundation
- R1 adds renderer-independent backend/profile metadata, strict request resolution and
bounded image/scratch admission checks, exposed by the read-only `render.backends`
command. It describes the current CPU raster snapshot path without evaluating the model.
Existing snapshot and viewport routes remain unchanged. The Render workspace, HDRI/PBR,
render jobs and optional engines remain future work; see [the contract](docs/render-backends.md)
and [the phased proposal (#620)](https://github.com/bherbruck/solidcraft/issues/620).
## 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
stored references keep their saved meaning. Section Analysis uses the same check.
- Sketch text can be edited from a selected outline's context menu or the Edit Text command.
- Sketch text can be edited from a letter interior or outline's context menu or the Edit Text command.
Interior selection requires every profile boundary to belong to the same text source;
the editor highlights the whole source without replacing profile selections.
Content, baseline X/Y, height and rotation update the original text source in one undo step;
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
use the document units captured when editing starts; generated outlines remain associated
with their text source, and untouched inputs preserve their saved internal values.
Dragging a text outline moves the entire source in one undo step; Escape restores the
gesture. Move/Copy on a selected text outline provides unit-aware X/Y distances and rotation
about its baseline; copies keep an independent editable text source.
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
waits for a distinct point without a degenerate-shape error or history entry. Typed input
and programmatic commands retain their validation.
@@ -145,6 +175,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 +187,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 +323,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 +357,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 +396,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)
+17 -1
View File
@@ -514,7 +514,23 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
out.push(json!({"command": "solid.mirror", "params": p}));
}
Some("fillet") => {
out.push(json!({"command": "solid.fillet", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}}))
let mut p = json!({"edges": edge_points(f), "radius": f.get("radius"), "name": name});
// Constant radius (the default), chord length, or a radius varying along each edge
// (`radius` at the end nearest `start`, `radius2` at the other, `mid` between,
// `smooth`), or a B-spline along each edge (`radius` its first control radius,
// `radii` the rest, `knots`); `fit` for a radius too large for the geometry.
match f.get("type").and_then(Value::as_str) {
Some("chord_length") => p["type"] = json!("chord"),
Some("variable_radius") => p["type"] = json!("variable"),
Some("spline_radius") => p["type"] = json!("spline"),
_ => {}
}
for key in ["radius2", "start", "mid", "smooth", "fit", "radii", "knots"] {
if let Some(v) = f.get(key) {
p[key] = v.clone();
}
}
out.push(json!({"command": "solid.fillet", "params": p}))
}
Some("chamfer") => {
out.push(json!({"command": "solid.chamfer", "params": {"edges": edge_points(f), "distance": f.get("distance"), "name": name}}))
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"}}
]
}
+131
View File
@@ -131,6 +131,137 @@ 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); 1] = [("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 #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 {
@@ -0,0 +1,203 @@
//! A parameter change gives the same topology as building at the new value from scratch, and
//! changing it back gives the original topology: no stale splits, no faces added to regions
//! nothing touched. Over the design corpus (`tests/corpus`, `tests/action_matrix/designs`), and
//! the README demo's Raspberry Pi case pinned step by step.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_engine::doc::kernel::{FaceSurface, face_surface};
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
}
/// A folder of its own for test `name` (tests run side by side).
fn scratch_dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("solidcraft-rebuild-topology-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
/// A body's topology: its face and edge counts, and how many faces lie in each plane.
#[derive(Debug, PartialEq, Eq)]
struct Topology {
faces: usize,
edges: usize,
planes: BTreeMap<[i64; 4], usize>,
}
/// Every solid body's topology, by name.
fn topology(s: &Session) -> BTreeMap<String, Topology> {
let st = s.world_state();
st.bodies
.iter()
.filter(|b| !b.body.is_mesh())
.map(|b| {
let mut planes = BTreeMap::new();
for i in 0..b.body.face_count() {
if let Some(FaceSurface::Plane { normal, point }) = face_surface(&b.body, i) {
let key = [normal.x, normal.y, normal.z, normal.dot(point) / 100.].map(|x| (x * 1e4).round() as i64);
*planes.entry(key).or_insert(0) += 1;
}
}
(b.name.clone(), Topology { faces: b.body.face_count(), edges: b.body.edge_count(), planes })
})
.collect()
}
fn open(path: &Path) -> Session {
let mut s = Session::default();
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
s
}
/// The design built from scratch: saved as it is (timeline only, no cached geometry) and
/// opened again.
fn rebuilt(s: &Session, path: &Path) -> Session {
let mut t = s.scratch();
t.execute("file.save", &json!({"path": path.to_string_lossy(), "format": "json"})).unwrap();
open(path)
}
fn errors(s: &Session) -> Vec<u64> {
s.model.results.iter().filter(|r| r.error.is_some()).map(|r| r.id).collect()
}
/// The corpus's designs, as sessions.
fn designs() -> Vec<(String, Session)> {
let root = root();
let mut out = Vec::new();
for dir in ["apps/solidcraft-cli/tests/corpus", "tests/action_matrix/designs"] {
let mut files: Vec<PathBuf> = std::fs::read_dir(root.join(dir))
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "solidcraft" || x == "json") && !p.to_string_lossy().ends_with(".expected.json"))
.collect();
files.sort();
for p in files {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let s = if p.extension().is_some_and(|x| x == "solidcraft") {
open(&p)
} else {
let mut s = Session::default();
let script: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
s.run_script(&script).unwrap_or_else(|e| panic!("{name}: {e}"));
if s.active_sketch.is_some() {
let _ = s.execute("sketch.finish", &json!({}));
}
s
};
out.push((name, s));
}
}
out
}
/// For each design and its first length parameter that edits cleanly (the user's first): the parameter
/// scaled by 1.1 gives what a fresh build at that value gives, and setting it back gives the
/// original topology.
#[test]
fn a_parameter_edit_rebuilds_like_a_fresh_build_and_reverts_exactly() {
let dir = scratch_dir("corpus");
let mut problems = Vec::new();
let mut checked = 0;
for (name, s) in designs() {
let original = topology(&s);
let before = errors(&s);
let mut params: Vec<_> = s.doc.params.iter().filter(|p| p.unit == "mm").cloned().collect();
params.sort_by_key(|p| p.model);
let mut tried = 0;
for p in params {
if tried == 1 {
break;
}
let mut t = s.scratch();
let expr = format!("({}) * 1.1", p.expr);
if t.execute("parameters.change", &json!({"name": p.name, "expression": expr})).is_err() || errors(&t) != before {
continue;
}
tried += 1;
checked += 1;
let edited = topology(&t);
let fresh = topology(&rebuilt(&t, &dir.join(format!("{name}-{}.solidcraft", p.name))));
if edited != fresh {
problems.push(format!("{name}: {} = {expr}: edited {edited:?}\n fresh {fresh:?}", p.name));
}
t.execute("parameters.change", &json!({"name": p.name, "expression": p.expr})).unwrap();
let back = topology(&t);
if back != original {
problems.push(format!("{name}: {} back to {}: {back:?}\n original {original:?}", p.name, p.expr));
}
}
}
let _ = std::fs::remove_dir_all(&dir);
assert!(checked >= 8, "only {checked} parameter edits checked");
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
/// The README demo's Raspberry Pi case (`examples/demo/enclosure_ui_mcp.py --story pi`): its
/// face count after each feature, and each outer side (the four walls and the bottom) one face
/// throughout: (a) built at height 24, (b) built at 24 and changed to 32 in Parameters, (c)
/// built at 32. The port cut-outs and the floor vents used to split the long wall (or the end
/// wall at 24) into strips along the openings' tops and bottoms.
#[test]
fn pi_case_faces_per_step_at_both_heights() {
// Sketch1 (1) .. RectangularPattern2 (14): the faces once features 1..=k are built.
const FACES: [(usize, usize); 13] =
[(2, 6), (3, 10), (4, 19), (5, 19), (6, 25), (7, 43), (8, 43), (9, 55), (10, 55), (11, 59), (12, 59), (13, 63), (14, 79)];
let dir = scratch_dir("pi");
let design: Value = serde_json::from_slice(&std::fs::read(root().join("tests/action_matrix/designs/pi-case.solidcraft")).unwrap()).unwrap();
let at = |height: &str, file: &str| {
let mut d = design.clone();
for p in d["params"].as_array_mut().unwrap() {
if p["name"] == "height" {
p["expr"] = json!(height);
}
}
let path = dir.join(file);
std::fs::write(&path, serde_json::to_vec(&d).unwrap()).unwrap();
open(&path)
};
let a = at("24 mm", "pi-24.solidcraft");
let mut b = at("24 mm", "pi-24-to-32.solidcraft");
b.execute("parameters.change", &json!({"name": "height", "expression": "32 mm"})).unwrap();
let c = at("32 mm", "pi-32.solidcraft");
let _ = std::fs::remove_dir_all(&dir);
let mut per_case = Vec::new();
for (case, s, h) in [("(a) at 24", a, 24.), ("(b) 24 changed to 32", b, 32.), ("(c) at 32", c, 32.)] {
assert!(errors(&s).is_empty(), "{case}: features fail: {:?}", errors(&s));
let mut steps = Vec::new();
for (k, want) in FACES {
let mut t = s.scratch();
t.execute("timeline.rollback", &json!({"position": k})).unwrap();
let st = t.world_state();
let body = st.bodies.iter().find(|b| b.name == "Body1").unwrap();
assert_eq!(body.body.face_count(), want, "{case}: faces after feature {k}");
// Each outer side: the faces in its plane.
for (n, d) in [([0., -1., 0.], 33.), ([0., 1., 0.], 33.), ([-1., 0., 0.], 47.5), ([1., 0., 0.], 47.5), ([0., 0., -1.], 0.)] {
let on = (0..body.body.face_count())
.filter(|&i| match face_surface(&body.body, i) {
Some(FaceSurface::Plane { normal, point }) => {
(normal.x - n[0]).abs() + (normal.y - n[1]).abs() + (normal.z - n[2]).abs() < 1e-6 && (normal.dot(point) - d).abs() < 1e-6
}
_ => false,
})
.count();
assert_eq!(on, 1, "{case}: after feature {k} the outer side facing {n:?} is {on} faces");
}
let bbox_top = body.mesh().bounds().max.z;
assert!((bbox_top - h).abs() < 1e-6, "{case}: height {bbox_top}");
steps.push(topology(&t));
}
per_case.push((case, steps));
}
// (b) and (c): the same topology after every feature.
let (b, c) = (&per_case[1].1, &per_case[2].1);
for (k, (x, y)) in b.iter().zip(c).enumerate() {
assert_eq!(x, y, "after feature {}: (b) differs from (c)", k + 2);
}
}
+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).
+8 -3
View File
@@ -227,7 +227,7 @@ impl FeatureKind {
}
FeatureKind::Fillet { edges, style, .. } => {
edges.iter_mut().for_each(pt);
if let crate::FilletStyle::Variable { start, .. } = style {
if let crate::FilletStyle::Variable { start, .. } | crate::FilletStyle::Spline { start, .. } = style {
pt(start);
}
}
@@ -263,8 +263,12 @@ impl FeatureKind {
dir(direction);
}
FeatureKind::Thread { face, .. } => pt(face),
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::SheetConvert { face, .. } => pt(face),
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, bends, .. } => {
pt(face);
bends.iter_mut().for_each(pt);
}
FeatureKind::SheetFold { a, b, fixed, .. } => {
pt(a);
pt(b);
@@ -317,6 +321,7 @@ impl FeatureKind {
}
FeatureKind::Revolve { .. }
| FeatureKind::Extrude { .. }
| FeatureKind::SurfaceExtrude { .. }
| FeatureKind::Combine { .. }
| FeatureKind::Loft { .. }
| FeatureKind::Sweep { .. }
+79 -57
View File
@@ -47,6 +47,7 @@ impl FeatureKind {
}
}
FeatureKind::Extrude { sketch, .. }
| FeatureKind::SurfaceExtrude { sketch, .. }
| FeatureKind::Revolve { sketch, .. }
| FeatureKind::Emboss { sketch, .. }
| FeatureKind::SheetBase { sketch, .. }
@@ -84,6 +85,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:")
@@ -239,69 +244,19 @@ fn remap_link(
}
}
impl crate::Feature {
/// Clone identities through typed reference fields. Sketch-local entity IDs stay local.
pub fn remap_clone(
&mut self,
ids: &BTreeMap<u64, u64>,
names: &BTreeMap<String, String>,
bodies: &BTreeMap<String, String>,
parameters: &BTreeMap<String, String>,
) -> crate::Result<()> {
impl FeatureKind {
/// Point the body names a feature acts on (targets, tools, the body it rounds…) at the
/// copies in `bodies`; names not in the map stay as they are.
pub fn remap_bodies(&mut self, bodies: &BTreeMap<String, String>) {
use FeatureKind as K;
match &self.kind {
K::Sketch { plane, sketch } => {
checked_plane(plane, 0)?;
for link in &sketch.links {
checked_link(&link.source, 0)?;
}
}
K::ConstructionPlane { plane }
| K::Split { plane, .. }
| K::SplitFace { plane, .. }
| K::Mirror { plane, .. }
| K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?,
K::Draft { neutral, .. } => checked_plane(neutral, 0)?,
K::ReplaceFace { target, .. } => checked_plane(target, 0)?,
K::ConstructionAxis { def } => {
for r in def.refs() {
checked_geo(r, 0)?;
}
}
K::ConstructionPoint { def } => {
for r in def.refs() {
checked_geo(r, 0)?;
}
}
_ => {}
}
self.kind.remap_refs(ids, names);
for n in &mut self.body_names {
mapped_name(n, bodies);
}
for n in &mut self.participants {
mapped_name(n, bodies);
}
for n in &mut self.param_names {
mapped_name(n, parameters);
}
for n in self.face_names.iter_mut().chain(&mut self.edge_names) {
*n = remap_topology(n, ids, bodies);
}
for (_, e, _) in self.kind.inputs_mut() {
*e = clone_expression(e, parameters);
}
for e in self.kind.point_expr_mut().into_iter().flatten() {
*e = clone_expression(e, parameters);
}
match &mut self.kind {
match self {
K::Emboss { face: Some(face), .. } => mapped_name(&mut face.body, bodies),
K::Split { tool: Some(tool), .. } | K::SplitFace { tool: Some(tool), .. } | K::SurfaceTrim { tool: Some(tool), .. } => {
mapped_name(&mut tool.body, bodies)
}
_ => {}
}
match &mut self.kind {
match self {
K::Extrude { targets, .. }
| K::Revolve { targets, .. }
| K::Loft { targets, .. }
@@ -369,6 +324,65 @@ impl crate::Feature {
| K::MeshConvert { body, .. } => mapped_name(body, bodies),
_ => {}
}
}
}
impl crate::Feature {
/// Clone identities through typed reference fields. Sketch-local entity IDs stay local.
pub fn remap_clone(
&mut self,
ids: &BTreeMap<u64, u64>,
names: &BTreeMap<String, String>,
bodies: &BTreeMap<String, String>,
parameters: &BTreeMap<String, String>,
) -> crate::Result<()> {
use FeatureKind as K;
match &self.kind {
K::Sketch { plane, sketch } => {
checked_plane(plane, 0)?;
for link in &sketch.links {
checked_link(&link.source, 0)?;
}
}
K::ConstructionPlane { plane }
| K::Split { plane, .. }
| K::SplitFace { plane, .. }
| K::Mirror { plane, .. }
| K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?,
K::Draft { neutral, .. } => checked_plane(neutral, 0)?,
K::ReplaceFace { target, .. } => checked_plane(target, 0)?,
K::ConstructionAxis { def } => {
for r in def.refs() {
checked_geo(r, 0)?;
}
}
K::ConstructionPoint { def } => {
for r in def.refs() {
checked_geo(r, 0)?;
}
}
_ => {}
}
self.kind.remap_refs(ids, names);
for n in &mut self.body_names {
mapped_name(n, bodies);
}
for n in &mut self.participants {
mapped_name(n, bodies);
}
for n in &mut self.param_names {
mapped_name(n, parameters);
}
for n in self.face_names.iter_mut().chain(&mut self.edge_names) {
*n = remap_topology(n, ids, bodies);
}
for (_, e, _) in self.kind.inputs_mut() {
*e = clone_expression(e, parameters);
}
for e in self.kind.point_expr_mut().into_iter().flatten() {
*e = clone_expression(e, parameters);
}
self.kind.remap_bodies(bodies);
match &mut self.kind {
K::Sketch { plane, sketch } => {
remap_plane(plane, ids, names, bodies, 0)?;
@@ -379,7 +393,7 @@ impl crate::Feature {
}
for link in &mut sketch.links {
remap_link(&mut link.source, ids, names, bodies, 0);
if let Some(n) = &mut link.face_name {
for n in link.face_name.iter_mut().chain(&mut link.edge_name) {
*n = remap_topology(n, ids, bodies);
}
}
@@ -464,6 +478,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),
+350 -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,67 @@ 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,
}
}
/// [`find`](Self::find), else the one sketch line a generated straight segment runs along
/// and overlaps: a profile a feature grew to overlap a flush face (its neighbours lengthened
/// past the sketch's corners) still names its sides by the lines they were drawn on. Two
/// candidates stay ambiguous.
pub(crate) fn find_along(&self, generated: &Seg2) -> Option<String> {
if let Some(id) = self.find(generated) {
return Some(id);
}
let Seg2::Line { a: c, b: d } = *generated else { return None };
let mut found: Option<Option<&str>> = None;
for (id, linked, originals) in &self.segments {
if *linked {
continue;
}
let along = originals.iter().any(|original| {
let Seg2::Line { a, b } = *original else { return false };
let direction = b - a;
let scale = direction.len().max((d - c).len()).max(1.);
if direction.len() <= 1e-12
|| direction.cross(c - a).abs() > 1e-10 * scale * scale
|| direction.cross(d - a).abs() > 1e-10 * scale * scale
{
return false;
}
let (tc, td) = ((c - a).dot(direction) / direction.len2(), (d - a).dot(direction) / direction.len2());
tc.min(td).max(0.) < tc.max(td).min(1.) - 1e-9
});
if along {
found = Some(if found.is_some() { None } else { Some(*id) });
}
}
found.flatten().map(str::to_owned)
}
}
@@ -411,7 +509,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 +580,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 +597,188 @@ 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 Split Face split `body`'s faces on `plane` (a split line lies on it).
fn split_on_purpose(body: &ModelBody, plane: &solidcraft_geom::Plane) -> bool {
let tolerance = 1e-6 * (1. + body.body.size());
let on_plane =
|line: &Vec<solidcraft_geom::Vec3>| !line.is_empty() && line.iter().all(|p| (*p - plane.origin).dot(plane.normal()).abs() <= tolerance);
body.body.split_lines().is_some_and(|lines| lines.iter().any(on_plane))
}
/// 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). Where
/// the face's pieces no longer share a plane (Offset Face moved one of them), the piece by
/// its own name: the face's name qualified by the faces around it (`F2:top#[F2:side:l4,…]`).
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 full = names.faces.get(index)?;
let stem = crate::naming::strip_piece(full);
let origin = stem.strip_prefix('F')?.split_once(':')?.0.parse::<u64>().ok()?;
let inverse = crate::mat_inverse(&body.source_transform)?;
let mut candidates = vec![stem];
if full != stem {
candidates.push(full);
}
candidates.into_iter().find_map(|name| {
let target = crate::config::ConfigFaceTarget {
body: body.definition_name().into(),
origin_feature: origin,
name: name.to_string(),
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);
// `face` is `name` itself or a piece of it (a piece of a piece, …).
let piece_of = |face: &str| {
let mut n = face;
loop {
if n == name {
return true;
}
match crate::piece_names::parent_piece(n) {
Some(parent) => n = parent,
None => return false,
}
}
};
let mut indices: Vec<usize> = names.faces.iter().enumerate().filter(|(_, face)| piece_of(face)).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))?;
// A piece by its own name stands for itself, which a piece of a face split on purpose
// (Split Face) never does: that needs a fixed plane.
if crate::naming::strip_piece(name) != name
&& let Some(plane) = indices.first().and_then(|index| body.body.planar_face(*index))
&& split_on_purpose(body, &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(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)
&& split_on_purpose(body, &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 +798,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 +848,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();
+32 -12
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 { .. }))))
}
@@ -123,24 +124,33 @@ impl Document {
target.validate()?;
let prefix = format!("F{}:", target.origin_feature);
let role = target.name.strip_prefix(&prefix).ok_or_else(|| DocError::Invalid("face lineage does not match its feature".into()))?;
if crate::naming::strip_piece(&target.name) != target.name {
// An exact history name may name a piece by the faces around it (`F2:top#[F2:side:l4,…]`),
// never by a number (an older design's piece, or a tie the faces around it left).
let numbered =
target.name.match_indices('#').any(|(i, _)| target.name.get(i + 1..).is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())));
let lineage_piece = matches!(target.certificate, Some(super::FaceCertificate::Lineage)) && !numbered;
if crate::naming::strip_piece(&target.name) != target.name && !lineage_piece {
return Err(DocError::Invalid("configure a face lineage, not a numbered split piece".into()));
}
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 +191,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 +205,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 +221,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 +247,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 +259,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 +268,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> =
+363 -14
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")]
@@ -714,12 +782,17 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
refold: bool,
},
/// Convert a plate body (constant thickness from the face at `face`) to sheet metal.
/// Convert a body to sheet metal from its fixed face at `face`: a plate of constant
/// thickness or, with `bends`, a solid or shelled body whose faces reached from the fixed
/// face across those edges become the sheet.
SheetConvert {
body: String,
face: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
rule: Option<String>,
/// Points on the sharp edges that become bends.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
bends: Vec<Vec3>,
},
/// Plastic boss: a round post on a face (draft, root fillet, hole, ribs).
Boss {
@@ -852,6 +925,18 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<String>,
},
/// A surface body swept straight along the sketch normal from open curve chains and the
/// boundaries of profiles: walls only, no caps.
SurfaceExtrude {
sketch: u64,
/// Profiles whose boundary loops are swept (none: only `curves`).
#[serde(default, skip_serializing_if = "Option::is_none")]
profiles: Option<ProfileSel>,
/// Sketch curve ids, grouped into chains of curves that meet end to end.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
curves: Vec<String>,
extent: Extent,
},
/// A planar surface body extended outward by `distance` past the edges through `edges`.
SurfaceExtend {
body: String,
@@ -874,6 +959,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.
@@ -917,16 +1006,23 @@ pub enum FilletStyle {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
smooth: bool,
},
/// The radius along each edge as a clamped B-spline of the position from the end nearest
/// `start` (0) to the other (1): `radius` is its first control radius and `radii` the rest,
/// its degree what `knots` leave (knots = control radii + degree + 1).
Spline { start: Vec3, knots: Vec<f64>, radii: Vec<String> },
}
/// A fillet radius too large for the geometry around its edges: refused (`Exact`, the default),
/// or reduced to the largest that works for all of them (`Reduce`), with a warning.
/// reduced to the largest that works for all of them (`Reduce`), or tapered along each edge it
/// doesn't fit to the room there, down to zero where a face closes to a point (`Taper`); a
/// warning says what was done.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FilletFit {
#[default]
Exact,
Reduce,
Taper,
}
impl FilletFit {
@@ -939,6 +1035,13 @@ impl FilletStyle {
pub fn is_constant(&self) -> bool {
*self == FilletStyle::Constant
}
/// The spline of a [`FilletStyle::Spline`] with its control radii `values` (the first is
/// the feature's `radius`).
pub fn spline(knots: &[f64], values: Vec<f64>) -> solidcraft_kernel::RadiusSpline {
let degree = knots.len().saturating_sub(values.len() + 1);
solidcraft_kernel::RadiusSpline { degree, knots: knots.to_vec(), radii: values }
}
}
/// A face picked by a point on it, on a named body.
@@ -1134,6 +1237,7 @@ impl FeatureKind {
use FeatureKind as K;
match self {
K::Extrude { sketch, .. }
| K::SurfaceExtrude { sketch, .. }
| K::Revolve { sketch, .. }
| K::Emboss { sketch, .. }
| K::Rib { sketch, .. }
@@ -1265,6 +1369,7 @@ impl FeatureKind {
FeatureKind::Align { .. } => "AlignFeature",
FeatureKind::Remove { .. } => "RemoveFeature",
FeatureKind::Patch { .. } => "PatchFeature",
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrudeFeature",
FeatureKind::Stitch { .. } => "StitchFeature",
FeatureKind::Thicken { .. } => "ThickenFeature",
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
@@ -1331,6 +1436,7 @@ impl FeatureKind {
FeatureKind::Align { .. } => "Align",
FeatureKind::Remove { .. } => "Remove",
FeatureKind::Patch { .. } => "Patch",
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrude",
FeatureKind::Stitch { .. } => "Stitch",
FeatureKind::Thicken { .. } => "Thicken",
FeatureKind::SurfaceTrim { .. } => "Trim",
@@ -1351,7 +1457,9 @@ impl FeatureKind {
| FeatureKind::Sweep { sketch, profiles, .. }
| FeatureKind::Emboss { sketch, profiles, .. }
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
FeatureKind::Patch { sketch: Some(sk), profiles, .. } => vec![(*sk, profiles)],
FeatureKind::Patch { sketch: Some(sk), profiles, .. } | FeatureKind::SurfaceExtrude { sketch: sk, profiles: Some(profiles), .. } => {
vec![(*sk, profiles)]
}
FeatureKind::Loft { sections, .. } => sections.iter_mut().filter(|s| s.point.is_none()).map(|s| (s.sketch, &mut s.profiles)).collect(),
_ => Vec::new(),
}
@@ -1365,7 +1473,9 @@ impl FeatureKind {
| FeatureKind::Sweep { sketch, profiles, .. }
| FeatureKind::Emboss { sketch, profiles, .. }
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
FeatureKind::Patch { sketch: Some(sketch), profiles, .. } => vec![(*sketch, profiles)],
FeatureKind::Patch { sketch: Some(sketch), profiles, .. } | FeatureKind::SurfaceExtrude { sketch, profiles: Some(profiles), .. } => {
vec![(*sketch, profiles)]
}
FeatureKind::Loft { sections, .. } => {
sections.iter().filter(|section| section.point.is_none()).map(|section| (section.sketch, &section.profiles)).collect()
}
@@ -1388,6 +1498,8 @@ impl FeatureKind {
FeatureKind::Thread { face, .. } => vec![face],
FeatureKind::Emboss { face: Some(face), .. } => vec![&mut face.at],
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
// With bend edges the fixed face is found by name (a plate keeps its point).
FeatureKind::SheetConvert { face, bends, .. } if !bends.is_empty() => vec![face],
_ => Vec::new(),
}
}
@@ -1411,7 +1523,7 @@ impl FeatureKind {
| FeatureKind::ConstructionPlane { plane }
| FeatureKind::Split { plane, .. }
| FeatureKind::SplitFace { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::Extrude { extent, .. } => {
FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } => {
v.push(&extent.distance);
if let Some(d) = &extent.distance2 {
v.push(d);
@@ -1432,9 +1544,13 @@ impl FeatureKind {
}
FeatureKind::Fillet { radius, style, .. } => {
v.push(radius);
if let FilletStyle::Variable { radius2, mid, .. } = style {
v.push(radius2);
v.extend(mid.iter().map(|(_, r)| r.as_str()));
match style {
FilletStyle::Variable { radius2, mid, .. } => {
v.push(radius2);
v.extend(mid.iter().map(|(_, r)| r.as_str()));
}
FilletStyle::Spline { radii, .. } => v.extend(radii.iter().map(String::as_str)),
FilletStyle::Constant | FilletStyle::Chord => {}
}
}
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
@@ -1521,9 +1637,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 +1742,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 +1787,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 +1805,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,
@@ -1684,12 +1821,48 @@ pub struct Document {
/// Browser groups (folders of bodies, sketches or construction planes).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub browser_groups: Vec<BrowserGroup>,
/// Named folders used to organise features in the timeline. They do not affect evaluation
/// order; feature ids remain the source of truth when features are renamed or reordered.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub timeline_groups: Vec<TimelineGroup>,
/// Browser item order per component folder (`"<component>/<folder>"` → item keys).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub browser_order: std::collections::BTreeMap<String, Vec<String>>,
/// 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).
@@ -1718,6 +1891,66 @@ pub struct BrowserGroup {
pub items: Vec<String>,
}
/// A saved feature-history folder. Folders may nest, and a feature can belong to at most one folder.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct TimelineGroup {
pub id: u64,
pub name: String,
/// The containing feature folder. `None` places the folder at the timeline root.
#[serde(default)]
pub parent: Option<u64>,
#[serde(default)]
pub features: Vec<u64>,
}
/// The first feature-history folder whose own and nested features do not form one
/// uninterrupted run in the timeline.
pub fn non_contiguous_timeline_group(features: &[Feature], groups: &[TimelineGroup]) -> Option<u64> {
let positions: std::collections::BTreeMap<u64, usize> = features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
let mut stats = std::collections::BTreeMap::new();
let mut children = std::collections::BTreeMap::new();
for group in groups {
let mut count = 0usize;
let mut first = usize::MAX;
let mut last = 0usize;
for index in group.features.iter().filter_map(|feature| positions.get(feature)) {
count = count.saturating_add(1);
first = first.min(*index);
last = last.max(*index);
}
stats.insert(group.id, (count, first, last));
children.entry(group.id).or_insert(0usize);
if let Some(parent) = group.parent {
let child_count = children.entry(parent).or_insert(0);
*child_count = child_count.saturating_add(1);
}
}
let parents: std::collections::BTreeMap<u64, Option<u64>> = groups.iter().map(|group| (group.id, group.parent)).collect();
let mut pending: Vec<u64> = groups.iter().filter(|group| children.get(&group.id) == Some(&0)).map(|group| group.id).collect();
while let Some(id) = pending.pop() {
let (count, first, last) = stats.get(&id).copied().unwrap_or_default();
if count > 0 && last.saturating_sub(first).saturating_add(1) != count {
return Some(id);
}
if let Some(parent) = parents.get(&id).copied().flatten() {
if let Some((parent_count, parent_first, parent_last)) = stats.get_mut(&parent) {
*parent_count = parent_count.saturating_add(count);
if count > 0 {
*parent_first = (*parent_first).min(first);
*parent_last = (*parent_last).max(last);
}
}
if let Some(remaining) = children.get_mut(&parent) {
*remaining = remaining.saturating_sub(1);
if *remaining == 0 {
pending.push(parent);
}
}
}
}
None
}
fn default_format() -> String {
crate::format::format_string(crate::format::FORMAT)
}
@@ -1748,16 +1981,20 @@ 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(),
param_comments: Default::default(),
browser_groups: Vec::new(),
timeline_groups: Vec::new(),
browser_order: Default::default(),
named_views: Vec::new(),
visibility: Visibility::default(),
}
}
@@ -1790,7 +2027,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.
@@ -1900,6 +2176,40 @@ impl Document {
}
}
/// The deepest timeline folder spanning the insertion slot between `marker - 1` and
/// `marker`. A slot at a folder's outer boundary is not inside that folder.
fn timeline_group_at_insertion(&self, marker: usize) -> Option<u64> {
let before = self.features.get(marker.checked_sub(1)?)?.id;
let after = self.features.get(marker)?.id;
let direct = |feature| self.timeline_groups.iter().find(|group| group.features.contains(&feature)).map(|group| group.id);
let parents: std::collections::BTreeMap<u64, Option<u64>> = self.timeline_groups.iter().map(|group| (group.id, group.parent)).collect();
let mut before_ancestors = std::collections::BTreeSet::new();
let mut current = direct(before);
let mut steps = 0usize;
while let Some(id) = current {
if !before_ancestors.insert(id) || steps >= self.timeline_groups.len() {
return None;
}
current = parents.get(&id).copied().flatten();
steps = steps.saturating_add(1);
}
current = direct(after);
steps = 0;
while let Some(id) = current {
if before_ancestors.contains(&id) {
return Some(id);
}
if steps >= self.timeline_groups.len() {
return None;
}
current = parents.get(&id).copied().flatten();
steps = steps.saturating_add(1);
}
None
}
/// Insert a feature at the marker (or the end) and return its id.
pub fn add_feature(&mut self, kind: FeatureKind, name: Option<&str>) -> Result<u64> {
if self.features.len() >= MAX_FEATURES {
@@ -1931,6 +2241,7 @@ impl Document {
shell_piece_order: false,
kind,
};
let insertion_group = self.marker.and_then(|marker| self.timeline_group_at_insertion(marker));
match self.marker {
Some(m) if m < self.features.len() => {
self.features.insert(m, f);
@@ -1938,6 +2249,14 @@ impl Document {
}
_ => self.features.push(f),
}
if let Some(group_id) = insertion_group {
let positions: std::collections::BTreeMap<u64, usize> =
self.features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
if let Some(group) = self.timeline_groups.iter_mut().find(|group| group.id == group_id) {
group.features.push(id);
group.features.sort_by_key(|feature| positions.get(feature).copied().unwrap_or(usize::MAX));
}
}
self.name_feature_inputs(id);
Ok(id)
}
@@ -1952,6 +2271,9 @@ impl Document {
.rules
.retain(|rule| !rule.references_features(&gone) && !rule.parameter_references().iter().any(|name| gone_params.contains(name)));
self.features.retain(|f| !gone.contains(&f.id));
for group in &mut self.timeline_groups {
group.features.retain(|feature| !gone.contains(feature));
}
// Configuration columns of deleted features go with them.
let keep: Vec<bool> = self
.configs
@@ -2210,18 +2532,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 +2597,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)
}
+497 -122
View File
File diff suppressed because it is too large Load Diff
+234 -64
View File
@@ -300,6 +300,17 @@ fn replace_sheet_body(mb: &mut ModelBody, body: Body) {
*mb = next;
}
/// Put a converted sheet in the place of the body it was made from.
fn put_sheet(st: &mut ModelState, sh: sheet::SheetBody) -> Result<()> {
let solid = sh.solid()?;
if let Some(b) = st.bodies.iter_mut().find(|b| b.name == sh.body) {
replace_sheet_body(b, solid);
}
st.sheets.retain(|s| s.body != sh.body);
st.sheets.push(sh);
Ok(())
}
fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> {
let sheet = st.sheets.get(idx).cloned().ok_or_else(|| DocError::Invalid("sheet".into()))?;
let solid = sheet.solid()?;
@@ -325,20 +336,32 @@ fn sheet_for(st: &ModelState, body: &Option<String>, pick: Option<Vec3>) -> Resu
.ok_or_else(|| DocError::Invalid("there is no sheet metal body".into()))
}
/// A region mirrored in the sketch x axis (y → −y); curves other than lines and arcs as polylines.
/// 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), exactly: free-form curves through their
/// control points.
fn mirror_y(r: &Region2) -> Region2 {
let f = |p: Vec2| Vec2::new(p.x, -p.y);
let m = |l: &Loop2| {
let segs: Vec<Seg2> = l
.segs
.iter()
.flat_map(|s| match *s {
Seg2::Line { a, b } => vec![Seg2::Line { a: f(a), b: f(b) }],
Seg2::Arc { center, radius, start, sweep } => vec![Seg2::Arc { center: f(center), radius, start: -start, sweep: -sweep }],
other => {
let pts: Vec<Vec2> = (0..=16).map(|k| f(other.point_at(k as f64 / 16.0))).collect();
pts.windows(2).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect()
}
.map(|s| match *s {
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center: f(center), radius, start: -start, sweep: -sweep },
other => sheet::map_controls(&other, f),
})
.collect();
Loop2 { segs }.ccw()
@@ -415,8 +438,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 +464,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 +523,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)
}
@@ -503,7 +557,34 @@ pub(super) fn sheet_eval(
}
rebuild_sheet(st, i)
}
FeatureKind::SheetConvert { body, face, rule } => {
// With bend edges: a solid or shelled body whose faces become the sheet.
FeatureKind::SheetConvert { body, face, rule, bends } if !bends.is_empty() => {
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let r = doc.sheet_rule(rule.as_deref());
let rv = doc.rule_values(vals, &r)?;
// The bend edges by name first; a name that is gone keeps its point, with a warning.
let mut picks = Vec::with_capacity(bends.len());
for (i, p) in bends.iter().enumerate() {
let name = f.edge_names.get(i).filter(|n| !n.is_empty());
picks.push(match name.map(|n| (n, crate::naming::locate_edge(&mb, n, *p))) {
Some((n, Ok(hit))) => {
if let Some(note) = hit.note(n) {
append_warning(warning, note);
}
hit.point
}
Some((n, Err(_))) => {
append_warning(warning, format!("edge `{n}` no longer exists; using the edge at the picked point"));
*p
}
None => *p,
});
}
let mut sh = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
tag_flanges(&mut sh, 0, f.id);
put_sheet(st, sh)
}
FeatureKind::SheetConvert { body, face, rule, .. } => {
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick a planar face of the plate".into()))?;
let tol = (mb.body.size() * 1e-3).max(1e-3);
@@ -522,20 +603,16 @@ pub(super) fn sheet_eval(
let area = region.area().abs();
let vol = kernel::measure(&mb.body)?.volume;
if (area * t - vol).abs() > 1e-3 * vol.max(1.0) {
return Err(DocError::Invalid("not supported yet: converting bodies that are not a plate of even thickness".into()));
return Err(DocError::Invalid(
"not supported yet: converting bodies that are not a plate of even thickness without bend edges (`bends`)".into(),
));
}
let r = doc.sheet_rule(rule.as_deref());
let mut rv = doc.rule_values(vals, &r)?;
rv.thickness = t;
let mut sh = sheet::base_flange(body, &r.name, &rv, plane, Region2 { outer: region.outer.clone(), holes: Vec::new() });
sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect();
let solid = sh.solid()?;
if let Some(b) = st.bodies.iter_mut().find(|b| &b.name == body) {
replace_sheet_body(b, solid);
}
st.sheets.retain(|s| &s.body != body);
st.sheets.push(sh);
Ok(())
put_sheet(st, sh)
}
_ => Err(DocError::Invalid(format!("{} is not a sheet metal feature", f.name))),
}
@@ -622,24 +699,113 @@ 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`].
#[derive(Clone, Copy, Debug)]
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 +816,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 +836,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 +844,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 +872,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 +906,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 +914,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 +975,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
@@ -983,11 +1149,12 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
let base = Plane::new(*position - z * delta, x, z.cross(x)).ok_or_else(|| DocError::Invalid("boss frame".into()))?;
let r_in = r0 + delta * dr.tan();
let disc = Region2 { outer: Loop2::circle(Vec2::ZERO, r_in), holes: Vec::new() };
let mut tools = if dr.abs() > 1e-12 {
let post = if dr.abs() > 1e-12 {
vec![kernel::extrude_tapered(&base, &disc, h + delta, 1.0, -dr)?]
} else {
kernel::extrude(&base, &[disc], 0.0, h + delta)?
};
let mut tools: Vec<Traced> = post.into_iter().map(|b| crate::lineage::part_tool(b, "post")).collect();
if let Some(n) = ribs {
let n = val(vals, n, Kind::Unitless)?.round();
if !(0.0..=64.0).contains(&n) {
@@ -1022,19 +1189,19 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
]),
holes: Vec::new(),
};
tools.extend(kernel::extrude(&rp, &[rect], -delta, hr)?);
tools.extend(kernel::extrude(&rp, &[rect], -delta, hr)?.into_iter().map(|b| crate::lineage::part_tool(b, &format!("rib{k}"))));
}
}
apply_op(st, f, tools, Operation::Join, std::slice::from_ref(&target))?;
apply_traced(st, f, tools, Operation::Join, std::slice::from_ref(&target))?;
// The root fillet: rounds the circle where the post meets the face.
if rf > 1e-9 {
let i = st.bodies.iter().position(|b| b.name == target).ok_or_else(|| DocError::Invalid("boss body".into()))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("boss body".into())) };
let mut warn = None;
let edges = resolve_edges(&mb.body, &[*position + x * r0], &[], &mut warn)?;
let nb = kernel::fillet(&mb.body, &edges, rf)?;
let nb = mb.traced().then(kernel::fillet(&mb.body, &edges, rf)?);
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
}
}
if let Some(hd) = hole_diameter {
@@ -1048,7 +1215,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
}
let hp = Plane::new(*position, x, z.cross(x)).ok_or_else(|| DocError::Invalid("hole frame".into()))?;
let tool = kernel::extrude(&hp, &[Region2 { outer: Loop2::circle(Vec2::ZERO, rh), holes: Vec::new() }], h - depth, h + delta)?;
apply_op(st, f, tool, Operation::Cut, std::slice::from_ref(&target))?;
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "hole")).collect();
apply_traced(st, f, tool, Operation::Cut, std::slice::from_ref(&target))?;
}
Ok(())
}
@@ -1081,7 +1249,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
&& let Ok(b) = kernel::raise_band(&mb.body, *face, w, h, *outside)
&& let Some(slot) = st.bodies.iter_mut().find(|x| x.name == target)
{
*slot = ModelBody::new(target.clone(), b, slot.feature);
*slot = ModelBody::traced_new(target.clone(), Traced::step(b, &[&mb.traced()]), slot.feature);
return Ok(());
}
// The band along the chosen edge, inside the rim face. A groove's cutter reaches past
@@ -1100,11 +1268,11 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
};
let delta = (h * 0.05).clamp(1e-3, 0.5);
if *groove {
let tool = kernel::extrude(&plane, &[band], -(h + g), delta)?;
apply_op(st, f, tool, Operation::Cut, std::slice::from_ref(&target))
let tool = kernel::extrude(&plane, &[band], -(h + g), delta)?.into_iter().map(|b| crate::lineage::part_tool(b, "groove")).collect();
apply_traced(st, f, tool, Operation::Cut, std::slice::from_ref(&target))
} else {
let tool = kernel::extrude(&plane, &[band], -delta, h)?;
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
let tool = kernel::extrude(&plane, &[band], -delta, h)?.into_iter().map(|b| crate::lineage::part_tool(b, "lip")).collect();
apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target))
}
}
FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, body } => {
@@ -1127,7 +1295,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
let sgn = if pl.normal().dot(y) >= 0.0 { 1.0 } else { -1.0 };
let (lo, hi) = if sgn > 0.0 { (0.0, w) } else { (-w, 0.0) };
let tool = kernel::extrude(&pl, &[Region2 { outer: lp, holes: Vec::new() }], lo, hi)?;
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "arm")).collect();
apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target))
}
FeatureKind::Rest { position, direction, along, width, length, height, draft, thickness, body } => {
let target = body_under(st, body, *position)?;
@@ -1176,7 +1345,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
} else {
kernel::extrude(&base, &[region], 0.0, h + delta)?
};
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "rest")).collect();
apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target))
}
_ => Err(DocError::Invalid(format!("{} is not a plastic feature", f.name))),
}
+80 -1
View File
@@ -1,6 +1,8 @@
//! Evaluation of the surface features: Patch, Stitch, Thicken and Trim (kernel `surfaces`).
//! Evaluation of the surface features: Patch, Stitch, Thicken, Trim, Extend and Surface Extrude
//! (kernel `surfaces`).
use super::*;
use solidcraft_geom::Seg2;
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
match &f.kind {
@@ -84,6 +86,83 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
let ss = sketch_in_feature_frame(
doc,
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
if extent.through_all || extent.taper.is_some() || extent.to.is_some() || extent.from.is_some() {
return Err(DocError::Invalid("not supported yet: a surface extrude through all, to or from an object, or with a taper".into()));
}
let d = val(vals, &extent.distance, Kind::Length)?;
let off = extent.start_offset.as_deref().map(|e| val(vals, e, Kind::Length)).transpose()?.unwrap_or(0.0);
let (a, b) = extent_sides(vals, extent, d, extent.direction)?;
let mut chains: Vec<Vec<Seg2>> = Vec::new();
if let Some(sel) = profiles {
for r in solidcraft_sketch::merge_regions(&select_profiles(&ss, sel)?) {
chains.extend(std::iter::once(r.outer).chain(r.holes).map(|lp| lp.segs));
}
}
chains.extend(curve_chains(&ss, curves)?);
if chains.is_empty() {
return Err(DocError::Invalid("pick the profiles or curves to extrude".into()));
}
let body = kernel::extrude_sheet(&ss.plane, &chains, a + off, b + off)?;
let name = new_name(st, f, 0);
st.bodies.push(ModelBody::new(name, body, f.id));
Ok(())
}
_ => Err(DocError::Invalid("not a surface feature".into())),
}
}
/// Most sketch curves one feature chains.
const MAX_CHAIN_CURVES: usize = 1000;
/// The sketch curves `ids` (free-form ones exactly where they can be) grouped into chains of segments that meet end to end, each oriented
/// to run one way. Curves are joined greedily at their free ends: where three meet, one of them
/// starts a chain of its own.
fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
if ids.len() > MAX_CHAIN_CURVES {
return Err(DocError::Invalid(format!("at most {MAX_CHAIN_CURVES} curves")));
}
let mut pieces: std::collections::VecDeque<Vec<Seg2>> = ids
.iter()
.map(|id| {
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?;
Ok(ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
})
.collect::<Result<Vec<_>>>()?
.into_iter()
.filter(|p| !p.is_empty())
.collect();
let reversed = |p: Vec<Seg2>| -> Vec<Seg2> { p.iter().rev().map(Seg2::reversed).collect() };
let near = |p: Vec2, q: Vec2| p.dist(q) <= 1e-6;
let mut chains = Vec::new();
while let Some(mut chain) = pieces.pop_front() {
while let (Some(head), Some(tail)) = (chain.first().map(Seg2::start), chain.last().map(Seg2::end)) {
if near(head, tail) {
break;
}
let ends = |p: &Vec<Seg2>| p.first().map(Seg2::start).zip(p.last().map(Seg2::end));
let Some(i) = pieces.iter().position(|p| ends(p).is_some_and(|(s, e)| near(s, tail) || near(e, tail) || near(s, head) || near(e, head)))
else {
break;
};
let Some(p) = pieces.remove(i) else { break };
let Some((s, e)) = ends(&p) else { continue };
if near(s, tail) {
chain.extend(p);
} else if near(e, tail) {
chain.extend(reversed(p));
} else if near(e, head) {
chain = p.into_iter().chain(chain).collect();
} else {
chain = reversed(p).into_iter().chain(chain).collect();
}
}
chains.push(chain);
}
Ok(chains)
}
+13
View File
@@ -350,6 +350,17 @@ pub fn is_literal(s: &str) -> bool {
}
}
/// Is the expression a bare number without a unit, like `20` or `-1.5`?
pub fn is_bare_number(s: &str) -> bool {
is_literal(s) && s.trim().trim_start_matches(['-', '+']).trim().parse::<f64>().is_ok()
}
/// The length unit of a design with these units: `mm` unless they name another length unit.
pub fn length_unit_or_mm(units: &str) -> &str {
let u = units.trim();
if unit_info(u).is_some_and(|(k, _)| k == Kind::Length) { u } else { "mm" }
}
struct Parser<'a> {
toks: Vec<Tok>,
pos: usize,
@@ -942,5 +953,7 @@ mod tests {
assert_eq!(rename_reference("10 mm", "mm", "x"), "10 mm");
assert!(is_literal("20") && is_literal("-1.5 in") && is_literal("3 deg"));
assert!(!is_literal("d1") && !is_literal("2 * 3") && !is_literal("2 width"));
assert!(is_bare_number(" -1.5 ") && is_bare_number("+2") && !is_bare_number("20 mm") && !is_bare_number("(2)"));
assert_eq!((length_unit_or_mm(" in "), length_unit_or_mm("deg"), length_unit_or_mm("")), ("in", "mm", "mm"));
}
}
+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 {
+97 -2
View File
@@ -46,13 +46,71 @@
//! 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.
//! - 17: a `lineage` face name can be a piece's own, qualified by the faces around it
//! (`F2:top#[F2:side:l4,F4:side:l4]`), where Offset Face moved one piece of a face off the
//! others' plane; never a numbered piece. Sheet face names can also name a rim, the sheet's
//! thickness along a panel's straight edge (`sheet:<body>:<panel>:rim.<edge>`, the edge a base
//! sketch line's id, `edge<k>`, or a flange's `far`, `side0` or `side1`), or a flat end of a
//! bend (`rim.bend0`, `rim.bend1`). Version 16 designs need no transformation; the version
//! stamp makes 0.8.0 (which reads up to 16) ask for an update instead of meeting names it
//! can't resolve.
//! Convert to Sheet Metal can take bend edges (`bends` in the feature: points on the sharp
//! edges of a solid or shelled body that become bends). A conversion without `bends` is the
//! plate conversion it always was and saves as before; 0.8.0 would drop the bend edges and
//! refuse the body as no plate.
//! A sketch's link to a projected edge can also keep the edge's persistent name (`edge_name`,
//! beside `face_name`) and is then found by it, wherever the edge went. A link without it is
//! the edge nearest its point, as it always was, and takes the name when its sketch is next
//! edited.
//! A fillet's `fit` can be `taper` (the radius follows the room along each edge it doesn't
//! fit). Fillets with `exact` or `reduce` save as before; 0.8.0 would refuse the unknown fit.
//! Feature-history folders are also first saved in 17 (`timeline_groups`: stable folder id,
//! name, optional parent folder and feature ids). Version 16 designs have no saved timeline
//! folders and open with the empty default.
//! A fillet's `type` can be `spline` (the radius along each edge a B-spline: `start`, `knots`,
//! and the control radii after `radius` in `radii`). Other fillets save as before; 0.8.0 would
//! refuse the unknown type.
//! Under the same version, a sheet metal rule's corner relief shape can also be `tear` (no
//! relief: each bend is cut straight from the bends' corner to where its flange's end
//! starts); a rule that says `notch`, `round` or `rectangular` builds what it did.
//! A sketch's view options can also say `hide_construction` (the Sketch Palette's
//! Construction Geometries); a sketch that shows its construction geometry saves as before.
//! A `SurfaceExtrude` feature (Surface Extrude: `sketch`, optional `profiles`, `curves` and
//! `extent`, walls only) is first saved in 17; 0.8.0 would refuse the unknown feature.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 14;
pub const FORMAT: u32 = 17;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -69,7 +127,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; 16] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -83,6 +141,9 @@ const JSON_STEPS: [JsonStep; 13] = [
|_| Ok(()),
|_| Ok(()),
v13_to_v14,
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
@@ -316,9 +377,43 @@ pub fn read(s: &str) -> Result<Document> {
if d.features.len() > crate::document::MAX_FEATURES
|| d.params.len() > crate::document::MAX_PARAMS
|| d.components.len() > crate::document::MAX_FEATURES
|| d.timeline_groups.len() > crate::document::MAX_FEATURES
|| d.timeline_groups.iter().any(|group| group.features.len() > crate::document::MAX_FEATURES)
{
return Err(DocError::Invalid("document too large".into()));
}
let feature_ids: std::collections::BTreeSet<_> = d.features.iter().map(|feature| feature.id).collect();
let mut group_ids = std::collections::BTreeSet::new();
let mut grouped_features = std::collections::BTreeSet::new();
for group in &d.timeline_groups {
if group.name.trim().is_empty() || group.name.len() > 128 || !group_ids.insert(group.id) {
return Err(DocError::Invalid("damaged design: invalid timeline folder".into()));
}
if group.parent.is_some_and(|parent| parent == group.id) {
return Err(DocError::Invalid("damaged design: timeline folder cannot contain itself".into()));
}
if group.features.iter().any(|feature| !feature_ids.contains(feature) || !grouped_features.insert(*feature)) {
return Err(DocError::Invalid("damaged design: invalid feature in a timeline folder".into()));
}
}
for group in &d.timeline_groups {
if let Some(parent) = group.parent {
if !group_ids.contains(&parent) {
return Err(DocError::Invalid("damaged design: timeline folder has an unknown parent".into()));
}
let mut seen = std::collections::BTreeSet::new();
let mut current = Some(parent);
while let Some(id) = current {
if !seen.insert(id) {
return Err(DocError::Invalid("damaged design: cyclic timeline folders".into()));
}
current = d.timeline_groups.iter().find(|candidate| candidate.id == id).and_then(|candidate| candidate.parent);
}
}
}
if let Some(id) = crate::non_contiguous_timeline_group(&d.features, &d.timeline_groups) {
return Err(DocError::Invalid(format!("damaged design: timeline folder {id} is not contiguous")));
}
for o in &d.occurrences {
if !rigid(&o.transform) {
return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name)));
+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;
+97 -1
View File
@@ -160,6 +160,27 @@ impl Traced {
let lineage = lineages.and_then(|l| Lineage::step(&result, &l));
Traced { body: result, lineage }
}
/// `result`, made by one kernel operation from this body alone. Where that operation's
/// history can't be composed with this body's (it blends an edge the same feature made: a
/// boss's root fillet), this body is a stage of the feature, its faces and edges named as a
/// whole first.
pub fn then(&self, result: Body) -> Traced {
let step = Traced::step(result.clone(), &[self]);
if step.lineage.is_some() {
return step;
}
let lineage = self.lineage.as_ref().and_then(|l| {
let history = result.history()?.clone();
if history.operands != 1 {
return None;
}
let body = crate::ModelBody::new(String::new(), self.body.clone(), 0);
let stage = Arc::new(Operand::Stage(Box::new(Stage { body, lineage: l.clone(), names: OnceLock::new() })));
Some(Lineage { history, operands: vec![stage] })
});
Traced { body: result, lineage }
}
}
/// How the bodies a feature builds from a profile name their faces: what each profile segment's
@@ -186,7 +207,7 @@ impl<'a> ProfileRoles<'a> {
let segments: Vec<&solidcraft_geom::Seg2> =
self.region.map(|r| std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).collect()).unwrap_or_default();
let curves = self.sketch.map(crate::config::ExactSketchCurves::new);
let curve = |segment: usize| -> Option<String> { curves.as_ref()?.find(segments.get(segment)?).map(|id| format!("side:{id}")) };
let curve = |segment: usize| -> Option<String> { curves.as_ref()?.find_along(segments.get(segment)?).map(|id| format!("side:{id}")) };
let roles = history
.faces
.iter()
@@ -228,6 +249,64 @@ impl<'a> ProfileRoles<'a> {
}
}
/// How a loft names its faces: each side by the sketch curves of the sections it runs between
/// (`side:<curve>` for the first section's, `side:s<k>.<curve>` for section `k`'s), named after
/// the section with the most segments (the one each side spans exactly one segment of) and
/// answering to the others; its caps `start` and `end`.
pub(crate) struct SectionRoles<'a> {
/// Each section's sketch and the loop lofted through it, in the order given.
pub sections: Vec<(&'a SolvedSketch, &'a solidcraft_geom::Loop2)>,
}
impl SectionRoles<'_> {
/// The loft `body`, its faces named by their roles where its history gives them.
pub fn tool(&self, body: Body) -> Traced {
let roles = body.history().filter(|h| h.operands == 0).map(|h| self.of(h));
Traced::made(body, roles)
}
fn of(&self, history: &History) -> Vec<Vec<String>> {
// Each profile segment (numbered through the sections in order): its section and role.
let mut segments: Vec<(usize, Option<String>)> = Vec::new();
for (k, (sketch, lp)) in self.sections.iter().enumerate() {
let curves = crate::config::ExactSketchCurves::new(sketch);
for seg in &lp.segs {
let role = curves.find_along(seg).map(|id| if k == 0 { format!("side:{id}") } else { format!("side:s{k}.{id}") });
segments.push((k, role));
}
}
let lead = self.sections.iter().enumerate().max_by_key(|(k, (_, lp))| (lp.segs.len(), std::cmp::Reverse(*k))).map_or(0, |(k, _)| k);
history
.faces
.iter()
.map(|face| {
let mut roles: Vec<(bool, usize, String)> = Vec::new();
for origin in &face.origins {
let (k, role) = match *origin {
FaceOrigin::Cap { end } => (0, Some(if end { "end" } else { "start" }.to_string())),
FaceOrigin::Profile { segment } => match segments.get(segment) {
Some((k, role)) => (*k, role.clone()),
None => (0, None),
},
_ => (0, None),
};
// One unknown origin leaves the face unknown: never a partial guess.
let Some(role) = role else { return Vec::new() };
roles.push((k != lead, k, role));
}
roles.sort();
let mut out: Vec<String> = Vec::new();
for (.., role) in roles {
if !out.contains(&role) {
out.push(role);
}
}
out
})
.collect()
}
}
/// A box primitive, its faces named by the side they face (`+x`, `-z`, …), as its construction
/// records them.
pub(crate) fn box_tool(body: Body) -> Traced {
@@ -260,6 +339,23 @@ pub(crate) fn cylinder_tool(body: Body) -> Traced {
Traced::made(body, roles)
}
/// A tool a feature builds without a sketch (a boss's post, a rib, a lip's band), its faces named
/// by the part it makes: `<part>:top` (its end cap), `<part>:base` (its start cap) and
/// `<part>:side:<k>` (swept from segment `k` of its profile), as its construction records them.
pub(crate) fn part_tool(body: Body, part: &str) -> Traced {
let roles = body.history().filter(|h| h.operands == 0).map(|h| {
h.faces
.iter()
.map(|f| match f.origins.as_slice() {
[FaceOrigin::Cap { end }] => vec![format!("{part}:{}", if *end { "top" } else { "base" })],
[FaceOrigin::Profile { segment }] => vec![format!("{part}:side:{segment}")],
_ => Vec::new(),
})
.collect()
});
Traced::made(body, roles)
}
/// Faces named by their place in a body built as a whole (an imported part's faces in the
/// file's order): `face:<k>`.
pub(crate) fn numbered_tool(body: Body) -> Traced {
+40 -8
View File
@@ -30,6 +30,8 @@ pub fn legacy_name(name: &str) -> Option<&str> {
/// Where a stored edge name was found.
#[derive(Clone, Copy, Debug)]
pub struct EdgeHit {
/// The edge (its index in the body's mesh).
pub edge: usize,
/// A point on the edge: the reference's own point when it lies on it, else the edge's middle.
pub point: Vec3,
/// The named edge is not one edge any more: the piece nearest the point was taken.
@@ -62,7 +64,7 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
let exact = |i: usize| -> Result<EdgeHit, String> {
let on = edge_near(b, &m, i, near);
mid(i)
.map(|p| EdgeHit { point: if on { near } else { p }, split: false, verified: verified(i), on_point: on, legacy: false })
.map(|p| EdgeHit { edge: i, point: if on { near } else { p }, split: false, verified: verified(i), on_point: on, legacy: false })
.ok_or_else(|| "edge".into())
};
if let Some(i) = names.iter().position(|n| n == name) {
@@ -84,7 +86,7 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
.iter()
.filter_map(|i| mid(*i).map(|p| (p, *i)))
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
.map(|(p, i)| EdgeHit { point: p, split: true, verified: verified(i), on_point: false, legacy: false })
.map(|(p, i)| EdgeHit { edge: i, point: p, split: true, verified: verified(i), on_point: false, legacy: false })
.ok_or_else(|| format!("edge `{name}` no longer exists"))
}
@@ -104,7 +106,8 @@ fn nearest_on_face(m: &Mesh, face: usize, p: Vec3) -> Option<(f64, Vec3)> {
let Some([a, b, c]) = m.tri(t) else { continue };
let q = closest(p, a, b, c);
let d = q.dist(p);
if best.is_none_or(|x| d < x.0) {
// A non-finite candidate must never become the best: nothing compares less than NaN.
if d.is_finite() && best.is_none_or(|x| d < x.0) {
best = Some((d, q));
}
}
@@ -136,15 +139,15 @@ fn legacy_locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit,
if let Some(i) = names.iter().position(|n| n == name) {
let on = edge_near(b, &m, i, near);
return edge_mid(&m, i)
.map(|p| EdgeHit { point: if on { near } else { p }, split: false, verified: false, on_point: on, legacy: true })
.map(|p| EdgeHit { edge: i, point: if on { near } else { p }, split: false, verified: false, on_point: on, legacy: true })
.ok_or(());
}
let stem = strip_edge_piece(name);
(0..names.len())
.filter(|i| names.get(*i).is_some_and(|n| strip_edge_piece(n) == stem))
.filter_map(|i| edge_mid(&m, i))
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
.map(|p| EdgeHit { point: p, split: true, verified: false, on_point: false, legacy: true })
.filter_map(|i| edge_mid(&m, i).map(|p| (p, i)))
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
.map(|(p, i)| EdgeHit { edge: i, point: p, split: true, verified: false, on_point: false, legacy: true })
.ok_or(())
}
@@ -211,6 +214,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> {
@@ -288,7 +320,7 @@ fn legacy_locate<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name:
let Some([a, bb, c]) = m.tri(t) else { continue };
let q = closest(p, a, bb, c);
let d = q.dist(p) - if exact { 1e-9 } else { 0.0 };
if best.is_none_or(|x| d < x.0) {
if d.is_finite() && best.is_none_or(|x| d < x.0) {
best = Some((d, bi, *f as usize, q, !exact, tol));
}
}
+46 -12
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.
@@ -112,6 +114,8 @@ fn samples(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
.collect()
}
/// The point of triangle `abc` nearest `p`. A zero-length edge (a cone's tip repeats its apex
/// vertex) has no interior to project onto, so its branch is skipped rather than dividing 0 by 0.
pub(crate) fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
let (ab, ac, ap) = (b - a, c - a, p - a);
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
@@ -124,7 +128,7 @@ pub(crate) fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
return b;
}
let vc = d1 * d4 - d3 * d2;
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 && d1 - d3 > 0.0 {
return a + ab * (d1 / (d1 - d3));
}
let cp = p - c;
@@ -133,11 +137,11 @@ pub(crate) fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
return c;
}
let vb = d5 * d2 - d1 * d6;
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 && d2 - d6 > 0.0 {
return a + ac * (d2 / (d2 - d6));
}
let va = d3 * d6 - d5 * d4;
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 && (d4 - d3) + (d5 - d6) > 0.0 {
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
}
let den = va + vb + vc;
@@ -334,6 +338,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 +408,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 +476,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;
}
@@ -735,6 +755,20 @@ pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec
mod tests {
use super::*;
/// A cone's tip repeats its apex vertex, so a triangle can have a zero-length edge. The nearest
/// point on it must still be a real point (it used to be NaN, which broke picking the tip).
#[test]
fn closest_point_on_a_degenerate_triangle_is_finite() {
let (a, c) = (Vec3::new(0., 0., 0.), Vec3::new(2., 0., 0.));
for (b, c) in [(a, c), (c, c), (a, a)] {
for p in [Vec3::new(1., 1., 0.), Vec3::new(-1., 0.5, 0.), Vec3::new(3., -2., 1.), a] {
let q = closest(p, a, b, c);
assert!(q.x.is_finite() && q.y.is_finite() && q.z.is_finite(), "{a:?} {b:?} {c:?} {p:?} -> {q:?}");
}
}
assert_eq!(closest(Vec3::new(1., 1., 0.), a, a, c), Vec3::new(1., 0., 0.));
}
#[test]
fn merged_face_votes_collectively_for_pieces_of_one_source() {
let body = ModelBody::new("Input".into(), solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(4., 1., 1.)).unwrap(), 1);
+17 -3
View File
@@ -54,7 +54,7 @@ impl FeatureKind {
}
}
},
FeatureKind::Extrude { extent, .. } => {
FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } => {
v.push(("Distance", &mut extent.distance, L));
if let Some(d) = &mut extent.distance2 {
v.push(("Distance 2", d, L));
@@ -83,6 +83,11 @@ impl FeatureKind {
v.push(("End radius", radius2, L));
v.extend(mid.iter_mut().map(|(_, r)| ("Mid radius", r, L)));
}
crate::FilletStyle::Spline { radii, .. } => {
v.push(("Start radius", radius, L));
let n = radii.len();
v.extend(radii.iter_mut().enumerate().map(|(i, r)| (if i + 1 == n { "End radius" } else { "Control radius" }, r, L)));
}
},
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
v.push(("Distance", distance, L));
@@ -223,12 +228,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));
@@ -457,7 +471,7 @@ impl Document {
/// in (feature inputs are stored in mm otherwise). Anything else is returned unchanged.
pub fn with_design_unit(&self, e: &str, kind: Kind) -> String {
let u = self.units.trim();
let bare = expr::is_literal(e) && e.trim().trim_start_matches(['-', '+']).trim().parse::<f64>().is_ok();
let bare = expr::is_bare_number(e);
if kind == Kind::Length && bare && !u.is_empty() && u != "mm" && expr::unit_info(u).is_some_and(|(k, _)| k == Kind::Length) {
format!("{} {u}", e.trim())
} else {
+152
View File
@@ -0,0 +1,152 @@
//! 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)]
pub struct Progress {
cancelled: AtomicBool,
done: AtomicUsize,
total: AtomicUsize,
/// The feature the evaluation waits at until cancelled ([`Progress::hold_at`]).
hold: AtomicUsize,
}
impl Default for Progress {
fn default() -> Self {
Progress { cancelled: AtomicBool::new(false), done: AtomicUsize::new(0), total: AtomicUsize::new(0), hold: AtomicUsize::new(usize::MAX) }
}
}
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)
}
/// Make the watched evaluation wait before feature `index` until it is cancelled: tests
/// use it to act while a load is certainly still running, whatever the machine's speed.
pub fn hold_at(&self, index: usize) {
self.hold.store(index, 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);
while index == cur.progress.hold.load(Ordering::Relaxed) && !cur.progress.is_cancelled() {
#[cfg(not(target_arch = "wasm32"))]
std::thread::sleep(std::time::Duration::from_millis(1));
#[cfg(target_arch = "wasm32")]
std::hint::spin_loop();
}
}
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");
}
}
+101 -20
View File
@@ -1,8 +1,9 @@
//! Resolving sketch links against the model: projecting edges, faces, body silhouettes, other
//! sketches and the origin onto a sketch plane, and intersecting bodies with it.
//!
//! Model geometry is found again on every recompute from the reference point stored in the
//! link (like fillet edges). Edge polylines from the tessellation lie exactly on their curves,
//! Model geometry is found again on every recompute: a face or an edge by the persistent name
//! its link keeps, anything else (and a link saved without a name) from the reference point
//! stored in the link. Edge polylines from the tessellation lie exactly on their curves,
//! so lines and circular arcs are recovered exactly by fitting; anything else (an ellipse from
//! a tilted circle, a spline edge) becomes a chain of lines.
@@ -35,6 +36,9 @@ pub struct Resolved {
/// Exact face selected during this resolution, before its closest point is
/// projected onto an edge shared with another face. Computed metadata only.
pub face_index: Option<usize>,
/// The edge an `Edge` reference resolved to (its index in the body's mesh), whose
/// persistent name the link keeps ([`resolved_edge_name`]). Computed metadata only.
pub edge_index: Option<usize>,
}
/// Resolutions of body-based references, keyed by the reference, the sketch plane and the
@@ -98,13 +102,9 @@ fn resolve_uncached(
let lost = |what: &str| DocError::Invalid(format!("{what} no longer exists"));
let mut moved = false;
let mut face_index = None;
let mut edge_index = None;
let mut source = src.clone();
let mut wires: Vec<Vec<Vec3>> = Vec::new();
// Included curves that leave the sketch plane stay 3D.
let off_plane = |pts: &[Vec3]| {
let size = pts.iter().fold(1.0_f64, |m, p| m.max(p.len()));
pts.iter().any(|p| plane.height(*p).abs() > size * 1e-7)
};
let geom = match src {
LinkSource::Edge { body, at } => {
let (bi, mesh) = body_mesh(st, body, *at)?;
@@ -116,15 +116,8 @@ fn resolve_uncached(
moved = d > (size * 2e-3).max(1e-3);
let name = st.bodies.get(bi).map(|b| b.name.clone()).unwrap_or_else(|| body.clone());
source = LinkSource::Edge { body: name, at: q };
let pts = mesh.edges.get(e).cloned().unwrap_or_default();
match kind {
LinkKind::Intersect => section_of_polyline(plane, &pts),
LinkKind::Include if off_plane(&pts) => {
wires.push(pts);
Vec::new()
}
_ => fit_world(plane, &pts),
}
edge_index = Some(e);
edge_geom(plane, kind, mesh.edges.get(e).cloned().unwrap_or_default(), &mut wires)
}
LinkSource::Face { body, at } => {
let (bi, mesh) = body_mesh(st, body, *at)?;
@@ -188,7 +181,7 @@ fn resolve_uncached(
}
LinkKind::Include => {
let pts: Vec<Vec3> = ss.sketch.polyline(ci).iter().map(|p| ss.plane.to_world(*p)).collect();
if off_plane(&pts) {
if off_plane(plane, &pts) {
wires.push(pts);
Vec::new()
} else {
@@ -259,7 +252,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() } };
@@ -270,7 +266,34 @@ fn resolve_uncached(
if geom.is_empty() && wires.is_empty() {
return Err(DocError::Invalid(format!("the {} projects to nothing on this sketch plane", src.describe())));
}
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved, face_index })
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved, face_index, edge_index })
}
/// Whether a curve (world points) leaves the sketch plane: included, it then stays 3D.
fn off_plane(plane: &Plane, pts: &[Vec3]) -> bool {
let size = pts.iter().fold(1.0_f64, |m, p| m.max(p.len()));
pts.iter().any(|p| plane.height(*p).abs() > size * 1e-7)
}
/// What a body edge (its points) gives a sketch: its section with the sketch plane, itself as a
/// 3D curve (into `wires`) when it is included and leaves the plane, else its projection.
fn edge_geom(plane: &Plane, kind: LinkKind, pts: Vec<Vec3>, wires: &mut Vec<Vec<Vec3>>) -> Vec<LinkGeom> {
match kind {
LinkKind::Intersect => section_of_polyline(plane, &pts),
LinkKind::Include if off_plane(plane, &pts) => {
wires.push(pts);
Vec::new()
}
_ => fit_world(plane, &pts),
}
}
/// The persistent name of the edge an `Edge` reference resolved to, for its link to keep and
/// be found by from then on. None on a mesh body, whose edges have no names.
pub fn resolved_edge_name(st: &ModelState, r: &Resolved) -> Option<String> {
let (LinkSource::Edge { body, .. }, Some(index)) = (&r.source, r.edge_index) else { return None };
let body = st.body(body).filter(|body| !body.body.is_mesh())?;
crate::naming::edge_names(body).get(index).filter(|name| !name.is_empty()).cloned()
}
/// Re-resolve every link of a sketch (in place). Returns warnings for lost, moved or rebuilt
@@ -279,6 +302,36 @@ fn lost(reference: &str) -> DocError {
DocError::Unknown(format!("{reference} no longer exists"))
}
/// The edge a link's stored name finds, and the body it is on: the link's body, or, when no
/// body has that name any more (it was joined into another), the body on which the name finds
/// the edge nearest the link's point.
fn locate_named_edge(st: &ModelState, body: &str, name: &str, at: Vec3) -> Option<(String, crate::naming::EdgeHit)> {
let hit = |b: &crate::ModelBody| crate::naming::locate_edge(b, name, at).ok().map(|hit| (b.name.clone(), hit));
match st.body(body) {
Some(b) => hit(b),
None => st.bodies.iter().filter(|b| !b.body.is_mesh()).filter_map(hit).min_by(|a, b| a.1.point.dist(at).total_cmp(&b.1.point.dist(at))),
}
}
/// An edge found by its persistent name: edge `index` of `body`, with the link's point `near`
/// moved onto it.
fn resolve_named_edge(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str, index: usize, near: Vec3) -> Result<Resolved> {
let target = st.body(body).ok_or_else(|| lost("named projection body"))?;
let pts = target.mesh().edges.get(index).cloned().unwrap_or_default();
let at = pts
.windows(2)
.filter_map(|w| Some(closest_on_segment(near, *w.first()?, *w.get(1)?)))
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
.ok_or_else(|| lost("named projection edge"))?;
let mut wires = Vec::new();
let geom = edge_geom(plane, kind, pts, &mut wires);
if geom.is_empty() && wires.is_empty() {
return Err(DocError::Invalid("named edge projects to nothing on this sketch plane".into()));
}
let source = LinkSource::Edge { body: body.into(), at };
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved: false, face_index: None, edge_index: Some(index) })
}
fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str, index: usize, at: Vec3) -> Result<Resolved> {
let target = st.body(body).ok_or_else(|| lost("named projection body"))?;
let mesh = target.mesh();
@@ -302,7 +355,7 @@ fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str
if geom.is_empty() {
return Err(DocError::Invalid("named face projects to nothing on this sketch plane".into()));
}
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index })
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index, edge_index: None })
}
fn projection_face_edges(body: &crate::ModelBody, mesh: &Mesh, index: u32, plane: &Plane) -> Result<Vec<usize>> {
@@ -321,6 +374,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 { .. }
)
@@ -388,6 +442,7 @@ pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body
source: LinkSource::Face { body: body.name.clone(), at: point },
moved: false,
face_index: if indices.len() == 1 { indices.first().copied() } else { None },
edge_index: None,
})
}
@@ -515,6 +570,22 @@ fn refresh_links_in(
continue;
}
}
// An edge the link keeps by name is that edge of its body, wherever it went. When no
// edge has the name any more the link is lost: no other edge is taken for it.
let mut named_edge = None;
if let (Some(name), LinkSource::Edge { body, at }) = (&l.edge_name, &source) {
match locate_named_edge(st, body, name, *at) {
Some((body, hit)) => {
warn.extend(hit.note(name).map(|note| format!("projected {note}")));
named_edge = Some((body, hit.edge, *at));
}
None => {
sk.set_link_lost(&l.id, true);
warn.push(format!("projected reference lost: edge `{name}` no longer exists; last geometry retained"));
continue;
}
}
}
let resolved = if l.auto_face {
anchor
.filter(|anchor| {
@@ -524,11 +595,21 @@ fn refresh_links_in(
.and_then(|anchor| resolve_anchored_face(doc, st, plane, l.kind, anchor))
} else if let Some((body, index, at)) = named_face {
resolve_named_face(st, plane, l.kind, &body, index, at)
} else if let Some((body, index, at)) = named_edge {
resolve_named_edge(st, plane, l.kind, &body, index, at)
} else {
resolve(doc, vals, st, plane, l.kind, &source)
};
match resolved {
Ok(r) => {
// An edge link saved without a name takes the name of the edge it was found
// on. A sketch that is edited stores it; evaluation leaves the design as it is.
if l.edge_name.is_none()
&& let Some(name) = resolved_edge_name(st, &r)
&& let Some(link) = sk.links.iter_mut().find(|link| link.id == l.id)
{
link.edge_name = Some(name);
}
sk.set_link_source(&l.id, r.source);
let _ = sk.set_link_wires(&l.id, &r.wires);
match sk.update_link(&l.id, &r.geom) {
+3556 -120
View File
File diff suppressed because it is too large Load Diff
+416
View File
@@ -0,0 +1,416 @@
//! Convert to Sheet Metal with bend edges: the faces of a solid, or the walls of a shelled one,
//! become one sheet.
//!
//! The planar faces reached from the fixed face across the bend edges are the sheet's panels,
//! all on one skin of it (the one the fixed face is on). A body with a parallel face the same
//! small distance behind the fixed face and behind the faces at every bend is a shell: it keeps
//! its walls and their thickness. Any other body's faces become the outside of the sheet, with
//! the rule's thickness taken into the body. A bend edge stays its bend's virtual sharp: the
//! panels on either side are set back from it by (R + T)·tan(θ/2) where the skin is the bend's
//! outside and R·tan(θ/2) where it is the inside, so the body keeps its envelope. An edge between
//! two panels that is no bend is cut open: each panel ends square to the sheet, half the rule's
//! gap from the plane halfway between the two.
//!
//! The result is the model every sheet metal feature builds ([`SheetBody`]): the fixed face is
//! the base panel and every other panel a flange on its parent's edge, its outline taken through
//! the inverse of its fold. Unfold, the flat pattern, the bend table and cuts then work as on a
//! sheet made from flanges.
//!
//! Not supported yet: curved faces (rolled sheet, a filleted edge as a bend), faces with curved
//! edges or holes, loops of bends, two faces that meet along more than one edge.
use std::collections::BTreeMap;
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Vec2, Vec3};
use solidcraft_kernel::{Body, FaceSurface, face_surface};
use super::{RuleValues, SheetBody, SheetFlange, base_flange, polys_cross, signed_area, snap};
use crate::{DocError, ModelBody, Result, apply_point, mat_inverse};
const MAX_BENDS: usize = 1000;
fn err<T>(msg: impl Into<String>) -> Result<T> {
Err(DocError::Invalid(msg.into()))
}
fn at(p: Vec3) -> String {
format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z)
}
/// A planar face of the body: its outward normal and its outline, counter-clockwise about the
/// normal. Edge `i` runs from corner `i` to the next one, along face `across[i]`.
struct Face {
n: Vec3,
pts: Vec<Vec3>,
across: Vec<Option<usize>>,
}
/// A face of the sheet: the panel it hangs off (none for the fixed face), and the bend between
/// the two: its angle (positive toward the material, as a flange's) and its setback on this skin.
struct Panel {
face: Face,
parent: Option<usize>,
angle: f64,
setback: f64,
}
/// Area of a loop in space, positive when it runs counter-clockwise about `n`.
fn area_about(pts: &[Vec3], n: Vec3) -> f64 {
let Some(&o) = pts.first() else { return 0.0 };
pts.windows(2).map(|w| (w[0] - o).cross(w[1] - o).dot(n)).sum::<f64>() / 2.0
}
/// Turn a loop's per-edge values round with its corners (edge `i` starts at corner `i`).
fn turn<T>(per_edge: &mut [T]) {
per_edge.reverse();
if !per_edge.is_empty() {
per_edge.rotate_left(1);
}
}
/// Face `f` of the body as a panel outline. Only a planar face bounded by one loop of straight
/// edges can be one.
fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result<Face> {
let Some(FaceSurface::Plane { normal: n, .. }) = face_surface(b, f) else {
return err("not supported yet: curved faces in a converted sheet (only planar faces convert)");
};
// Its edges, each with the face on its other side.
let mut edges = Vec::new();
for (line, faces) in mesh.edges.iter().zip(&mesh.edge_faces) {
if !faces.iter().any(|g| *g as usize == f) {
continue;
}
let (Some(&p), Some(&q)) = (line.first(), line.last()) else { continue };
if p.dist(q) <= tol || line.iter().any(|x| x.dist_to_segment(p, q) > tol) {
return err("not supported yet: faces with curved edges in a converted sheet");
}
edges.push((p, q, faces.iter().map(|g| *g as usize).find(|g| *g != f)));
}
// Chained head to tail from the first one, they close one loop.
let Some((start, mut end, across)) = edges.pop() else { return err("a face of the body has no edges") };
let (mut pts, mut tags) = (vec![start], vec![across]);
while end.dist(start) > tol {
let Some(k) = edges.iter().position(|e| e.0.dist(end) <= tol || e.1.dist(end) <= tol) else {
return err("the outline of a face of the body does not close");
};
let (p, q, across) = edges.swap_remove(k);
pts.push(end);
tags.push(across);
end = if p.dist(end) <= tol { q } else { p };
}
if !edges.is_empty() {
return err("not supported yet: faces with holes in a converted sheet (cut them after converting)");
}
// A straight run along one neighbour is one edge, however the body's edges divide it (a
// shell's outer corners are divided at the height of its floor).
let m = pts.len();
let corner = |i: usize| -> bool {
let before = (i + m - 1) % m;
let (Some(&a), Some(&p), Some(&b)) = (pts.get(before), pts.get(i), pts.get((i + 1) % m)) else { return true };
tags.get(before) != tags.get(i) || p.dist_to_segment(a, b) > tol
};
let keep: Vec<bool> = (0..m).map(corner).collect();
let (mut pts, mut tags): (Vec<Vec3>, Vec<Option<usize>>) = pts.into_iter().zip(tags).zip(keep).filter(|(_, kept)| *kept).map(|(x, _)| x).unzip();
if area_about(&pts, n) < 0.0 {
pts.reverse();
turn(&mut tags);
}
Ok(Face { n, pts, across: tags })
}
/// The face straight through the material behind planar face `f`, when it is parallel to it,
/// and how far behind it lies.
fn behind(b: &Body, mesh: &Mesh, f: usize) -> Option<(usize, f64)> {
let Some(FaceSurface::Plane { normal: n, point: o }) = face_surface(b, f) else { return None };
// From the middle of the face's largest triangle.
let area = |t: &[Vec3; 3]| (t[1] - t[0]).cross(t[2] - t[0]).len();
let tri = mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g as usize == f).filter_map(|(t, _)| mesh.tri(t));
let [p, q, r] = tri.max_by(|x, y| area(x).total_cmp(&area(y)))?;
let (_, hit) = mesh.raycast((p + q + r) * (1.0 / 3.0), n * -1.0)?;
let g = *mesh.tri_face.get(hit)? as usize;
let Some(FaceSurface::Plane { normal, point }) = face_surface(b, g) else { return None };
(normal.dot(n) < -1.0 + 1e-9).then_some((g, (o - point).dot(n)))
}
/// How far a panel's edge along a rip moves in, so that the panel, cut square to the sheet,
/// stays half the gap from the plane halfway between it and the panel `y` across the rip.
/// `e` is the edge's direction round panel `x`.
fn rip_cut(x: &Face, y: &Face, e: Vec3, t: f64, gap: f64) -> Result<f64> {
// Into each panel from the edge, square to the edge.
let (wx, wy) = (x.n.cross(e), e.cross(y.n));
let mid = (wx - wy).normalized().filter(|m| wx.dot(*m) > 1e-9);
let Some(mid) = mid else { return err("two faces of the sheet lie on each other along the edge between them") };
// The sheet's other face lies `t` behind the panel: nearer that plane, or further from it.
let lean = -x.n.dot(mid) * t;
Ok((gap / 2.0 - lean.min(0.0)) / wx.dot(mid))
}
/// A counter-clockwise outline with each edge moved in by its own amount. Moved edges meet
/// where their lines cross; two edges in one line that move by different amounts are joined by
/// a square step. Returns the corners and, per edge, the edge it was (none for a step).
fn inset(pts: &[Vec2], by: &[f64]) -> Result<(Vec<Vec2>, Vec<Option<usize>>)> {
let n = pts.len();
if n < 3 {
return err("a face of the sheet has no outline");
}
let moved = |i: usize| -> Option<(Vec2, Vec2)> {
let (a, b) = (*pts.get(i % n)?, *pts.get((i + 1) % n)?);
let d = (b - a).normalized()?;
Some((a + Vec2::new(-d.y, d.x) * *by.get(i % n)?, d))
};
let (mut out, mut from) = (Vec::new(), Vec::new());
for i in 0..n {
let (Some((p0, d0)), Some((p1, d1))) = (moved(i + n - 1), moved(i)) else { return err("a face of the sheet has an edge without length") };
let cross = d0.cross(d1);
if cross.abs() > 1e-9 {
out.push(p0 + d0 * ((p1 - p0).cross(d1) / cross));
} else if d0.dot(d1) > 0.0 {
let end = p0 + d0 * (p1 - p0).dot(d0);
if end.dist(p1) > 1e-9 {
out.push(end);
from.push(None);
}
out.push(p1);
} else {
return err("the outline of a face of the sheet doubles back on itself");
}
from.push(Some(i));
}
// Every edge still runs the way it did, and the outline is still one simple loop.
let m = out.len();
let kept = (0..m).all(|j| match (from.get(j).copied().flatten().and_then(moved), out.get(j), out.get((j + 1) % m)) {
(Some((_, d)), Some(&a), Some(&b)) => (b - a).dot(d) > 1e-9,
_ => true,
});
if !kept || !(signed_area(&out) > 0.0) || polys_cross(&out, &out) {
return err("the bends' setbacks and the gaps at the cut edges use up an edge of a face");
}
Ok((out, from))
}
/// Give flange `i` its panel: `outline` in flat coordinates, counter-clockwise, its edge at the
/// bend first trimmed to the end of the bend zone.
fn set_panel(sh: &mut SheetBody, i: usize, outline: &[Vec2]) {
let ba = sh.flanges.get(i).map_or(0.0, |f| sh.allowance(f));
let Some(f) = sh.flanges.get_mut(i) else { return };
// Its corners along the bend (u) and past the bend zone (v); those at the zone exactly on it.
let uv: Vec<(f64, f64)> =
outline.iter().map(|q| ((*q - f.p).dot(f.d), (*q - f.p).dot(f.n) - ba)).map(|(u, v)| (u, if v.abs() < 1e-6 { 0.0 } else { v })).collect();
f.leg = uv.iter().fold(0.0, |m, (_, v)| m.max(*v));
let level = |h: f64| -> Vec<f64> { uv.iter().filter(|(_, v)| (v - h).abs() < 1e-6).map(|(u, _)| *u).collect() };
match (level(0.0).as_slice(), level(f.leg).as_slice()) {
// Two corners at the bend and two on a far edge parallel to it: a flange's own panel,
// with its run-ons past the bend's ends and its slanted ends.
(&[a, b], &[c, d]) if uv.len() == 4 && f.leg > 1e-6 => {
let zero = |x: f64| if x.abs() < 1e-12 { 0.0 } else { x };
f.ext = (zero(-a.min(b)), zero(a.max(b) - f.len));
f.slope = (zero((a.min(b) - c.min(d)) / f.leg), zero((c.max(d) - a.max(b)) / f.leg));
}
_ => f.shape = Some(uv.iter().map(|(u, v)| f.p + f.d * *u + f.n * (ba + *v)).collect()),
}
}
/// Names of the edges of `body` at the bend points, to find them again after the body changes.
/// A point on no edge gets none: the conversion then says so, instead of bending the edge that
/// happens to be nearest.
pub fn bend_edge_names(st: &crate::ModelState, body: &str, pts: &[Vec3]) -> Vec<String> {
let Some(b) = st.body(body) else { return Vec::new() };
let (mesh, near) = (b.mesh(), (b.body.size() * 2e-3).max(1e-3));
let on = |p: &Vec3| mesh.edges.iter().any(|e| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) <= near));
pts.iter().zip(crate::naming::edge_names_at(b, pts)).map(|(p, name)| if on(p) { name } else { String::new() }).collect()
}
/// Convert body `mb` to a sheet under the rule `rule` with values `rv`: `face` is a point on the
/// fixed face and `bends` are points on the sharp edges that become bends (the body's
/// coordinates, mm). See the module docs.
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<SheetBody> {
if bends.is_empty() || bends.len() > MAX_BENDS {
return err(format!("pick 1…{MAX_BENDS} bend edges"));
}
if !(rv.gap >= 0.0 && rv.bend_radius >= 0.0) {
return err("the rule's gap and bend radius must not be negative");
}
let (b, mesh) = (&mb.body, mb.mesh());
let size = b.size();
if !(size.is_finite() && size > 0.0) {
return err("the body has no size");
}
// Points closer than `tol` are one corner; a pick lies within `near` of what it picks.
let (tol, near) = ((size * 1e-6).max(1e-6), (size * 2e-3).max(1e-3));
let Some((_, f0)) = crate::appearance::nearest_face(mb, face).filter(|(d, _)| *d <= near) else {
return err(format!("no face of the body at the fixed face point {}", at(face)));
};
let fixed = outline(b, &mesh, f0, tol).map_err(|e| DocError::Invalid(format!("the fixed face can't be a face of the sheet: {e}")))?;
// The two faces along each bend edge.
let mut picks = Vec::with_capacity(bends.len());
for p in bends {
let dist = |e: &Vec<Vec3>| e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
let edge = mesh.edges.iter().zip(&mesh.edge_faces).map(|(e, faces)| (dist(e), faces)).min_by(|x, y| x.0.total_cmp(&y.0));
match edge.filter(|(d, _)| *d <= near).map(|(_, faces)| faces.as_slice()) {
Some(&[x, y]) if x != y => picks.push((*p, (x as usize, y as usize))),
Some(_) => return err(format!("the edge at the bend point {} does not lie between two faces", at(*p))),
None => return err(format!("no edge of the body at the bend point {}", at(*p))),
}
}
// A shell: the same small distance of material behind the fixed face and the faces at every
// bend. It keeps its thickness, and a bend picked on one skin is that bend on the other too.
let thin = behind(b, &mesh, f0).map(|(_, d)| d).filter(|d| *d > 0.0 && d * d * 16.0 <= area_about(&fixed.pts, fixed.n));
let wall = |f: usize, d: f64| behind(b, &mesh, f).filter(|(_, e)| (e - d).abs() <= 1e-6 * d.max(1.0)).map(|(g, _)| g);
let backs: Vec<Option<(usize, usize)>> = picks.iter().map(|(_, (x, y))| thin.and_then(|d| Some((wall(*x, d)?, wall(*y, d)?)))).collect();
let shell = thin.filter(|_| backs.iter().all(Option::is_some));
let t = shell.map_or(rv.thickness, |d| (d * 1e9).round() / 1e9);
let r = rv.bend_radius;
let joined = |x: usize, y: usize| mesh.edge_faces.iter().any(|faces| faces.contains(&(x as u32)) && faces.contains(&(y as u32)));
let mut todo: Vec<(Vec3, Vec<(usize, usize)>)> = Vec::with_capacity(picks.len());
for (&(p, pair), back) in picks.iter().zip(&backs) {
let other = back.filter(|(x, y)| shell.is_some() && joined(*x, *y));
todo.push((p, std::iter::once(pair).chain(other).collect()));
}
// The panels: the faces reached from the fixed face across the bends, each hanging off the
// face it was reached from.
let mut panels = BTreeMap::from([(f0, Panel { face: fixed, parent: None, angle: 0.0, setback: 0.0 })]);
let mut order = vec![f0];
loop {
let waiting = todo.len();
let mut rest = Vec::new();
for (p, pairs) in todo {
let reached = pairs.iter().find(|(x, y)| panels.contains_key(x) || panels.contains_key(y));
let Some(&(x, y)) = reached else {
rest.push((p, pairs));
continue;
};
let (from, to) = if panels.contains_key(&x) { (x, y) } else { (y, x) };
if let Some(seen) = panels.get(&to) {
// The same bend picked twice is one bend; any other edge between two panels
// would close a ring of bends, which no flat sheet folds into.
if seen.parent == Some(from) || panels.get(&from).is_some_and(|k| k.parent == Some(to)) {
continue;
}
return err(format!("the bend at {} closes a loop of bends (leave one of its edges unpicked: it is cut open)", at(p)));
}
let child = outline(b, &mesh, to, tol)
.map_err(|e| DocError::Invalid(format!("the face across the bend at {} can't be a face of the sheet: {e}", at(p))))?;
let Some(parent) = panels.get(&from) else { continue };
let theta = parent.face.n.dot(child.n).clamp(-1.0, 1.0).acos();
if !(theta > 1e-6 && theta < std::f64::consts::PI - 1e-6) {
return err(format!("the faces at the bend at {} must meet at an angle between 0 and 180°", at(p)));
}
// Up: the child rises on the material side of its parent, which is behind its face.
let o = parent.face.pts.first().copied().unwrap_or_default();
let lift = child.pts.iter().map(|q| (*q - o).dot(parent.face.n)).fold(0.0, |m: f64, h| if h.abs() > m.abs() { h } else { m });
let (angle, sharp) = if lift < 0.0 { (theta, r + t) } else { (-theta, r) };
panels.insert(to, Panel { face: child, parent: Some(from), angle, setback: sharp * (theta / 2.0).tan() });
order.push(to);
}
todo = rest;
if todo.is_empty() || todo.len() == waiting {
break;
}
}
if let Some((p, _)) = todo.first() {
return err(format!("the bend at {} does not connect to the fixed face through the other bends", at(*p)));
}
// The flat frame: on the fixed face, x along its longest edge, z into the material.
let Some(base) = panels.get(&f0) else { return err("fixed face") };
let corners = base.face.pts.iter().zip(base.face.pts.iter().cycle().skip(1));
let longest = corners.max_by(|x, y| x.0.dist(*x.1).total_cmp(&y.0.dist(*y.1)));
let frame = longest
.and_then(|(a, b)| Some((*a, (*b - *a).normalized()?)))
.and_then(|(a, x)| Plane::new(a, x, x.cross(base.face.n)))
.ok_or_else(|| DocError::Invalid("the fixed face has no plane".into()))?;
let mut sh = base_flange(&mb.name, rule, &RuleValues { thickness: t, ..*rv }, frame, Region2::default());
// Lay the panels out, parents first: each is its face, seen through the inverse of its
// fold, with its edges moved in by a bend's setback or a rip's cut.
let mut queue: Vec<(usize, Option<usize>)> = vec![(f0, None)];
let mut next = 0;
while let Some(&(pf, fl)) = queue.get(next) {
next += 1;
let Some(panel) = panels.get(&pf) else { continue };
let inv = mat_inverse(&sh.world(fl)).ok_or_else(|| DocError::Invalid("sheet panel frame".into()))?;
let n = panel.face.pts.len();
let (mut pts, mut by) = (Vec::with_capacity(n), Vec::with_capacity(n));
for (i, p) in panel.face.pts.iter().enumerate() {
let q = apply_point(&inv, *p);
if !(q.z.abs() <= tol) {
return err(format!("the face at {} does not lie where its bend puts it ({:.6} mm off)", at(*p), q.z));
}
pts.push(Vec2::new(q.x, q.y));
let e = panel.face.pts.get((i + 1) % n).and_then(|b| (*b - *p).normalized()).unwrap_or(Vec3::X);
by.push(match panel.face.across.get(i).copied().flatten().and_then(|g| Some((g, panels.get(&g)?))) {
Some((g, _)) if panel.parent == Some(g) => panel.setback,
Some((_, other)) if other.parent == Some(pf) => other.setback,
Some((_, other)) => rip_cut(&panel.face, &other.face, e, t, rv.gap)?,
None => 0.0,
});
}
let mut across = panel.face.across.clone();
if signed_area(&pts) < 0.0 {
pts.reverse();
turn(&mut by);
turn(&mut across);
}
let first = panel.face.pts.first().copied().unwrap_or_default();
let (trimmed, from) = inset(&pts, &by).map_err(|e| DocError::Invalid(format!("{e} (the face at {})", at(first))))?;
match fl {
None => sh.base.outer = Loop2::polygon(&trimmed.iter().map(|q| snap(*q)).collect::<Vec<_>>()),
Some(i) => set_panel(&mut sh, i, &trimmed),
}
// Its children: flanges on the trimmed edges they bend at.
let m = trimmed.len();
for &g in &order {
let Some(child) = panels.get(&g).filter(|c| c.parent == Some(pf)) else { continue };
let runs: Vec<usize> =
(0..m).filter(|j| from.get(*j).copied().flatten().and_then(|i| across.get(i).copied().flatten()) == Some(g)).collect();
let (a, b) = match runs.as_slice() {
&[j] => (trimmed.get(j).copied().map(snap), trimmed.get((j + 1) % m).copied().map(snap)),
_ => return err("not supported yet: two faces of the sheet that meet along more than one edge"),
};
let (Some(a), Some(b)) = (a, b) else { continue };
let Some(d) = (b - a).normalized() else { return err("a bend edge has no length") };
sh.flanges.push(SheetFlange {
parent: fl,
p: a,
d,
len: a.dist(b),
n: Vec2::new(d.y, -d.x),
angle: child.angle,
radius: r,
leg: 0.0,
ext: (0.0, 0.0),
slope: (0.0, 0.0),
shape: None,
tag: String::new(),
tear: (false, false),
});
queue.push((g, Some(sh.flanges.len() - 1)));
}
}
Ok(sh)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edges_move_in_by_their_own_amounts_and_step_along_one_line() {
let v = Vec2::new;
let square = [v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)];
let (p, from) = inset(&square, &[1.0, 0.0, 2.0, 0.0]).unwrap();
assert_eq!(p, vec![v(0.0, 1.0), v(10.0, 1.0), v(10.0, 8.0), v(0.0, 8.0)]);
assert_eq!(from, vec![Some(0), Some(1), Some(2), Some(3)]);
// Two edges in one line that move by different amounts: a square step between them,
// so 5 x 2 goes from the 10 x 10.
let split = [v(0.0, 0.0), v(5.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)];
let (p, from) = inset(&split, &[0.0, 2.0, 0.0, 0.0, 0.0]).unwrap();
assert_eq!(p, vec![v(0.0, 0.0), v(5.0, 0.0), v(5.0, 2.0), v(10.0, 2.0), v(10.0, 10.0), v(0.0, 10.0)]);
assert_eq!(from, vec![Some(0), None, Some(1), Some(2), Some(3), Some(4)]);
// A slanted end keeps its line: the corner slides along it (2 up a 45° edge is 2 along).
let slanted = [v(0.0, 0.0), v(10.0, 0.0), v(15.0, 5.0), v(0.0, 5.0)];
let (p, _) = inset(&slanted, &[2.0, 0.0, 0.0, 0.0]).unwrap();
assert!(p[1].dist(v(12.0, 2.0)) < 1e-12 && p[0].dist(v(0.0, 2.0)) < 1e-12, "{p:?}");
// Setbacks longer than the face leave nothing of it.
assert!(inset(&square, &[6.0, 0.0, 6.0, 0.0]).is_err());
assert!(inset(&square, &[f64::NAN, 0.0, 0.0, 0.0]).is_err());
}
}
+19
View File
@@ -386,6 +386,25 @@ fn timeline_clones_share_meshes_even_when_cloned_before_tessellation() {
assert!(!a.mesh().triangles.is_empty());
}
#[test]
fn feature_inserted_at_a_marker_inside_a_timeline_folder_joins_it() {
let mut doc = Document::new("folder insertion");
let first = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("First")).unwrap();
let second =
doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("Second")).unwrap();
doc.timeline_groups.push(TimelineGroup { id: 100, name: "Pair".into(), parent: None, features: vec![first, second] });
doc.marker = Some(1);
let inserted =
doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("Inserted")).unwrap();
assert_eq!(doc.features.iter().map(|feature| feature.id).collect::<Vec<_>>(), vec![first, inserted, second]);
assert_eq!(doc.timeline_groups[0].features, vec![first, inserted, second]);
assert_eq!(doc.marker, Some(2));
assert!(non_contiguous_timeline_group(&doc.features, &doc.timeline_groups).is_none());
assert!(Document::from_json(&doc.to_json()).is_ok(), "the inserted feature must leave a reopenable design");
}
/// A construction plane that is its own side resolves through the model and back into the
/// document. The depth count has to survive that hop, or the recursion overflows the stack and
/// aborts the process, which no panic guard can intercept.
@@ -5,6 +5,35 @@ use serde_json::{Value, json};
use crate::Session;
/// Runs in PR CI as well as the heavier fixed-seed suite. Two pattern copies are
/// disconnected, and one cylinder overlaps the block by half its volume.
#[test]
fn partial_face_contact_pattern_has_analytic_material() {
// Repeated construction exposes re-imprinting a cylinder through both of its
// side faces, which can create a redundant arc along the first imprint.
for _ in 0..8 {
check_partial_face_contact_pattern();
}
}
fn check_partial_face_contact_pattern() {
let s = replay(include_str!("../../../examples/regressions/partial_face_contact_pattern.json"));
let (mut volume, mut area, mut shells) = (0.0, 0.0, 0);
for b in &s.model.state().bodies {
assert!(b.body.validity().is_empty(), "{:?}", b.body.validity());
let m = b.body.tessellate(1e-5).unwrap().measure();
volume += m.volume;
area += m.area;
shells += b.body.shell_count();
}
let expected_volume = 25.0 * 47.5 * 37.5 + 3.5 * std::f64::consts::PI * 25.0 * 2.5;
let expected_area = 2.0 * (25.0 * 47.5 + 25.0 * 37.5 + 47.5 * 37.5) + 237.5 * std::f64::consts::PI - 25.0;
assert!((volume - expected_volume).abs() < 0.02, "{volume} expected {expected_volume}");
assert!((area - expected_area).abs() < 0.02, "{area} expected {expected_area}");
assert_eq!(shells, 3);
println!("#674 volume {volume:.6} / {expected_volume:.6} mm³; area {area:.6} / {expected_area:.6} mm²; {shells} shells");
}
fn replay(script: &str) -> Session {
let mut s = Session::default();
let script: Value = serde_json::from_str(script).unwrap();
@@ -88,3 +117,101 @@ 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);
}
/// #679: one sketch of a 2 × 2 grid of 10 mm squares; three cells extruded up 10 mm (XO/XX) and
/// three down 10 mm, joined (XX/OX). The two L's meet back to back over two whole cells, along
/// an edge and at the centre: one solid of 6 cells × 10 mm × 100 mm².
#[test]
fn ls_extruded_up_and_down_from_one_grid_join() {
let mut s = Session::default();
let run = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Grid"}));
for (p0, p1) in [([-10, -10], [0, 0]), ([0, -10], [10, 0]), ([-10, 0], [0, 10]), ([0, 0], [10, 10])] {
run(&mut s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1}));
}
run(&mut s, "sketch.finish", json!({}));
let cells = |pts: &[[f64; 2]]| Value::Array(pts.iter().map(|p| json!({"point": p})).collect());
run(
&mut s,
"solid.extrude",
json!({"sketch": "Grid", "profiles": cells(&[[-5., 5.], [-5., -5.], [5., -5.]]), "distance": 10, "body_name": "Logo"}),
);
let reply = run(
&mut s,
"solid.extrude",
json!({"sketch": "Grid", "profiles": cells(&[[-5., 5.], [5., 5.], [5., -5.]]), "distance": "10 mm", "direction": "negative", "operation": "join"}),
);
assert!(reply["warning"].as_str().is_none_or(|w| !w.contains("tool displacement")), "{reply}");
let st = s.model.state();
assert_eq!(st.bodies.len(), 1);
let body = &st.body("Logo").unwrap().body;
assert_eq!(body.shell_count(), 1);
assert!(body.validity().is_empty(), "{:?}", body.validity());
let v = volume(&s, "Logo");
assert!((v - 6000.0).abs() < 1e-6, "{v}");
}
+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();
+45
View File
@@ -72,6 +72,8 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
for f in feats {
let mut kind = f.kind.clone();
kind.remap_refs(&sketches, &names);
// A body made by an earlier copy is the one this copy acts on (#613).
kind.remap_bodies(&bodies);
if let FeatureKind::Sketch { plane, .. } = &mut kind
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
{
@@ -208,4 +210,47 @@ mod tests {
let mut t = Session::default();
assert!(t.execute("timeline.paste", &json!({})).is_err());
}
#[test]
fn paste_at_a_marker_inside_a_timeline_folder_joins_the_folder() {
let mut s = Session::default();
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Source", "SourceBody", 40)] {
run(
&mut s,
"solid.box",
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
);
}
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First", "Second"]}))["group"].as_u64().unwrap();
run(&mut s, "timeline.copy", json!({"features": ["Source"]}));
run(&mut s, "timeline.roll_to", json!({"position": 1}));
let pasted = run(&mut s, "timeline.paste", json!({}))["pasted"][0]["id"].as_u64().unwrap();
let folder = s.doc.timeline_groups.iter().find(|folder| folder.id == group).unwrap();
let first = s.doc.find_feature("First").unwrap().id;
let second = s.doc.find_feature("Second").unwrap().id;
assert_eq!(folder.features, vec![first, pasted, second]);
assert!(solidcraft_doc::Document::from_json(&s.doc.to_json()).is_ok(), "pasting inside a folder must leave a reopenable design");
}
#[test]
fn pasted_fillet_rounds_the_copied_body_it_names() {
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, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 10, "operation": "new", "body_name": "Block", "name": "Block"}));
run(&mut s, "solid.fillet", json!({"body": "Block", "radius": 2, "edges": [[20, 0, 10], [20, 20, 10]], "name": "Round"}));
let one = volume(&mut s);
assert!(one < 8000.0 - 1.0, "the fillet removes material: {one}");
run(&mut s, "timeline.copy", json!({"features": ["S", "Block", "Round"]}));
let r = run(&mut s, "timeline.paste", json!({"translate": [0, 40, 0]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
let round = r["pasted"][2]["name"].as_str().unwrap_or_default().to_string();
let def = run(&mut s, "timeline.inspect", json!({"feature": round}));
assert_eq!(def["definition"]["body"], "Block (copy)", "{def}");
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["body_count"], 2, "{m}");
assert!((volume(&mut s) - 2.0 * one).abs() < 1e-6, "both blocks are rounded: {m}");
}
}
+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/17\""));
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()))? {
+408 -5
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,12 +24,29 @@ 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.group", "New Feature Folder", group)
.icon("folder")
.doc_only()
.params("name?; features?: [id|name]; parent?: folder id (features leave any other timeline folder)"),
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup)
.doc_only()
.params("group: id | groups: [ids] (features remain in the timeline)"),
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).doc_only().params("group: id, name"),
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group)
.doc_only()
.params("features?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).params(
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot; atomically changes folder membership and moves all selected contents as one block",
),
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)"),
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder)
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).params(
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; refused when a feature that worked would fail",
),
CommandSpec::new("timeline.redefine", "Edit Feature", redefine)
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"),
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents)
@@ -163,18 +182,324 @@ 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}))
}
fn timeline_group_id(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
timeline_group_id_field(s, p, "group", cmd)
}
fn timeline_group_id_field(s: &Session, p: &Value, field: &str, cmd: &str) -> Result<u64> {
let id = p.get(field).and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{field}` must be a timeline folder id")))?;
if s.doc.timeline_groups.iter().any(|group| group.id == id) { Ok(id) } else { Err(bad(cmd, format!("no timeline folder {id}"))) }
}
fn group_features(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<Vec<u64>> {
let values = match p.get("features") {
Some(Value::Array(values)) => values,
None if !required => return Ok(Vec::new()),
_ => return Err(bad(cmd, "`features` must be a list of feature ids or names")),
};
if values.is_empty() && required {
return Err(bad(cmd, "`features` must list at least one feature"));
}
if values.len() > 10_000 {
return Err(bad(cmd, "too many features"));
}
let mut ids = Vec::new();
for value in values {
let id = feature_id(s, Some(value), cmd)?;
if !ids.contains(&id) {
ids.push(id);
}
}
Ok(ids)
}
fn group_ids(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<Vec<u64>> {
let values = match p.get("groups") {
Some(Value::Array(values)) => values,
None if !required => return Ok(Vec::new()),
None => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
_ => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
};
if values.is_empty() && required {
return Err(bad(cmd, "`groups` must list at least one folder"));
}
if values.len() > 10_000 {
return Err(bad(cmd, "too many folders"));
}
let mut ids = Vec::new();
for value in values {
let id = value.as_u64().ok_or_else(|| bad(cmd, "`groups` must contain folder ids"))?;
if !s.doc.timeline_groups.iter().any(|group| group.id == id) {
return Err(bad(cmd, format!("no timeline folder {id}")));
}
if !ids.contains(&id) {
ids.push(id);
}
}
Ok(ids)
}
fn group_move_would_cycle(s: &Session, target: u64, sources: &[u64]) -> bool {
let sources: std::collections::BTreeSet<u64> = sources.iter().copied().collect();
let parents: std::collections::BTreeMap<u64, Option<u64>> = s.doc.timeline_groups.iter().map(|group| (group.id, group.parent)).collect();
let mut current = Some(target);
let mut steps = 0usize;
while let Some(id) = current {
if sources.contains(&id) {
return true;
}
steps += 1;
if steps > s.doc.timeline_groups.len() {
return true;
}
current = parents.get(&id).copied().flatten();
}
false
}
fn group_feature_ids(doc: &solidcraft_doc::Document, roots: &[u64]) -> std::collections::BTreeSet<u64> {
let by_id: std::collections::BTreeMap<u64, &solidcraft_doc::TimelineGroup> = doc.timeline_groups.iter().map(|group| (group.id, group)).collect();
let mut children = std::collections::BTreeMap::<u64, Vec<u64>>::new();
for group in &doc.timeline_groups {
if let Some(parent) = group.parent {
children.entry(parent).or_default().push(group.id);
}
}
let mut pending = roots.to_vec();
let mut visited = std::collections::BTreeSet::new();
let mut features = std::collections::BTreeSet::new();
while let Some(id) = pending.pop() {
if !visited.insert(id) {
continue;
}
if let Some(group) = by_id.get(&id) {
features.extend(group.features.iter().copied());
pending.extend(children.get(&id).into_iter().flatten().copied());
}
}
features
}
fn sort_group_features(doc: &mut solidcraft_doc::Document) {
let positions: std::collections::BTreeMap<u64, usize> = doc.features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
for group in &mut doc.timeline_groups {
group.features.sort_by_key(|feature| positions.get(feature).copied().unwrap_or(usize::MAX));
}
}
fn require_contiguous_groups(doc: &solidcraft_doc::Document, cmd: &str) -> Result<()> {
let Some(id) = solidcraft_doc::non_contiguous_timeline_group(&doc.features, &doc.timeline_groups) else { return Ok(()) };
let name = doc.timeline_groups.iter().find(|group| group.id == id).map(|group| group.name.as_str()).unwrap_or("unknown");
Err(bad(cmd, format!("timeline folder `{name}` must contain one contiguous run of features")))
}
fn group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.group";
let mut features = group_features(s, p, false, cmd)?;
let groups = group_ids(s, p, false, cmd)?;
let name = match str_(p, "name").map(str::trim).filter(|name| !name.is_empty()) {
Some(name) if name.len() <= 128 => name.to_string(),
Some(_) => return Err(bad(cmd, "`name` must be 1…128 characters")),
None => format!("Folder{}", s.doc.timeline_groups.len() + 1),
};
let id = s
.doc
.timeline_groups
.iter()
.map(|group| group.id)
.max()
.unwrap_or(0)
.checked_add(1)
.ok_or_else(|| bad(cmd, "no more timeline folder ids are available"))?;
let parent = match p.get("parent") {
Some(Value::Null) | None => None,
Some(_) => Some(timeline_group_id_field(s, p, "parent", cmd)?),
};
if let Some(parent) = parent
&& group_move_would_cycle(s, parent, &groups)
{
return Err(bad(cmd, "a new folder cannot contain an ancestor of its parent"));
}
let nested = group_feature_ids(&s.doc, &groups);
features.retain(|feature| !nested.contains(feature));
let mut next = (*s.doc).clone();
for group in &mut next.timeline_groups {
group.features.retain(|feature| !features.contains(feature));
if groups.contains(&group.id) {
group.parent = Some(id);
}
}
next.timeline_groups.push(solidcraft_doc::TimelineGroup { id, name: name.clone(), parent, features });
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
Ok(json!({"group": id, "name": name, "parent": parent, "groups": groups}))
}
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.ungroup";
let ids = match p.get("groups") {
Some(Value::Array(values)) if !values.is_empty() && values.len() <= 10_000 => {
let mut ids = Vec::new();
for value in values {
let id = value.as_u64().ok_or_else(|| bad(cmd, "`groups` must contain timeline folder ids"))?;
if !s.doc.timeline_groups.iter().any(|group| group.id == id) {
return Err(bad(cmd, format!("no timeline folder {id}")));
}
if !ids.contains(&id) {
ids.push(id);
}
}
ids
}
Some(Value::Array(_)) => return Err(bad(cmd, "`groups` must list 1…10000 timeline folder ids")),
Some(_) => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
None => vec![timeline_group_id(s, p, cmd)?],
};
let parents: std::collections::BTreeMap<u64, Option<u64>> =
s.doc.timeline_groups.iter().filter(|group| ids.contains(&group.id)).map(|group| (group.id, group.parent)).collect();
let surviving_parent = |mut parent: Option<u64>| {
let mut steps = 0usize;
while let Some(id) = parent.filter(|id| ids.contains(id)) {
parent = parents.get(&id).copied().flatten();
steps += 1;
if steps > parents.len() {
return None;
}
}
parent
};
let mut next = (*s.doc).clone();
let moved_features: Vec<(Option<u64>, Vec<u64>)> = next
.timeline_groups
.iter()
.filter(|group| ids.contains(&group.id))
.map(|group| (surviving_parent(group.parent), group.features.clone()))
.collect();
for group in &mut next.timeline_groups {
if ids.contains(&group.id) {
continue;
}
group.parent = surviving_parent(group.parent);
for (_, features) in moved_features.iter().filter(|(parent, _)| *parent == Some(group.id)) {
group.features.extend(features.iter().copied());
}
}
next.timeline_groups.retain(|group| !ids.contains(&group.id));
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
Ok(json!({"groups": ids}))
}
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.rename_group";
let id = timeline_group_id(s, p, cmd)?;
let name = str_(p, "name")
.map(str::trim)
.filter(|name| !name.is_empty() && name.len() <= 128)
.ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
if let Some(group) = s.doc_mut().timeline_groups.iter_mut().find(|group| group.id == id) {
group.name = name.to_string();
}
Ok(json!({"group": id, "name": name}))
}
fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.move_to_group";
let features = group_features(s, p, false, cmd)?;
let groups = group_ids(s, p, false, cmd)?;
if features.is_empty() && groups.is_empty() {
return Err(bad(cmd, "list at least one feature or folder"));
}
let target = match p.get("group") {
Some(Value::Null) | None => None,
Some(_) => Some(timeline_group_id(s, p, cmd)?),
};
if let Some(target) = target
&& group_move_would_cycle(s, target, &groups)
{
return Err(bad(cmd, "a folder cannot be moved into itself or one of its descendants"));
}
let mut next = (*s.doc).clone();
for group in &mut next.timeline_groups {
group.features.retain(|feature| !features.contains(feature));
if Some(group.id) == target {
group.features.extend(features.iter().copied());
}
}
for group in &mut next.timeline_groups {
if groups.contains(&group.id) {
group.parent = target;
}
}
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
Ok(json!({"features": features, "groups": groups, "group": target}))
}
fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.move_many";
let mut features = group_features(s, p, false, cmd)?;
let groups = group_ids(s, p, false, cmd)?;
if features.is_empty() && groups.is_empty() {
return Err(bad(cmd, "list at least one feature or folder"));
}
let target = match p.get("group") {
Some(Value::Null) | None => None,
Some(_) => Some(timeline_group_id(s, p, cmd)?),
};
if let Some(target) = target
&& group_move_would_cycle(s, target, &groups)
{
return Err(bad(cmd, "a folder cannot be moved into itself or one of its descendants"));
}
let nested = group_feature_ids(&s.doc, &groups);
features.retain(|feature| !nested.contains(feature));
let moving: Vec<u64> =
s.doc.features.iter().filter(|feature| nested.contains(&feature.id) || features.contains(&feature.id)).map(|feature| feature.id).collect();
let mut next = (*s.doc).clone();
for group in &mut next.timeline_groups {
group.features.retain(|feature| !features.contains(feature));
if Some(group.id) == target {
group.features.extend(features.iter().copied());
}
if groups.contains(&group.id) {
group.parent = target;
}
}
sort_group_features(&mut next);
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
let (next, ordered) = reorder_features(s, next, &moving, slot, cmd)?;
if next != *s.doc {
*s.doc_mut() = next;
}
Ok(json!({"features": ordered, "groups": groups, "group": target, "position": slot}))
}
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
let n = s.doc.features.len();
let pos = match p.get("feature") {
@@ -322,6 +647,20 @@ 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));
}
}
// New bend edges for a conversion: the names of the body's edges there now.
if let (FeatureKind::SheetConvert { bends: a, body, .. }, FeatureKind::SheetConvert { bends: b, body: was, .. }) = (&nf.kind, &f.kind)
&& (a != b || body != was)
{
nf.edge_names = solidcraft_doc::sheet::bend_edge_names(&s.model.state_before(id), body, a);
}
// 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)?;
@@ -397,6 +736,8 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let f = next.features.remove(from);
let name = f.name.clone();
next.features.insert(to, f);
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
// Everything that evaluated before must still evaluate.
let mut after = solidcraft_doc::Model::new();
after.evaluate(&next);
@@ -409,16 +750,78 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"feature": id, "position": to}))
}
fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.reorder_many";
let ids = group_features(s, p, true, cmd)?;
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
let (next, ordered) = reorder_features(s, (*s.doc).clone(), &ids, slot, cmd)?;
if next != *s.doc {
*s.doc_mut() = next;
}
Ok(json!({"features": ordered, "position": slot.min(s.doc.features.len())}))
}
fn reorder_features(
s: &Session,
mut next: solidcraft_doc::Document,
ids: &[u64],
slot: usize,
cmd: &str,
) -> Result<(solidcraft_doc::Document, Vec<u64>)> {
let n = next.features.len();
let slot = slot.min(n);
let selected: std::collections::BTreeSet<u64> = ids.iter().copied().collect();
let ordered: Vec<u64> = next.features.iter().filter(|feature| selected.contains(&feature.id)).map(|feature| feature.id).collect();
let before_slot = next.features.iter().take(slot).filter(|feature| selected.contains(&feature.id)).count();
let insert_at = slot.saturating_sub(before_slot).min(n.saturating_sub(ordered.len()));
let mut moving = Vec::new();
next.features.retain(|feature| {
if selected.contains(&feature.id) {
moving.push(feature.clone());
false
} else {
true
}
});
let unchanged = s.doc.features.iter().map(|feature| feature.id).eq(next.features[..insert_at]
.iter()
.chain(moving.iter())
.chain(next.features[insert_at..].iter())
.map(|feature| feature.id));
next.features.splice(insert_at..insert_at, moving);
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
if !unchanged {
let mut after = solidcraft_doc::Model::new();
after.evaluate(&next);
let broken = newly_broken(&s.model, &after);
if !broken.is_empty() {
let list: Vec<String> = broken.iter().map(|(name, error)| format!("{name} ({error})")).collect();
return Err(bad(cmd, format!("moving the selected features there breaks {}", list.join("; "))));
}
}
Ok((next, ordered))
}
fn input_keys(kind: &FeatureKind) -> Vec<(String, solidcraft_doc::expr::Kind)> {
let mut keys: Vec<_> = kind.inputs().into_iter().map(|(label, _, unit)| (label.to_string(), unit)).collect();
if let FeatureKind::Fillet { style, .. } = kind {
if let Some((key, _)) = keys.first_mut() {
*key = "fillet.radius".into();
}
if let solidcraft_doc::FilletStyle::Variable { mid, .. } = style {
for ((key, _), (position, _)) in keys.iter_mut().skip(2).zip(mid) {
*key = format!("fillet.mid.{}", position.to_bits());
match style {
solidcraft_doc::FilletStyle::Variable { mid, .. } => {
for ((key, _), (position, _)) in keys.iter_mut().skip(2).zip(mid) {
*key = format!("fillet.mid.{}", position.to_bits());
}
}
solidcraft_doc::FilletStyle::Spline { .. } => {
for (i, (key, _)) in keys.iter_mut().enumerate().skip(1) {
*key = format!("fillet.control.{i}");
}
}
solidcraft_doc::FilletStyle::Constant | solidcraft_doc::FilletStyle::Chord => {}
}
}
keys
+160
View File
@@ -310,3 +310,163 @@ fn inserting_variable_fillet_control_preserves_parameters_at_existing_positions(
assert_eq!(length["value"], 3.0);
assert_eq!(width["value"], 4.0);
}
#[test]
fn timeline_folders_are_command_backed_saved_undoable_and_follow_feature_ids() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
run(&mut s, "solid.fillet", json!({"edges": [[10, 5, 10]], "radius": 1, "name": "Round"}));
let base = s.doc.find_feature("Base").unwrap().id;
let round = s.doc.find_feature("Round").unwrap().id;
let made = run(&mut s, "timeline.group", json!({"name": "Housing", "features": ["Base", round]}));
let group = made["group"].as_u64().unwrap();
assert_eq!(s.doc.timeline_groups[0].features, vec![base, round]);
run(&mut s, "timeline.rename", json!({"feature": base, "name": "Foundation"}));
assert_eq!(s.doc.timeline_groups[0].features, vec![base, round], "folders follow stable ids, not names");
run(&mut s, "timeline.rename_group", json!({"group": group, "name": "Main housing"}));
run(&mut s, "timeline.move_to_group", json!({"features": [round], "group": null}));
assert_eq!(s.doc.timeline_groups[0].features, vec![base]);
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(reopened.timeline_groups, s.doc.timeline_groups);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.timeline_groups[0].features, vec![base, round]);
run(&mut s, "timeline.delete", json!({"features": [base]}));
assert_eq!(s.doc.timeline_groups[0].features, vec![round], "deleted features leave folders cleanly");
run(&mut s, "timeline.ungroup", json!({"group": group}));
assert!(s.doc.timeline_groups.is_empty());
}
#[test]
fn timeline_reorders_selected_features_as_one_block_and_removes_folders_in_one_step() {
let mut s = Session::default();
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] {
run(
&mut s,
"solid.box",
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
);
}
let names = |session: &Session| session.doc.features.iter().map(|feature| feature.name.clone()).collect::<Vec<_>>();
assert_eq!(names(&s), vec!["First", "Second", "Third"]);
run(&mut s, "timeline.reorder_many", json!({"features": ["First", "Third"], "position": 3}));
assert_eq!(names(&s), vec!["Second", "First", "Third"], "selected features keep their relative order");
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&s), vec!["First", "Second", "Third"]);
run(&mut s, "edit.redo", json!({}));
assert_eq!(names(&s), vec!["Second", "First", "Third"]);
run(&mut s, "edit.undo", json!({}));
let first = run(&mut s, "timeline.group", json!({"name": "One", "features": ["First"]}))["group"].as_u64().unwrap();
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.timeline_groups.is_empty(), "creating a folder is undoable");
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.timeline_groups.first().map(|group| group.id), Some(first));
let second = run(&mut s, "timeline.group", json!({"name": "Two", "features": ["Second"]}))["group"].as_u64().unwrap();
run(&mut s, "timeline.ungroup", json!({"groups": [first, second]}));
assert!(s.doc.timeline_groups.is_empty());
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.timeline_groups.len(), 2, "removing several folders is one undoable command");
run(&mut s, "edit.redo", json!({}));
assert!(s.doc.timeline_groups.is_empty(), "removing folders is redoable");
}
#[test]
fn timeline_folders_refuse_unknown_non_contiguous_and_split_contents() {
let mut s = Session::default();
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] {
run(
&mut s,
"solid.box",
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
);
}
let before = s.doc.clone();
let error = s.execute("timeline.group", &json!({"features": ["First", "Third"]})).unwrap_err().to_string();
assert!(error.contains("contiguous"), "{error}");
assert_eq!(s.doc, before);
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First", "Second"]}))["group"].as_u64().unwrap();
let before = s.doc.clone();
let error = s.execute("timeline.reorder_many", &json!({"features": ["First"], "position": 3})).unwrap_err().to_string();
assert!(error.contains("contiguous"), "{error}");
assert_eq!(s.doc, before, "a refused reorder must not split the folder");
for params in [json!({"features": [999_u64], "group": group}), json!({"groups": [999_u64], "group": group})] {
let error = s.execute("timeline.move_to_group", &params).unwrap_err().to_string();
assert!(error.contains("no feature") || error.contains("no timeline folder"), "{error}");
}
let mut damaged: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
let first = s.doc.find_feature("First").unwrap().id;
let third = s.doc.find_feature("Third").unwrap().id;
damaged["timeline_groups"] = json!([{"id": 1, "name": "Broken", "features": [first, third]}]);
let error = solidcraft_doc::Document::from_json(&damaged.to_string()).unwrap_err().to_string();
assert!(error.contains("not contiguous"), "{error}");
}
#[test]
fn timeline_move_many_is_atomic_and_one_undo_step() {
let mut s = Session::default();
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] {
run(
&mut s,
"solid.box",
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
);
}
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First"]}))["group"].as_u64().unwrap();
let before = (*s.doc).clone();
run(&mut s, "timeline.move_many", json!({"features": ["Third"], "group": group, "position": 1}));
assert_eq!(s.doc.features.iter().map(|feature| feature.name.as_str()).collect::<Vec<_>>(), vec!["First", "Third", "Second"]);
assert_eq!(s.doc.timeline_groups[0].features.len(), 2);
run(&mut s, "edit.undo", json!({}));
assert_eq!(*s.doc, before, "one undo restores both order and folder membership");
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.timeline_groups[0].features.len(), 2);
let child = run(&mut s, "timeline.group", json!({"name": "Child", "features": ["Second"]}))["group"].as_u64().unwrap();
let before_parent = (*s.doc).clone();
let parent = run(&mut s, "timeline.group", json!({"name": "Parent", "groups": [group, child]}))["group"].as_u64().unwrap();
assert!(s.doc.timeline_groups.iter().filter(|folder| folder.parent == Some(parent)).count() == 2);
run(&mut s, "edit.undo", json!({}));
assert_eq!(*s.doc, before_parent, "creating a parent for selected folders is one undo step");
}
#[test]
fn failed_timeline_move_many_keeps_folder_membership_and_order() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
run(&mut s, "solid.fillet", json!({"edges": [[10, 5, 10]], "radius": 1, "name": "Round"}));
let group = run(&mut s, "timeline.group", json!({"name": "Destination"}))["group"].as_u64().unwrap();
let before = s.doc.clone();
let undo = s.undo.len();
let error = s.execute("timeline.move_many", &json!({"features": ["Base"], "group": group, "position": 2})).unwrap_err().to_string();
assert!(error.contains("breaks"), "{error}");
assert_eq!(s.doc, before);
assert_eq!(s.undo.len(), undo, "a refused atomic move adds no undo step");
}
#[test]
fn timeline_folders_can_nest_and_reject_cycles() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
let root = run(&mut s, "timeline.group", json!({"name": "Root"}))["group"].as_u64().unwrap();
let child = run(&mut s, "timeline.group", json!({"name": "Child", "parent": root}))["group"].as_u64().unwrap();
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, Some(root));
run(&mut s, "timeline.move_to_group", json!({"groups": [child], "group": null}));
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, None);
run(&mut s, "timeline.move_to_group", json!({"groups": [child], "group": root}));
let error = s.execute("timeline.move_to_group", &json!({"groups": [root], "group": child})).unwrap_err().to_string();
assert!(error.contains("itself") || error.contains("descendant"), "{error}");
let before = s.doc.clone();
let error = s.execute("timeline.move_many", &json!({"groups": [root], "group": child, "position": 0})).unwrap_err().to_string();
assert!(error.contains("itself") || error.contains("descendant"), "{error}");
assert_eq!(s.doc, before);
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(reopened.timeline_groups, s.doc.timeline_groups);
run(&mut s, "timeline.ungroup", json!({"group": root}));
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, None, "deleting a folder promotes its children");
}
+33 -11
View File
@@ -20,7 +20,12 @@ fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
/// The outward normal of the planar body face at `p`.
pub(super) fn face_normal(s: &Session, p: Vec3) -> Option<Vec3> {
let (m, f, d, _) = face_at(s, p)?;
face_normal_in(s, p, None)
}
/// The outward normal of the planar face at `p` of `body` (any body when `None`).
pub(super) fn face_normal_in(s: &Session, p: Vec3, body: Option<&str>) -> Option<Vec3> {
let (m, f, d, _) = face_at_in(s, p, body).ok().flatten()?;
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
if d > tol * 10.0 {
return None;
@@ -52,12 +57,20 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
Some(ns.iter().all(|n| n.dot(n0) > 1.0 - 1e-6))
}
/// The body face under a point: (mesh, face index, distance).
/// The body face under a point: (mesh, face index, distance, body).
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64, String)> {
face_at_in(s, p, None).ok().flatten()
}
type FacePick = (std::sync::Arc<Mesh>, u32, f64, String);
/// The face under a point of `only` (any body when `None`). Where faces of several bodies are
/// equally near (bodies touching flush), the pick is ambiguous: `Err` names those bodies.
fn face_at_in(s: &Session, p: Vec3, only: Option<&str>) -> std::result::Result<Option<FacePick>, Vec<String>> {
let st = s.model.state();
let mut best: Option<(std::sync::Arc<Mesh>, u32, f64, String)> = None;
let mut ambiguous = false;
for b in &st.bodies {
let mut best: Option<FacePick> = None;
let mut tied: Vec<String> = Vec::new();
for b in st.bodies.iter().filter(|b| only.is_none_or(|n| b.name == n)) {
let m = b.mesh();
let mut near: Option<(u32, f64)> = None;
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
@@ -70,13 +83,16 @@ fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64, Stri
if let Some((f, d)) = near {
if best.as_ref().is_none_or(|x| d < x.2 - 1e-8) {
best = Some((m.clone(), f, d, b.name.clone()));
ambiguous = false;
tied.clear();
} else if best.as_ref().is_some_and(|x| (d - x.2).abs() <= 1e-8) {
ambiguous = true;
tied.push(b.name.clone());
}
}
}
if ambiguous { None } else { best }
match best {
Some(b) if !tied.is_empty() => Err(std::iter::once(b.3).chain(tied).collect()),
b => Ok(b),
}
}
/// Circle through three points.
@@ -97,11 +113,17 @@ fn xy(v: Vec2) -> Value {
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
/// the sketch id and a point inside the face (sketch coordinates). The sketch is left active.
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<(u64, Vec2)> {
let (m, f, d, body) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?;
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec2)> {
let none = || match face_body {
Some(b) => bad(cmd, format!("no face of `{b}` at `face`")),
None => bad(cmd, "no body face at `face`"),
};
let (m, f, d, body) = face_at_in(s, point, face_body)
.map_err(|names| bad(cmd, format!("faces of several bodies meet at `face` ({}): name one with `face_body`", names.join(", "))))?
.ok_or_else(none)?;
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
if d > tol * 10.0 {
return Err(bad(cmd, "no body face at `face`"));
return Err(none());
}
let tris: Vec<[Vec3; 3]> = m.triangles.iter().zip(&m.tri_face).filter(|(_, tf)| **tf == f).filter_map(|(t, _)| m.tri(t)).collect();
let normal = tris.iter().find_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).ok_or_else(|| bad(cmd, "degenerate face"))?;
+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)
);
}
+190 -52
View File
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, expr::Kind, point_expr};
use solidcraft_geom::Vec3;
use super::CommandSpec;
use super::{CommandSpec, not_in_sketch};
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec2, vec3};
use crate::{EngineError, Result, Session};
@@ -13,11 +13,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("extrude")
.key("E")
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"),
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.sweep", "Sweep", sweep)
.at("SOLID", "CREATE")
.icon("sweep")
@@ -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; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; 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|taper (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; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, 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,14 +107,14 @@ 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").enabled(not_in_sketch).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
/// face, and the unit normal.
fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
if let Some(fp) = p.get("face").and_then(vec3) {
return Some((fp, super::face::face_normal(s, fp)?));
return Some((fp, super::face::face_normal_in(s, fp, str_(p, "face_body"))?));
}
let sid = feature_sketch(s, p, "Extrude").ok()?;
let st = s.model.state();
@@ -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,8 +389,14 @@ 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)),
// A conversion's bend edges are edges of the body it converts.
FeatureKind::SheetConvert { body, bends, .. } if !bends.is_empty() => {
(Vec::new(), solidcraft_doc::sheet::bend_edge_names(&s.model.state(), body, bends))
}
_ => (Vec::new(), Vec::new()),
};
@@ -470,8 +478,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> {
@@ -479,11 +499,31 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
Some(fp) => {
let (id, inside) = super::face::sketch_of_face(s, fp, cmd)?;
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
(id, Some(ProfileSel::Points { points: vec![inside] }))
}
None => (feature_sketch(s, p, cmd)?, None),
};
let extent = extent_param(s, p, cmd)?;
let kind = FeatureKind::Extrude {
sketch,
profiles: match face_profile {
Some(f) => f,
None => profiles(p, cmd)?,
},
extent,
operation: match str_(p, "operation") {
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
_ => operation(p, cmd)?,
},
targets: string_list(p, "targets"),
};
add_feature(s, p, kind)
}
/// An extrude's extent from its parameters: distance (or through all, or to an object), the
/// side(s), a second distance, a start offset, a taper, and from an object.
pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result<Extent> {
let through_all = bool_(p, "through_all").unwrap_or(false) || str_(p, "extent").is_some_and(|e| e.eq_ignore_ascii_case("through_all"));
let to_object = p.get("to").is_some();
if to_object && p.get("to").and_then(vec3).is_none() {
@@ -512,30 +552,17 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(d) = &start_offset {
check_expr(s, d, Kind::Length, cmd, "start_offset")?;
}
let kind = FeatureKind::Extrude {
sketch,
profiles: match face_profile {
Some(f) => f,
None => profiles(p, cmd)?,
},
extent: Extent {
distance,
direction,
distance2,
start_offset,
through_all,
taper,
to: p.get("to").and_then(vec3),
to_offset: expr(p, "to_offset"),
from: p.get("from").and_then(vec3),
},
operation: match str_(p, "operation") {
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
_ => operation(p, cmd)?,
},
targets: string_list(p, "targets"),
};
add_feature(s, p, kind)
Ok(Extent {
distance,
direction,
distance2,
start_offset,
through_all,
taper,
to: p.get("to").and_then(vec3),
to_offset: expr(p, "to_offset"),
from: p.get("from").and_then(vec3),
})
}
fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
@@ -543,7 +570,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
// A planar body face revolves like a profile (its edges projected into a sketch on it).
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
Some(fp) => {
let (id, inside) = super::face::sketch_of_face(s, fp, cmd)?;
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
s.active_sketch = None;
(id, Some(ProfileSel::Points { points: vec![inside] }))
}
@@ -596,14 +623,36 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, kind)
}
fn radius_expr(p: &Value, cmd: &str) -> Result<String> {
fn radius_expr(s: &Session, p: &Value, cmd: &str) -> Result<String> {
match (expr(p, "radius"), expr(p, "diameter")) {
(Some(r), _) => Ok(r),
(None, Some(d)) => Ok(format!("({d}) / 2")),
(None, Some(d)) => {
let [d] = length_operands(s, [(&d, &typed_length_unit(s))]);
Ok(format!("({d}) / 2"))
}
_ => Err(bad(cmd, "needs `radius` or `diameter`")),
}
}
/// The unit a bare length typed into a command means: the design's units. (A point's bare
/// coordinates, and a bare number already stored in a feature, are millimetres.)
pub(super) fn typed_length_unit(s: &Session) -> String {
solidcraft_doc::expr::length_unit_or_mm(&s.doc.units).to_string()
}
/// The lengths a command works one expression out from (a radius from a diameter, a pitch from
/// a height), each with the unit a bare number means where it came from. Bare numbers get that
/// unit written in, so the expression keeps the meaning they had: inside a larger expression a
/// bare number is read in the design's units of the moment, and changing them would resize the
/// part (#716). Next to a unit-less expression that is not a bare number (`2 * 5`, a unit-less
/// parameter), which is read in the design's units itself, they stay bare and are read with it,
/// as before.
pub(super) fn length_operands<const N: usize>(s: &Session, operands: [(&str, &str); N]) -> [String; N] {
use solidcraft_doc::expr::is_bare_number;
let follows = operands.iter().any(|(e, _)| !is_bare_number(e) && s.doc.eval(e, Kind::Unitless).is_ok());
operands.map(|(e, unit)| if !follows && is_bare_number(e) { format!("{} {unit}", e.trim()) } else { e.to_string() })
}
/// A placement point: `[x, y, z]` (or `{x, y, z}`) of numbers (mm) or length expressions;
/// the origin when it is not given. Anything else is refused (never read as the origin).
pub(super) fn point_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<PointExpr>> {
@@ -648,11 +697,17 @@ fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
}
let corner = match (point_param(s, p, "corner", cmd)?, point_param(s, p, "center", cmd)?) {
(Some(c), _) => c,
// The corner half a size back from the centre (numbers stay numbers).
// The corner half a size back from the centre (numbers stay numbers, in mm). The centre's
// bare numbers are millimetres, the sizes' the design's units.
(None, Some([cx, cy, cz])) => {
let unit = typed_length_unit(s);
let mm_per_unit = solidcraft_doc::expr::unit_info(&unit).map_or(1.0, |(_, k)| k);
let back = |c: String, size: &String| match (c.parse::<f64>(), size.parse::<f64>()) {
(Ok(a), Ok(b)) => format!("{}", a - b / 2.0),
_ => format!("({c}) - ({size}) / 2"),
(Ok(a), Ok(b)) => format!("{}", a - b * mm_per_unit / 2.0),
_ => {
let [c, size] = length_operands(s, [(&c, "mm"), (size, &unit)]);
format!("({c}) - ({size}) / 2")
}
};
[back(cx, &l), back(cy, &w), back(cz, &h)]
}
@@ -663,7 +718,7 @@ fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.cylinder";
let radius = radius_expr(p, cmd)?;
let radius = radius_expr(s, p, cmd)?;
let height = req_expr(cmd, p, "height")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
check_expr(s, &height, Kind::Length, cmd, "height")?;
@@ -674,7 +729,7 @@ fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.sphere";
let radius = radius_expr(p, cmd)?;
let radius = radius_expr(s, p, cmd)?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
let center = point_param(s, p, "center", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
add_feature(s, p, FeatureKind::Sphere { center, radius, operation: operation(p, cmd)? })
@@ -738,7 +793,9 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
FeatureKind::Chamfer { distance, .. } => ("distance", distance.clone()),
_ => return Err(bad(cmd, "that face is not a fillet's or a chamfer's")),
};
let new = format!("({old}) + ({distance})");
// A bare number stored in the feature is millimetres; the distance's, the design's units.
let [old, more] = length_operands(s, [(&old, "mm"), (&distance, &typed_length_unit(s))]);
let new = format!("({old}) + ({more})");
let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?;
let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?;
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed"), "restored": out.get("restored")}));
@@ -747,7 +804,13 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?;
return (r.run)(s, &json!({"faces": [face], "distance": distance}));
}
let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (format!("-({distance})"), "negative", "cut") };
// Pushed in: the extrude cuts the other way by as much (a bare number stays one, so it keeps
// being read as a bare number typed into the extrude).
let flipped = match distance.trim().parse::<f64>() {
Ok(v) if solidcraft_doc::expr::is_bare_number(&distance) => format!("{}", -v),
_ => format!("-({distance})"),
};
let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (flipped, "negative", "cut") };
extrude(s, &json!({"face": face, "distance": dist, "direction": dir, "operation": op}))
}
@@ -783,22 +846,52 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
let smooth = p.get("smooth").and_then(Value::as_bool).unwrap_or(false);
solidcraft_doc::FilletStyle::Variable { radius2, start, mid, smooth }
}
Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord or variable)"))),
Some("spline") => {
let start = p
.get("start")
.and_then(vec3)
.ok_or_else(|| bad(cmd, "a spline fillet needs `start`: [x,y,z] near the end its positions start from"))?;
let knots: Vec<f64> = p.get("knots").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_f64).collect();
let mut radii = Vec::new();
for r in p.get("radii").and_then(Value::as_array).into_iter().flatten().take(100) {
let r = match r {
Value::String(x) => x.clone(),
Value::Number(n) => n.to_string(),
_ => return Err(bad(cmd, "a control radius is a number or an expression")),
};
check_expr(s, &r, Kind::Length, cmd, "control radius")?;
radii.push(r);
}
// All control radii (`radius` and `radii`) plus the degree (1…3) plus one.
if radii.is_empty() || !(radii.len() + 3..=radii.len() + 5).contains(&knots.len()) {
return Err(bad(
cmd,
"a spline fillet needs `radii` (the control radii after `radius`) and `knots` (as many as all control radii, the degree 1…3 and one more)",
));
}
solidcraft_doc::FilletStyle::Spline { start, knots, radii }
}
Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord, variable or spline)"))),
};
let fit = match str_(p, "fit").map(str::to_ascii_lowercase).as_deref() {
None | Some("exact") => solidcraft_doc::FilletFit::Exact,
Some("reduce") => solidcraft_doc::FilletFit::Reduce,
Some(o) => return Err(bad(cmd, format!("unknown fit `{o}` (exact or reduce)"))),
Some("taper") => solidcraft_doc::FilletFit::Taper,
Some(o) => return Err(bad(cmd, format!("unknown fit `{o}` (exact, reduce or taper)"))),
};
if fit == solidcraft_doc::FilletFit::Reduce && !style.is_constant() {
return Err(bad(cmd, "`fit: reduce` takes a constant radius"));
if !fit.is_exact() && !style.is_constant() {
return Err(bad(cmd, "`fit: reduce` and `fit: taper` take a constant radius"));
}
let mut out = add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string), style, fit })?;
// 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 +966,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 +1399,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 +1653,18 @@ 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;
#[cfg(test)]
#[path = "move_in_sketch_tests.rs"]
mod move_in_sketch_tests;
+13 -3
View File
@@ -6,7 +6,9 @@ use solidcraft_doc::expr::Kind;
use solidcraft_geom::Vec3;
use super::CommandSpec;
use super::features::{add_feature, axis_dir_param, check_expr, feature_sketch, plane_param, point_param, profiles};
use super::features::{
add_feature, axis_dir_param, check_expr, feature_sketch, length_operands, plane_param, point_param, profiles, typed_length_unit,
};
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{Result, Session};
@@ -49,10 +51,18 @@ fn coil(s: &mut Session, p: &Value) -> Result<Value> {
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
let turns = expr(p, "revolutions").or_else(|| expr(p, "turns"));
let (height, pitch) = (expr(p, "height"), expr(p, "pitch"));
// The pitch or the revolutions from the height keep the units the lengths were given in.
let unit = typed_length_unit(s);
let (turns, pitch) = match (turns, height, pitch) {
(Some(n), _, Some(pt)) => (n, pt),
(Some(n), Some(h), None) => (n.clone(), format!("({h}) / ({n})")),
(None, Some(h), Some(pt)) => (format!("({h}) / ({pt})"), pt),
(Some(n), Some(h), None) => {
let [h] = length_operands(s, [(&h, &unit)]);
(n.clone(), format!("({h}) / ({n})"))
}
(None, Some(h), Some(pt)) => {
let [h, by] = length_operands(s, [(&h, &unit), (&pt, &unit)]);
(format!("({h}) / ({by})"), pt)
}
_ => return Err(bad(cmd, "give two of `revolutions`, `height` and `pitch`")),
};
check_expr(s, &turns, Kind::Unitless, cmd, "revolutions")?;
File diff suppressed because one or more lines are too long
+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));
}
+329
View File
@@ -0,0 +1,329 @@
//! 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}");
}
/// Offset Face raises one piece of a split top: the pieces no longer share a plane, so each
/// takes a sketch by its own name (the face's, qualified by the faces around it) and follows it.
#[test]
fn a_raised_piece_of_a_split_top_takes_a_sketch_by_its_own_name() {
let mut s = slotted_block();
run(&mut s, "parameters.add", json!({"name": "raise", "expression": "2 mm"}));
run(&mut s, "solid.offset_face", json!({"faces": [[5, 15, 10]], "distance": "raise", "name": "Raise"}));
no_errors(&s);
sketch_on(&mut s, [5., 15., 12.], "Raised");
sketch_on(&mut s, [35., 15., 10.], "Kept");
let (raised, certificate) = healthy(&s, "Raised");
assert_eq!(certificate, Some(FaceCertificate::Lineage));
assert!((raised.origin.z - 12.).abs() < 1e-9 && (raised.normal().z - 1.).abs() < 1e-9, "{raised:?}");
assert!((healthy(&s, "Kept").0.origin.z - 10.).abs() < 1e-9);
run(&mut s, "parameters.change", json!({"name": "raise", "expression": "3 mm"}));
no_errors(&s);
assert!((healthy(&s, "Raised").0.origin.z - 13.).abs() < 1e-9);
assert!((healthy(&s, "Kept").0.origin.z - 10.).abs() < 1e-9);
let t = reopened(&s);
assert!((healthy(&t, "Raised").0.origin.z - 13.).abs() < 1e-9);
}
/// A plastic Boss keeps the history of the body it stands on (#494): the plate's faces still
/// take attached sketches, and so does the boss's own top, which follows its height.
#[test]
fn faces_after_a_boss_take_a_sketch_that_follows_it() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "boss_h", "expression": "12 mm"}));
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 2, "name": "Plate"}));
run(
&mut s,
"plastic.boss",
json!({"position": [20, 20, 2], "diameter": 7, "height": "boss_h", "hole_diameter": 2.5, "fillet": 0.5, "name": "Boss"}),
);
no_errors(&s);
sketch_on(&mut s, [5., 25., 2.], "OnPlate");
sketch_on(&mut s, [80., 25., 1.], "OnSide");
sketch_on(&mut s, [22.5, 20., 14.], "OnBoss");
for name in ["OnPlate", "OnSide"] {
healthy(&s, name);
}
let (top, certificate) = healthy(&s, "OnBoss");
assert_eq!(certificate, Some(FaceCertificate::Lineage));
assert!((top.origin.z - 14.).abs() < 1e-9, "{top:?}");
run(&mut s, "parameters.change", json!({"name": "boss_h", "expression": "15 mm"}));
no_errors(&s);
assert!((healthy(&s, "OnBoss").0.origin.z - 17.).abs() < 1e-9);
healthy(&reopened(&s), "OnBoss");
}
/// A Hole names its faces by the part they cut (a counterbore's floor is `F<hole>:cbore:base`),
/// and a pattern's copies of it as copies: each floor takes its own sketch, which follows the
/// counterbore's depth. The plate's faces stay attachable too.
#[test]
fn counterbore_floors_and_their_pattern_copies_take_a_sketch() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "cb", "expression": "3 mm"}));
run(&mut s, "solid.box", json!({"length": 80, "width": 30, "height": 10, "name": "Plate"}));
run(
&mut s,
"solid.hole",
json!({"position": [15, 15, 10], "diameter": 5, "type": "counterbore", "cb_diameter": 10, "cb_depth": "cb", "name": "Hole"}),
);
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Hole"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 25, "name": "Row"}));
no_errors(&s);
for (name, x) in [("Seed", 18.5), ("Copy", 43.5), ("Top", 30.)] {
sketch_on(&mut s, [x, 15., if name == "Top" { 10. } else { 7. }], name);
}
for name in ["Seed", "Copy"] {
let (floor, certificate) = healthy(&s, name);
assert_eq!(certificate, Some(FaceCertificate::Lineage), "{name}");
assert!((floor.origin.z - 7.).abs() < 1e-9, "{name}: {floor:?}");
}
healthy(&s, "Top");
run(&mut s, "parameters.change", json!({"name": "cb", "expression": "4 mm"}));
no_errors(&s);
for name in ["Seed", "Copy"] {
assert!((healthy(&s, name).0.origin.z - 6.).abs() < 1e-9, "{name}");
}
healthy(&reopened(&s), "Copy");
}
/// The middle of the largest triangle of each flat face of the first body.
fn flat_faces(s: &Session) -> Vec<(usize, solidcraft_geom::Vec3)> {
let state = s.model.state();
let body = &state.bodies[0].body;
let mesh = body.tessellate(0.01).unwrap();
(0..body.face_count())
.filter(|index| body.planar_face(*index).is_some())
.filter_map(|index| {
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))?;
Some((index, point))
})
.collect()
}
/// Every flat face of the first body takes an attached sketch (#681: a flat face is a flat face,
/// whichever features it was merged from).
fn every_flat_face_takes_a_sketch(s: &mut Session) {
no_errors(s);
let faces = flat_faces(s);
assert!(faces.len() >= 8, "{}", faces.len());
let mut failed = Vec::new();
for (index, point) in faces {
match s.execute("sketch.create", &json!({"plane": {"face": point, "body": "Body1", "index": index}})) {
Ok(made) => {
run(s, "sketch.finish", json!({}));
let id = made["sketch"].as_u64().unwrap();
if !matches!(s.placed_document().sketch_face_attachment(id, &s.model), FaceAttachmentStatus::Healthy { .. }) {
failed.push(format!("face {index} at {point:?}: not healthy"));
}
}
Err(e) => failed.push(format!("face {index} at {point:?}: {e}")),
}
}
assert!(failed.is_empty(), "{}", failed.join("\n"));
}
/// #681: a block with a lower step joined flush beside it. The step's side walls continue the
/// block's (a Join merged them), and they take a sketch like every other flat face.
#[test]
fn every_flat_face_of_a_block_and_a_flush_step_takes_a_sketch() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "A"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "A", "distance": 10, "name": "Block"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "B"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 10], "p1": [20, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "B", "distance": 5, "operation": "join", "name": "Step"}));
every_flat_face_takes_a_sketch(&mut s);
}
/// #681 as reported: one sketch of a 2 x 2 grid, three cells extruded up (an elbow) and three
/// joined down (the inverse elbow).
#[test]
#[ignore = "the join of the elbow down fails until #679 is fixed"]
fn every_flat_face_of_an_elbow_up_and_an_elbow_down_takes_a_sketch() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Grid"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0], [20, 20], [0, 20]], "closed": true}));
run(&mut s, "sketch.line", json!({"points": [[10, 0], [10, 20]]}));
run(&mut s, "sketch.line", json!({"points": [[0, 10], [20, 10]]}));
run(&mut s, "sketch.finish", json!({}));
let cells = |pts: [[f64; 2]; 3]| pts.iter().map(|p| json!({"point": p})).collect::<Vec<_>>();
run(&mut s, "solid.extrude", json!({"sketch": "Grid", "distance": 10, "profiles": cells([[5., 5.], [15., 5.], [5., 15.]]), "name": "Up"}));
let down = cells([[15., 5.], [5., 15.], [15., 15.]]);
run(
&mut s,
"solid.extrude",
json!({"sketch": "Grid", "distance": 10, "direction": "negative", "operation": "join", "profiles": down, "name": "Down"}),
);
every_flat_face_takes_a_sketch(&mut s);
}
+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}))
}
+36
View File
@@ -9,6 +9,17 @@ macro_rules! created_doc {
};
}
/// A command that is designed and registered (id, toolbar place, icon, params doc) but not built
/// yet. It is disabled with the reason, so the toolbar greys it and says why, and its `run`
/// refuses too, however it is reached. `$issue` is the GitHub issue that tracks building it.
macro_rules! planned {
($id:literal, $label:literal, $issue:literal) => {
CommandSpec::new($id, $label, |_, _| Err(crate::EngineError::Other(concat!("not supported yet: ", $label, " (issue #", $issue, ")").into())))
.enabled(|_| Err(concat!("not supported yet (issue #", $issue, ")").into()))
.planned($issue)
};
}
mod browser;
mod clipboard;
mod component;
@@ -22,10 +33,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;
@@ -36,6 +49,7 @@ pub(crate) use measure_sel::measure_items;
mod kernel_heal_tests;
mod parameters;
mod plastic;
mod render;
mod sheet;
mod sketch;
pub(crate) use sketch::author_path_for_rebind;
@@ -51,6 +65,7 @@ mod sketch_modify;
mod sketch_project;
mod surface;
mod surface_analysis;
mod view_navigation;
use serde::Serialize;
use serde_json::Value;
@@ -85,6 +100,8 @@ pub struct CommandSpec {
/// Reads the evaluated model (the default). A command that reads only the document and the
/// session's history runs on a model left stale by a live gesture without evaluating it first.
pub reads_model: bool,
/// Registered but not built yet (see `planned!`): the GitHub issue number that tracks it.
pub planned: Option<&'static str>,
}
impl CommandSpec {
@@ -102,8 +119,14 @@ impl CommandSpec {
undoable: true,
live: None,
reads_model: true,
planned: None,
}
}
/// Not built yet; `issue` tracks building it.
pub const fn planned(mut self, issue: &'static str) -> Self {
self.planned = Some(issue);
self
}
/// Takes `live: true`, run by `run`.
pub const fn live(mut self, run: LiveRun) -> Self {
self.live = Some(run);
@@ -157,6 +180,7 @@ impl CommandSpec {
params: self.params,
enabled: e.is_ok(),
disabled_reason: e.err(),
planned: self.planned,
}
}
}
@@ -172,6 +196,8 @@ pub struct CommandInfo {
pub params: &'static str,
pub enabled: bool,
pub disabled_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub planned: Option<&'static str>,
}
pub fn always(_: &Session) -> std::result::Result<(), String> {
@@ -182,6 +208,12 @@ pub fn in_sketch(s: &Session) -> std::result::Result<(), String> {
if s.active_sketch.is_some() { Ok(()) } else { Err("no sketch is being edited (Create Sketch or Edit Sketch first)".into()) }
}
/// The mirror of [`in_sketch`], for a command that acts on bodies and has nothing to offer the
/// geometry under the cursor while a sketch is open.
pub fn not_in_sketch(s: &Session) -> std::result::Result<(), String> {
if s.active_sketch.is_none() { Ok(()) } else { Err("a sketch is being edited (this works on bodies: Finish Sketch first)".into()) }
}
/// Every command, in toolbar order.
pub fn command_specs() -> Vec<&'static CommandSpec> {
let mut v: Vec<&'static CommandSpec> = Vec::new();
@@ -203,6 +235,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,8 +247,11 @@ 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(view_navigation::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
v.extend(render::COMMANDS.iter());
v
}
@@ -0,0 +1,56 @@
//! `solid.move` moves bodies, so it has nothing to offer the geometry under the cursor while a
//! sketch is being edited. It is disabled there, with a reason, and comes back once the sketch
//! is finished.
use super::*;
fn cube(s: &mut Session) {
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20})).unwrap();
}
fn spec() -> &'static crate::cmd::CommandSpec {
crate::find_command("solid.move").unwrap()
}
#[test]
fn move_is_disabled_while_a_sketch_is_being_edited_and_says_why() {
let mut s = Session::default();
cube(&mut s);
assert!(spec().info(&s).enabled, "Move is enabled with no sketch open");
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let info = spec().info(&s);
assert!(!info.enabled, "Move must be disabled while a sketch is being edited");
let why = info.disabled_reason.unwrap_or_default();
assert!(why.contains("sketch is being edited"), "the reason must name the sketch: {why}");
assert!(why.contains("bodies"), "the reason must say what Move acts on: {why}");
assert!(why.contains("Finish Sketch"), "the reason must say the way out: {why}");
}
#[test]
fn running_move_in_a_sketch_is_refused_and_changes_nothing() {
let mut s = Session::default();
cube(&mut s);
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let features = s.doc.features.len();
let e = s.execute("solid.move", &json!({"bodies": ["Body1"], "translate": [30, 0, 0]})).unwrap_err();
assert!(matches!(e, EngineError::Disabled(ref id, _) if id == "solid.move"), "{e:?}");
assert_eq!(s.doc.features.len(), features, "a refused command adds no feature");
assert!(s.active_sketch.is_some(), "the sketch is still being edited");
let bounds = s.model.state().body("Body1").unwrap().mesh().bounds();
assert!(bounds.min.dist(Vec3::ZERO) < 1e-6, "the body did not move: {bounds:?}");
}
#[test]
fn move_works_again_once_the_sketch_is_finished() {
let mut s = Session::default();
cube(&mut s);
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
assert!(spec().info(&s).enabled, "Move comes back once the sketch is finished");
s.execute("solid.move", &json!({"bodies": ["Body1"], "translate": [30, 0, 0]})).unwrap();
let bounds = s.model.state().body("Body1").unwrap().mesh().bounds();
assert!(bounds.min.dist(Vec3::new(30.0, 0.0, 0.0)) < 1e-6, "{bounds:?}");
}
+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);
}
+63 -7
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) {
@@ -241,16 +260,53 @@ fn hole_pattern(count1: usize, count2: usize) -> (f64, std::time::Duration) {
(v, took)
}
/// Whether a job four times the size took at most about twice linear: `pair` times the small job,
/// then the big one. Linear is 4×, the old quadratic costs 16× or more. The load of the other test
/// binaries can rise between the two runs (on a 16-core Mac under `cargo xtask test`, 32 circles
/// took 4.7 s and 128 took 42 s, though both together take 18 s alone), so a pair that misses is
/// measured again, up to three times. Load only ever slows a run, so each size keeps its fastest
/// time and the ratio is taken of those: a retry cannot pass a regression on a small run that a
/// spike happened to slow, once an earlier small run was fast. (A spike on the very first small
/// run can still hide one, as it could before the retries.)
fn scales_linearly(mut pair: impl FnMut() -> (std::time::Duration, std::time::Duration)) -> Result<(), String> {
let (mut small, mut big) = (f64::INFINITY, f64::INFINITY);
let mut runs = Vec::new();
for _ in 0..3 {
let (s, b) = pair();
small = small.min(s.as_secs_f64());
big = big.min(b.as_secs_f64());
runs.push(format!("{s:?} then {b:?}"));
if big < 8.0 * small + 2.0 {
return Ok(());
}
}
Err(format!("{}; fastest {small:.2} s and {big:.2} s, limit {:.2} s", runs.join(", "), 8.0 * small + 2.0))
}
#[test]
fn scales_linearly_keeps_each_sizes_fastest_run() {
use std::time::Duration;
let pairs = |p: Vec<(u64, u64)>| {
let mut p = p.into_iter().map(|(s, b)| (Duration::from_secs(s), Duration::from_secs(b)));
move || p.next().unwrap_or((Duration::from_secs(1000), Duration::from_secs(1000)))
};
assert!(scales_linearly(pairs(vec![(1, 4)])).is_ok());
// A big run slowed by load, then a quiet pair.
assert!(scales_linearly(pairs(vec![(1, 30), (1, 5)])).is_ok());
// A quadratic cost (16×) whose later small runs a spike slowed: the fast first one still counts.
let r = scales_linearly(pairs(vec![(1, 16), (3, 16), (3, 16)]));
assert!(r.as_ref().is_err_and(|e| e.contains("fastest 1.00 s and 16.00 s, limit 10.00 s")), "{r:?}");
}
/// 512 identical through-holes from a feature pattern of one cut (#261). 128 took 30 s and 512 did
/// not finish in 150 s: each copy was cut into an ever more complex plate. The time must grow about
/// linearly with the count. A ratio, not a time: CI runs every test binary at once and is many
/// times slower than a machine running this alone (about 6 s for 512 holes there).
#[test]
fn pattern_of_512_hole_cuts_finishes_in_reasonable_time() {
let (_, t128) = hole_pattern(16, 8);
let (_, t512) = hole_pattern(32, 16);
// Linear is 4×, the old quadratic cost 16× or more.
assert!(t512.as_secs_f64() < 8.0 * t128.as_secs_f64() + 2.0, "128 holes took {t128:?}, 512 took {t512:?}");
if let Err(missed) = scales_linearly(|| (hole_pattern(16, 8).1, hole_pattern(32, 16).1)) {
panic!("128 holes, then 512, took {missed}");
}
}
/// A plate with `n` Ø2 circles drawn in one sketch (16 a row, 6 mm apart) and cut by one extrude:
@@ -286,7 +342,7 @@ fn sketch_hole_cut(n: usize) -> std::time::Duration {
/// linear since #261. Linear is 4×. A ratio, not a time (see the 512-hole pattern test).
#[test]
fn one_extrude_of_many_sketch_circles_scales_linearly() {
let t32 = sketch_hole_cut(32);
let t128 = sketch_hole_cut(128);
assert!(t128.as_secs_f64() < 8.0 * t32.as_secs_f64() + 2.0, "32 circles took {t32:?}, 128 took {t128:?}");
if let Err(missed) = scales_linearly(|| (sketch_hole_cut(32), sketch_hole_cut(128))) {
panic!("32 circles, then 128, took {missed}");
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Render capability discovery. No rendering, host probing or document mutation occurs here.
use serde_json::{Value, json};
use super::CommandSpec;
use crate::params::bad;
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("render.backends", "Render Backends", backends)
.noundo()
.doc_only()
.params("No parameters. Returns {schema_version: 1, default_backend: builtin, backends: [{id, label, availability, executors: [{id, availability, profiles, limits, scratch}]}]}. Metadata only; does not start rendering or probe a GPU. See docs/render-backends.md")];
fn backends(_: &mut Session, params: &Value) -> Result<Value> {
if !params.as_object().is_some_and(|p| p.is_empty()) {
return Err(bad("render.backends", "expected an empty parameter object"));
}
Ok(json!({"schema_version": 1, "default_backend": "builtin", "backends": [crate::render::builtin_backend()]}))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
fn assert_unchanged(s: &mut Session, params: Value) -> Result<Value> {
let doc = s.doc.clone();
let before = (s.is_dirty(), s.revision, s.evaluations, s.model.is_stale());
let camera = s.camera;
let undo = s.undo.clone();
let redo = s.redo.clone();
let result = s.execute("render.backends", &params);
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.is_dirty(), s.revision, s.evaluations, s.model.is_stale()));
assert_eq!(camera, s.camera);
for (before, after) in [(&undo, &s.undo), (&redo, &s.redo)] {
assert_eq!(before.len(), after.len());
for (a, b) in before.iter().zip(after.iter()) {
assert!(Arc::ptr_eq(&a.doc, &b.doc));
assert_eq!((&a.label, a.active_sketch, &a.pending_moves), (&b.label, b.active_sketch, &b.pending_moves));
}
}
result
}
#[test]
fn discovery_preserves_clean_dirty_and_undo_redo_state() {
let mut s = Session::default();
let r = assert_unchanged(&mut s, Value::Null).unwrap();
assert_eq!(r["schema_version"], 1);
assert_eq!(r["default_backend"], "builtin");
assert_eq!(r["backends"].as_array().unwrap().len(), 1);
let descriptor: crate::render::BackendDescriptor = serde_json::from_value(r["backends"][0].clone()).unwrap();
assert_eq!(descriptor, crate::render::builtin_backend());
s.execute("solid.box", &json!({"length": 10, "width": 20, "height": 5})).unwrap();
s.execute("view.apply", &json!({"view": "top"})).unwrap();
assert!(s.is_dirty());
assert_unchanged(&mut s, json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.redo.is_empty());
assert_unchanged(&mut s, json!({})).unwrap();
for p in [json!([]), json!(3), json!({"backend": "missing"}), json!({"width": "bad"})] {
assert!(assert_unchanged(&mut s, p).is_err());
}
}
#[test]
fn discovery_does_not_settle_a_live_drag_or_rebuild_geometry() {
let mut s = Session::default();
for (id, p) in [
("sketch.create", json!({"plane": "XY"})),
("sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]})),
("sketch.dimension", json!({"entities": ["l1"], "value": 20})),
("sketch.dimension", json!({"entities": ["l2"], "value": 15})),
("sketch.finish", json!({})),
("solid.extrude", json!({"distance": 5})),
("sketch.edit", json!({"sketch": "Sketch1"})),
("sketch.drag", json!({"entity": "l1.start", "from": [5, 5], "to": [4, 5], "live": true})),
] {
s.execute(id, &p).unwrap();
}
assert!(s.model.is_stale());
s.evaluations = 0;
let model = s.model.state();
assert_unchanged(&mut s, json!({})).unwrap();
assert_unchanged(&mut s, json!({"unexpected": true})).unwrap_err();
assert!(Arc::ptr_eq(&model, &s.model.state()));
assert_eq!(s.evaluations, 0);
}
}
+149 -23
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")
@@ -32,23 +34,33 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sheet.convert", "Convert to Sheet Metal", convert)
.at("SHEET METAL", "CREATE")
.icon("convert_sheet")
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
.params(
"body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?; \
bends?: [[x,y,z] on sharp edges] (a solid or a shelled body: `face` is the fixed face and the flat faces reached from it \
across these edges become the sheet; a shell keeps its thickness, a solid takes the rule's inward; \
edges between two of those faces that are no bends are cut open with the rule's gap)",
),
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|tear (what is cut where two bends meet at a corner of the base: round and rectangular a relief \
`corner_relief` across; tear none, each bend's end is cut straight from the corner to its flange's end; 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 +73,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 +114,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 +166,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 +242,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> {
@@ -200,8 +278,17 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("no body `{body}`")));
}
let face = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on the plate's face"))?;
// With bend edges, a solid or shelled body; without, a plate.
let bends = match p.get("bends") {
None | Some(Value::Null) => Vec::new(),
Some(_) => points(p, "bends", cmd)?,
};
// Such a sheet's values are its rule's (a plate's thickness is the plate's own).
if let Some(key) = RULE_ONLY.iter().find(|k| !bends.is_empty() && p.get(**k).is_some()) {
return Err(bad(cmd, format!("`{key}` is not a sheet.convert parameter: the sheet metal rule sets it. Pass a rule's name as `rule`")));
}
let rule = rule_param(s, p, cmd)?;
add_feature(s, p, FeatureKind::SheetConvert { body, face, rule })
add_feature(s, p, FeatureKind::SheetConvert { body, face, rule, bends })
}
fn rules(s: &mut Session, p: &Value) -> Result<Value> {
@@ -235,6 +322,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, rectangular or tear"))?.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 +365,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 +410,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 +432,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 +511,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 {
@@ -492,3 +614,7 @@ fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
#[path = "sheet_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "sheet_convert_tests.rs"]
mod convert_tests;
@@ -0,0 +1,447 @@
//! Convert to Sheet Metal with bend edges: solid and shelled bodies.
use std::f64::consts::{FRAC_PI_2, PI, SQRT_2};
use serde_json::{Value, json};
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 volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn bbox(s: &mut Session) -> ([f64; 3], [f64; 3]) {
let m = run(s, "inspect.measure", json!({}));
let b = &m["bodies"][0]["bbox"];
let v = |k: &str| [0, 1, 2].map(|i| b[k][i].as_f64().unwrap_or(f64::NAN));
(v("min"), v("max"))
}
fn flat(s: &mut Session) -> Value {
run(s, "sheet.flat_pattern", json!({}))
}
fn flat_size(s: &mut Session) -> (f64, f64) {
let f = flat(s);
let v = |i: usize| f["flat_size_mm"][i].as_f64().unwrap_or(f64::NAN);
(v(0), v(1))
}
/// Either way round (the flat frame's x runs along the fixed face's longest edge).
fn same_size(got: (f64, f64), want: (f64, f64), tol: f64) -> bool {
((got.0 - want.0).abs() < tol && (got.1 - want.1).abs() < tol) || ((got.0 - want.1).abs() < tol && (got.1 - want.0).abs() < tol)
}
/// The bend table's rows as (angle in whole degrees, direction, length).
fn bend_rows(s: &mut Session) -> Vec<(i64, String, f64)> {
let rows = flat(s)["bends"].as_array().cloned().unwrap_or_default();
let row = |b: &Value| {
(
b["angle_deg"].as_f64().unwrap_or(0.0).round() as i64,
b["direction"].as_str().unwrap_or("").to_string(),
b["length"].as_f64().unwrap_or(0.0),
)
};
rows.iter().map(row).collect()
}
fn rule(s: &mut Session, name: &str, t: f64, k: f64, r: f64, gap: f64) {
let mm = |v: f64| format!("{v} mm");
run(
s,
"sheet.manage_rules",
json!({"name": name, "thickness": mm(t), "k_factor": format!("{k}"), "bend_radius": mm(r), "gap": mm(gap), "active": true}),
);
}
/// Bend deduction: twice the outside setback less the allowance.
fn deduction(theta: f64, r: f64, t: f64, k: f64) -> f64 {
2.0 * (r + t) * (theta / 2.0).tan() - theta * (r + k * t)
}
const BOTTOM_EDGES: [[f64; 3]; 4] = [[50.0, 0.0, 0.0], [100.0, 30.0, 0.0], [50.0, 60.0, 0.0], [0.0, 30.0, 0.0]];
/// A 100 x 60 x 30 box shelled to `t` with its top open.
fn tray(s: &mut Session, t: f64) {
run(s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "body_name": "Tray"}));
run(s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": t}));
}
/// The folded tray's volume: base, four 90° bends and four walls cut back at the corners.
fn tray_volume(l: f64, w: f64, h: f64, t: f64, r: f64, gap: f64) -> f64 {
let s = r + t;
let cut = t + gap / SQRT_2;
let base = (l - 2.0 * s) * (w - 2.0 * s);
let bends = FRAC_PI_2 * (r + t / 2.0) * 2.0 * ((l - 2.0 * s) + (w - 2.0 * s));
let walls = (h - s) * 2.0 * ((l - 2.0 * cut) + (w - 2.0 * cut));
t * (base + bends + walls)
}
/// A strip `t` thick along the polyline `outer` (its material to the left of the way the line
/// runs, its ends cut square), extruded `len` along z as the body `Section`. Returns a point on
/// the edge at each corner of the polyline.
fn section(s: &mut Session, outer: &[(f64, f64)], t: f64, len: f64) -> Vec<[f64; 3]> {
let n = outer.len();
let dir = |i: usize| {
let (a, b) = (outer[i], outer[i + 1]);
let l = (b.0 - a.0).hypot(b.1 - a.1);
((b.0 - a.0) / l, (b.1 - a.1) / l)
};
let shift = |p: (f64, f64), d: (f64, f64)| (p.0 - d.1 * t, p.1 + d.0 * t);
let mut inner = vec![shift(outer[0], dir(0))];
for (i, &corner) in outer.iter().enumerate().take(n - 1).skip(1) {
let (d0, d1) = (dir(i - 1), dir(i));
let (p0, p1) = (shift(corner, d0), shift(corner, d1));
let along = ((p1.0 - p0.0) * d1.1 - (p1.1 - p0.1) * d1.0) / (d0.0 * d1.1 - d0.1 * d1.0);
inner.push((p0.0 + d0.0 * along, p0.1 + d0.1 * along));
}
inner.push(shift(outer[n - 1], dir(n - 2)));
let pts: Vec<[f64; 2]> = outer.iter().chain(inner.iter().rev()).map(|p| [p.0, p.1]).collect();
run(s, "sketch.create", json!({"plane": "XY", "name": "Section"}));
run(s, "sketch.line", json!({"points": pts, "closed": true}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"sketch": "Section", "distance": len, "body_name": "Section"}));
outer[1..n - 1].iter().map(|p| [p.0, p.1, len / 2.0]).collect()
}
/// A base 200 wide with walls 40 long at 72° and returns 15 long at 108° more, 300 long.
fn trapezoid_section(s: &mut Session, t: f64) -> Vec<[f64; 3]> {
let (c, sn) = (72f64.to_radians().cos(), 72f64.to_radians().sin());
let (x, y) = (100.0 + 40.0 * c, 40.0 * sn);
section(s, &[(-x + 15.0, y), (-x, y), (-100.0, 0.0), (100.0, 0.0), (x, y), (x - 15.0, y)], t, 300.0)
}
#[test]
fn a_shelled_tray_keeps_its_walls_and_unfolds_by_the_k_factor() {
let (t, k, r, gap) = (2.0, 0.44, 2.0, 0.5);
let mut s = Session::default();
// The shell's own thickness counts, not the rule's.
rule(&mut s, "Test", 3.0, k, r, gap);
tray(&mut s, t);
let out = run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
assert!(out["warning"].is_null(), "{out}");
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(0.0) - t).abs() < 1e-9);
// Four 90° bends up, each as long as the base is between its setbacks (R + T at either end).
let mut rows = bend_rows(&mut s);
rows.sort_by(|a, b| a.2.total_cmp(&b.2));
let want = [52.0, 52.0, 92.0, 92.0];
assert!(rows.len() == 4 && rows.iter().zip(want).all(|(row, l)| row.0 == 90 && row.1 == "up" && (row.2 - l).abs() < 1e-6), "{rows:?}");
let allowance = flat(&mut s)["bends"][0]["allowance"].as_f64().unwrap_or(0.0);
assert!((allowance - FRAC_PI_2 * (r + k * t)).abs() < 1e-9, "{allowance}");
// The envelope stays: the sharp edges became bends inside it.
let (lo, hi) = bbox(&mut s);
assert!(
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 100.0).abs() < 1e-6 && (hi[1] - 60.0).abs() < 1e-6 && (hi[2] - 30.0).abs() < 1e-6,
"{lo:?} {hi:?}"
);
// Flat: the outside lengths less two bend deductions each way.
let bd = deduction(FRAC_PI_2, r, t, k);
let want = (100.0 + 60.0 - 2.0 * bd, 60.0 + 60.0 - 2.0 * bd);
assert!(same_size(flat_size(&mut s), want, 1e-6), "{:?} vs {want:?}", flat_size(&mut s));
let v = volume(&mut s);
let expect = tray_volume(100.0, 60.0, 30.0, t, r, gap);
assert!((v - expect).abs() < 2e-3 * expect, "{v} vs {expect}");
// Unfold: flat, one sheet thick, the same outline; refold gives the tray back.
run(&mut s, "sheet.unfold", json!({}));
let (lo, hi) = bbox(&mut s);
assert!((hi[2] - lo[2] - t).abs() < 1e-6, "{lo:?} {hi:?}");
assert!(same_size((hi[0] - lo[0], hi[1] - lo[1]), want, 1e-6), "{lo:?} {hi:?}");
run(&mut s, "sheet.refold", json!({}));
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
// It exports like a sheet made from flanges: an outline and four bend lines up.
let dxf = run(&mut s, "sheet.export_dxf", json!({}));
assert_eq!(dxf["bends"], 4);
let lines: Vec<&str> = dxf["dxf"].as_str().unwrap_or_default().lines().collect();
assert_eq!(lines.windows(2).filter(|w| w[0].trim() == "8" && w[1].trim() == "BEND_UP").count(), 4, "four bend lines");
assert_eq!(run(&mut s, "sheet.export_bend_table", json!({"format": "json"}))["bends"], 4);
}
#[test]
fn bends_picked_inside_and_a_fixed_face_inside_give_the_same_sheet() {
let (t, k, r, gap) = (2.0, 0.44, 2.0, 0.5);
let inner = [[50.0, 2.0, 2.0], [98.0, 30.0, 2.0], [50.0, 58.0, 2.0], [2.0, 30.0, 2.0]];
let mut s = Session::default();
rule(&mut s, "Test", t, k, r, gap);
tray(&mut s, t);
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
let (v, f) = (volume(&mut s), flat_size(&mut s));
// The inner edges (2 mm in and up) are the same bends.
let mut a = Session::default();
rule(&mut a, "Test", t, k, r, gap);
tray(&mut a, t);
run(&mut a, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": inner}));
assert!((volume(&mut a) - v).abs() < 1e-6 * v && same_size(flat_size(&mut a), f, 1e-9));
// From the floor inside, the sheet's other skin is the fixed one: the bends turn down from it
// and the flat is the inside lengths less the inside setbacks, L' + 2H' - 4R + 2BA, which is
// the same blank.
let mut b = Session::default();
rule(&mut b, "Test", t, k, r, gap);
tray(&mut b, t);
run(&mut b, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 2], "bends": BOTTOM_EDGES}));
assert!(bend_rows(&mut b).iter().all(|row| row.0 == 90 && row.1 == "down"), "{:?}", bend_rows(&mut b));
let ba = FRAC_PI_2 * (r + k * t);
let inside = (96.0 + 2.0 * 28.0 - 4.0 * r + 2.0 * ba, 56.0 + 2.0 * 28.0 - 4.0 * r + 2.0 * ba);
assert!(same_size(flat_size(&mut b), inside, 1e-6) && same_size(inside, f, 1e-6), "{:?} {inside:?} {f:?}", flat_size(&mut b));
assert!((volume(&mut b) - v).abs() < 1e-6 * v, "{} vs {v}", volume(&mut b));
let (lo, hi) = bbox(&mut b);
assert!(lo.iter().all(|x| x.abs() < 1e-6) && (hi[0] - 100.0).abs() < 1e-6 && (hi[2] - 30.0).abs() < 1e-6, "{lo:?} {hi:?}");
}
#[test]
fn a_solid_block_takes_its_rules_thickness_inward() {
let (k, r, gap) = (0.44, 2.0, 0.5);
for (name, t) in [(None, 2.0), (Some("Thick"), 3.0)] {
// The active rule, or the one the conversion names.
let mut s = Session::default();
rule(&mut s, "Thick", 3.0, k, r, gap);
rule(&mut s, "Test", 2.0, k, r, gap);
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "body_name": "Block"}));
let mut p = json!({"body": "Block", "face": [50, 30, 0], "bends": BOTTOM_EDGES});
if let Some(n) = name {
p["rule"] = json!(n);
}
run(&mut s, "sheet.convert", p);
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(0.0) - t).abs() < 1e-9);
// The block's faces are the sheet's outside; its top is left out.
let (lo, hi) = bbox(&mut s);
assert!(
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 100.0).abs() < 1e-6 && (hi[1] - 60.0).abs() < 1e-6 && (hi[2] - 30.0).abs() < 1e-6,
"{lo:?} {hi:?}"
);
let bd = deduction(FRAC_PI_2, r, t, k);
assert!(same_size(flat_size(&mut s), (160.0 - 2.0 * bd, 120.0 - 2.0 * bd), 1e-6), "{:?}", flat_size(&mut s));
let expect = tray_volume(100.0, 60.0, 30.0, t, r, gap);
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
}
}
#[test]
fn a_trapezoid_section_unfolds_through_72_and_108_degree_bends() {
let (t, k, r) = (2.0, 0.5, 1.0);
let mut s = Session::default();
rule(&mut s, "Test", t, k, r, 0.5);
let bends = trapezoid_section(&mut s, t);
run(&mut s, "sheet.convert", json!({"body": "Section", "face": [0, 0, 150], "bends": bends}));
let rows = bend_rows(&mut s);
assert!(rows.len() == 4 && rows.iter().all(|row| row.1 == "up" && (row.2 - 300.0).abs() < 1e-9), "{rows:?}");
assert_eq!((rows.iter().filter(|row| row.0 == 72).count(), rows.iter().filter(|row| row.0 == 108).count()), (2, 2), "{rows:?}");
let (a72, a108) = (72f64.to_radians(), 108f64.to_radians());
let across = 200.0 + 2.0 * 40.0 + 2.0 * 15.0 - 2.0 * deduction(a72, r, t, k) - 2.0 * deduction(a108, r, t, k);
assert!(same_size(flat_size(&mut s), (across, 300.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
// The section keeps its outside faces: the returns' tops stay 40 sin 72° up and the length
// stays; the widest point is on the round of the 108° bend (outer radius R + T), whose
// centre lies (R + T) / sin 36° from the sharp corner along the corner's bisector.
let (lo, hi) = bbox(&mut s);
let (c72, s72) = (a72.cos(), a72.sin());
let rho = r + t;
let bisector_x = (-c72 - 1.0) / (-c72 - 1.0).hypot(-s72);
let wide = 2.0 * (100.0 + 40.0 * c72 + bisector_x * rho / 36f64.to_radians().sin() + rho);
// (The box is measured on the tessellation: the round comes out a chord's sag inside.)
assert!((hi[0] - lo[0] - wide).abs() < 2e-3 && (hi[1] - 40.0 * s72).abs() < 1e-6 && (hi[2] - lo[2] - 300.0).abs() < 1e-6, "{lo:?} {hi:?} {wide}");
run(&mut s, "sheet.unfold", json!({}));
let (lo, hi) = bbox(&mut s);
let flat_box = [(hi[0] - lo[0], hi[1] - lo[1]), (hi[0] - lo[0], hi[2] - lo[2])];
assert!(flat_box.iter().any(|b| same_size(*b, (across, 300.0), 1e-6)), "{lo:?} {hi:?}");
}
#[test]
fn a_z_section_bends_up_at_one_edge_and_down_at_the_other() {
let (t, k, r) = (2.0, 0.5, 1.0);
let mut s = Session::default();
rule(&mut s, "Test", t, k, r, 0.5);
// Flange 60, web 40, flange 30 on one skin: its first corner is the outside of its bend,
// its second the inside.
let bends = section(&mut s, &[(0.0, 0.0), (60.0, 0.0), (60.0, 40.0), (90.0, 40.0)], t, 100.0);
run(&mut s, "sheet.convert", json!({"body": "Section", "face": [30, 0, 50], "bends": bends}));
let mut directions: Vec<String> = bend_rows(&mut s).into_iter().map(|row| row.1).collect();
directions.sort();
assert_eq!(directions, ["down", "up"]);
// Outside setbacks R + T at the first bend, inside setbacks R at the second, and a 90°
// allowance for each: 60 + 40 + 30 - 2 (R + T) - 2 R + π (R + K T) = 128.2832.
let across = 130.0 - 2.0 * (r + t) - 2.0 * r + PI * (r + k * t);
assert!((across - 128.283185).abs() < 1e-6);
assert!(same_size(flat_size(&mut s), (across, 100.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
let (lo, hi) = bbox(&mut s);
assert!(
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 90.0).abs() < 1e-6 && (hi[1] - 42.0).abs() < 1e-6 && (hi[2] - 100.0).abs() < 1e-6,
"{lo:?} {hi:?}"
);
// Three flat strips and two quarter rounds on the neutral radius R + T/2.
let expect = t * 100.0 * ((60.0 - (r + t)) + (40.0 - (r + t) - r) + (30.0 - r) + PI * (r + t / 2.0));
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
}
#[test]
fn a_tapered_tray_converts_with_72_degree_walls_and_slanted_corners() {
let (t, k, r, gap) = (2.0, 0.5, 1.0, 0.5);
let mut s = Session::default();
rule(&mut s, "Test", t, k, r, gap);
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Floor"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [200, 120]}));
run(&mut s, "sketch.finish", json!({}));
// Walls leaning out 18°: 72° bends from the floor.
run(&mut s, "solid.extrude", json!({"sketch": "Floor", "distance": 40, "taper": "18 deg", "body_name": "Tray"}));
let (lo, hi) = bbox(&mut s);
assert!(hi[0] - lo[0] > 200.0 + 1.0, "the taper widens the top: {lo:?} {hi:?}");
run(&mut s, "solid.shell", json!({"faces": [[100, 60, 40]], "thickness": t}));
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [100, 60, 0], "bends": [[100, 0, 0], [200, 60, 0], [100, 120, 0], [0, 60, 0]]}));
let rows = bend_rows(&mut s);
assert!(rows.len() == 4 && rows.iter().all(|row| row.0 == 72 && row.1 == "up"), "{rows:?}");
let slant = 40.0 / 72f64.to_radians().sin();
let bd = deduction(72f64.to_radians(), r, t, k);
assert!(same_size(flat_size(&mut s), (200.0 + 2.0 * slant - 2.0 * bd, 120.0 + 2.0 * slant - 2.0 * bd), 1e-6), "{:?}", flat_size(&mut s));
// The outside stays; the rim is cut square to the sheet, as a blank is, so the inside edge
// of a wall leaning out 18° stands T sin 18° above the solid's level rim.
let (lo2, hi2) = bbox(&mut s);
assert!((0..2).all(|i| (lo2[i] - lo[i]).abs() < 1e-6 && (hi2[i] - hi[i]).abs() < 1e-6) && lo2[2].abs() < 1e-6, "{lo2:?} {hi2:?}");
assert!((hi2[2] - 40.0 - t * 18f64.to_radians().sin()).abs() < 1e-6, "{hi2:?}");
let v = volume(&mut s);
run(&mut s, "sheet.unfold", json!({}));
run(&mut s, "sheet.refold", json!({}));
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
}
#[test]
fn the_sheet_follows_the_solid_through_parameter_changes_undo_and_reopening() {
let (t, k, r) = (2.0, 0.44, 2.0);
let mut s = Session::default();
rule(&mut s, "Test", t, k, r, 0.5);
run(&mut s, "parameters.add", json!({"name": "L", "expression": "100 mm"}));
run(&mut s, "parameters.add", json!({"name": "T", "expression": "2 mm"}));
run(&mut s, "solid.box", json!({"length": "L", "width": 60, "height": 30, "body_name": "Tray"}));
run(&mut s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": "T"}));
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
let bd = |t: f64| deduction(FRAC_PI_2, r, t, k);
assert!(same_size(flat_size(&mut s), (160.0 - 2.0 * bd(t), 120.0 - 2.0 * bd(t)), 1e-6));
// Longer: the right-hand bend edge moved from x = 100 to x = 140 and is found by its name.
run(&mut s, "parameters.change", json!({"name": "L", "expression": "140 mm"}));
assert!(s.model.results.iter().all(|x| x.error.is_none()), "{:?}", s.model.results.iter().map(|x| (&x.name, &x.error)).collect::<Vec<_>>());
assert!(same_size(flat_size(&mut s), (200.0 - 2.0 * bd(t), 120.0 - 2.0 * bd(t)), 1e-6), "{:?}", flat_size(&mut s));
let (_, hi) = bbox(&mut s);
assert!((hi[0] - 140.0).abs() < 1e-6, "{hi:?}");
// Thicker walls: the sheet takes the shell's new thickness.
run(&mut s, "parameters.change", json!({"name": "T", "expression": "3 mm"}));
assert!(s.model.results.iter().all(|x| x.error.is_none()));
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(0.0) - 3.0).abs() < 1e-9);
let thick = (200.0 - 2.0 * bd(3.0), 120.0 - 2.0 * bd(3.0));
assert!(same_size(flat_size(&mut s), thick, 1e-6), "{:?}", flat_size(&mut s));
run(&mut s, "edit.undo", json!({}));
assert!(same_size(flat_size(&mut s), (200.0 - 2.0 * bd(t), 120.0 - 2.0 * bd(t)), 1e-6));
run(&mut s, "edit.redo", json!({}));
assert!(same_size(flat_size(&mut s), thick, 1e-6));
// The design file keeps the bend edges, and reopens to the same sheet.
let saved = s.doc.to_json();
assert!(saved.contains("\"bends\""), "{saved}");
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
assert!(same_size(flat_size(&mut reopened), thick, 1e-6));
// Bend edges came with format 17: a build from before them asks for an update.
let version = serde_json::from_str::<Value>(&saved).unwrap()["format"].as_str().and_then(solidcraft_doc::format::version_of);
assert!(version >= Some(17), "{version:?}");
}
/// Designs from before bend edges open unchanged: a plate conversion saves without `bends`, as
/// it always did, and a format 16 file with one is the same document and the same sheet.
#[test]
fn designs_from_before_bend_edges_open_unchanged() {
let mut s = Session::default();
rule(&mut s, "Test", 2.0, 0.44, 2.0, 0.5);
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2, "body_name": "Plate"}));
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [50, 30, 2]}));
let mut older: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
assert!(!older.to_string().contains("\"bends\""), "{older}");
older["format"] = json!("solidcraft/16");
let doc = solidcraft_doc::Document::from_json(&older.to_string()).unwrap();
assert_eq!(doc, *s.doc);
let mut reopened = Session::new(doc);
assert!(reopened.model.results.iter().all(|x| x.error.is_none()));
assert!(same_size(flat_size(&mut reopened), (100.0, 60.0), 1e-9), "{:?}", flat_size(&mut reopened));
assert!((volume(&mut reopened) - 100.0 * 60.0 * 2.0).abs() < 1e-6);
assert_eq!(flat(&mut reopened), flat(&mut s));
}
#[test]
fn bend_edges_changed_in_an_edit_are_the_ones_bent() {
let (t, k, r) = (2.0, 0.44, 2.0);
let mut s = Session::default();
rule(&mut s, "Test", t, k, r, 0.5);
tray(&mut s, t);
let made = run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
// Only the two long walls: a channel as long as the tray, 60 + 2 x 30 less two deductions
// across. (By the names of the first two edges it would be the front and the right wall.)
run(&mut s, "timeline.edit", json!({"feature": made["feature"], "set": {"bends": [BOTTOM_EDGES[0], BOTTOM_EDGES[2]]}}));
let rows = bend_rows(&mut s);
assert!(rows.len() == 2 && rows.iter().all(|row| row.0 == 90 && row.1 == "up" && (row.2 - 100.0).abs() < 1e-6), "{rows:?}");
let across = 120.0 - 2.0 * deduction(FRAC_PI_2, r, t, k);
assert!(same_size(flat_size(&mut s), (100.0, across), 1e-6), "{:?} vs {across}", flat_size(&mut s));
// Its end walls are no faces of the sheet any more: the channel is open at both ends.
let expect = t * 100.0 * ((60.0 - 2.0 * (r + t)) + 2.0 * (30.0 - (r + t)) + PI * (r + t / 2.0));
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
}
#[test]
fn edge_flanges_and_cuts_work_on_a_converted_sheet() {
let mut s = Session::default();
rule(&mut s, "Test", 2.0, 0.44, 2.0, 0.5);
tray(&mut s, 2.0);
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
let v0 = volume(&mut s);
// A return flange on the front wall's top edge.
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 30]], "height": 10, "angle": 90}));
let v1 = volume(&mut s);
assert!(v1 > v0 + 500.0, "{v0} {v1}");
assert_eq!(bend_rows(&mut s).len(), 5);
// A hole cut in the flat stays in the base when the tray is folded again.
run(&mut s, "sheet.unfold", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
run(&mut s, "sketch.circle.center", json!({"center": [50, 30], "radius": 5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 10, "operation": "cut", "direction": "symmetric"}));
run(&mut s, "sheet.refold", json!({}));
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1));
let hole = PI * 25.0 * 2.0;
assert!((volume(&mut s) - (v1 - hole)).abs() < 2e-3 * v1, "{} vs {}", volume(&mut s), v1 - hole);
}
#[test]
fn conversions_that_cannot_work_say_why() {
let mut s = Session::default();
rule(&mut s, "Wide", 2.0, 0.44, 40.0, 0.5);
rule(&mut s, "Test", 2.0, 0.44, 2.0, 0.5);
tray(&mut s, 2.0);
let err = |s: &mut Session, p: Value| s.execute("sheet.convert", &p).err().map(|e| e.to_string()).unwrap_or_default();
// A bend that doesn't reach the fixed face through other bends.
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": [[0, 0, 15]]}));
assert!(e.contains("does not connect"), "{e}");
// No edge there, and no face there.
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": [[50, 30, 0]]}));
assert!(e.contains("no edge"), "{e}");
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, -20], "bends": BOTTOM_EDGES}));
assert!(e.contains("no face"), "{e}");
// A loop of bends.
let mut bends: Vec<[f64; 3]> = BOTTOM_EDGES.to_vec();
bends.push([0.0, 0.0, 15.0]);
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": bends}));
assert!(e.contains("loop"), "{e}");
// Walls shorter than their bends' setbacks (a 40 mm bend radius on 30 mm walls).
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES, "rule": "Wide"}));
assert!(e.contains("setback"), "{e}");
// The sheet's values are the rule's; an empty list of bends is not a plate.
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES, "thickness": 3}));
assert!(e.contains("the sheet metal rule sets it"), "{e}");
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": []}));
assert!(e.contains("1…1000"), "{e}");
// Curved faces.
let mut c = Session::default();
run(&mut c, "solid.cylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
let e = err(&mut c, json!({"body": "Rod", "face": [0, 0, 30], "bends": [[10, 0, 30]]}));
assert!(e.contains("curved"), "{e}");
// Nothing changed.
assert!(s.model.state().sheets.is_empty() && c.model.state().sheets.is_empty());
}
File diff suppressed because it is too large Load Diff
+250 -34
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)
@@ -106,7 +106,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. Returns measured in unit (mm or deg)",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
.at("SKETCH", "CONSTRAINTS")
@@ -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")
@@ -183,7 +183,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.enabled(in_sketch)
.params("dimension: constraint id or parameter name; at: [x,y] text centre (sketch coordinates) | reset: true (default place)"),
CommandSpec::new("sketch.options", "Sketch Palette Options", sketch_options).icon("sketch").params(
"sketch?: id|name (default active); any of show_profile, show_points, show_dimensions, show_constraints, show_projected, grid, snap, slice, sketch_3d: bool. Returns them all",
"sketch?: id|name (default active); any of show_profile, show_points, show_dimensions, show_constraints, show_projected, show_construction, grid, snap, slice, sketch_3d: bool. Returns them all",
),
CommandSpec::new("sketch.chain", "Chain", chain_cmd)
.enabled(in_sketch)
@@ -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
@@ -401,6 +401,7 @@ pub(super) fn draw(
let first = sk.points.len();
let (curves, mut cons) = f(sk, doc)?;
cons.extend(connect_points(sk, first, p)?);
drop_implied_axes(sk, &mut cons);
Ok((curves, cons))
})
}
@@ -454,6 +455,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 +663,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 {
@@ -803,6 +822,74 @@ fn rect_constraints(sk: &mut Sketch, l: &[usize]) -> Result<Vec<String>> {
Ok(cons)
}
/// Whether line `l` being horizontal (`horizontal`) or vertical already follows from another
/// constraint than `own`: its ends coincide with the ends of a line constrained the same way (a
/// side two rectangles share), or with two points constrained level (or plumb).
fn axis_implied(sk: &Sketch, l: usize, horizontal: bool, own: &str) -> bool {
let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|c| &c.kind) else { return false };
let (a, b) = (*a, *b);
// Coincident points as one (union-find over the coincident constraints).
let mut parent: Vec<usize> = (0..sk.points.len()).collect();
let root = |parent: &[usize], mut i: usize| {
for _ in 0..parent.len() {
match parent.get(i) {
Some(&up) if up != i => i = up,
_ => break,
}
}
i
};
for c in &sk.constraints {
if let ConstraintKind::Coincident { p, q } = c.kind {
let (rp, rq) = (root(&parent, p), root(&parent, q));
if rp != rq
&& let Some(slot) = parent.get_mut(rp)
{
*slot = rq;
}
}
}
let (ra, rb) = (root(&parent, a), root(&parent, b));
sk.constraints.iter().filter(|c| c.id != own).any(|c| {
let ends = match (&c.kind, horizontal) {
(ConstraintKind::Horizontal { l: m }, true) | (ConstraintKind::Vertical { l: m }, false) if *m != l => {
match sk.curves.get(*m).map(|c| &c.kind) {
Some(CurveKind::Line { a, b }) => Some((*a, *b)),
_ => None,
}
}
(ConstraintKind::HorizontalPoints { p, q }, true) | (ConstraintKind::VerticalPoints { p, q }, false) => Some((*p, *q)),
_ => None,
};
ends.is_some_and(|(p, q)| {
let (rp, rq) = (root(&parent, p), root(&parent, q));
rp != rq && ((rp, rq) == (ra, rb) || (rp, rq) == (rb, ra))
})
})
}
/// Drawing adds horizontal/vertical only where it is not already implied: a rectangle drawn
/// onto another's side shares that side's direction through the two coincident corners, so a
/// second "vertical" there would only be redundant. Drops such constraints among `cons` (the
/// drawing's own) from the sketch and the list.
fn drop_implied_axes(sk: &mut Sketch, cons: &mut Vec<String>) {
let mut dropped = Vec::new();
for id in cons.iter() {
let axis = sk.constraints.iter().find(|c| &c.id == id).and_then(|c| match c.kind {
ConstraintKind::Horizontal { l } => Some((l, true)),
ConstraintKind::Vertical { l } => Some((l, false)),
_ => None,
});
if let Some((l, horizontal)) = axis
&& axis_implied(sk, l, horizontal, id)
{
sk.constraints.retain(|c| &c.id != id);
dropped.push(id.clone());
}
}
cons.retain(|id| !dropped.contains(id));
}
/// Which side and corner of an axis-aligned rectangle is which, from its lines and corner
/// points as `polygon_lines` made them (counter-clockwise from the bottom-left corner), so a
/// caller can dimension it without inspecting the sketch first.
@@ -837,6 +924,7 @@ fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
let mut cons = rect_constraints(sk, &l)?;
mark_construction(sk, &l, p);
cons.extend(connect_points(sk, first, p)?);
drop_implied_axes(sk, &mut cons);
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
})?;
Ok(rect_result(out, names, info))
@@ -869,6 +957,7 @@ fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
}
mark_construction(sk, &l, p);
cons.extend(connect_points(sk, first, p)?);
drop_implied_axes(sk, &mut cons);
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
})?;
Ok(rect_result(out, names, info))
@@ -1154,21 +1243,26 @@ 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 mut residual = None;
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let k = f(sk)?;
let rep = solve(&mut sk.clone());
before = Some(rep.dof);
let order = entity_order(sk, p);
let id = add_c(sk, k)?;
if let Some((first, second)) = order {
residual = residual_of(sk, &id);
// A constraint that already holds needs nothing moved (even when both entities are
// fully determined: it is then redundant, see `redundancy`).
let holds = residual.is_some_and(|r| r <= solidcraft_sketch::CONSISTENT);
if let Some((first, second)) = order.filter(|_| !holds) {
apply_in_order(sk, &rep, &[first], second, cmd)?;
}
Ok(id)
})?;
reject_redundant(before, &info, cmd)?;
Ok(json!({"constraint": id, "sketch": info}))
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
constraint_result(p, cmd, id, info, by, Value::Null)
}
/// A sketch entity a constraint is applied to.
@@ -1179,7 +1273,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
@@ -1312,18 +1406,79 @@ pub(super) fn dof_now(sk: &Sketch) -> usize {
solve(&mut sk.clone()).dof
}
/// A constraint that removes no degree of freedom over-constrains the sketch (Fusion refuses
/// it); the caller's edit is rolled back by returning the error.
pub(super) fn reject_redundant(before: Option<usize>, info: &Value, cmd: &str) -> Result<()> {
/// A constraint just added that removes no degree of freedom (`before` → the edit's `info`) is
/// redundant or a conflict. Redundant when it held where the sketch was before it was added
/// (`residual`, see [`solidcraft_sketch::constraint_residual`]): what it asks for is already
/// implied by other constraints, and the solver satisfies it with them. Returns the
/// constraints that determine it (as "id (Name)"), or `None` when it removes a degree of
/// freedom. A conflict is refused; the caller's edit is rolled back by returning the error.
pub(super) fn redundancy(
s: &Session,
p: &Value,
cmd: &str,
before: Option<usize>,
info: &Value,
residual: Option<f64>,
cid: &str,
) -> Result<Option<Vec<String>>> {
let after = info.get("dof").and_then(Value::as_u64).map(|d| d as usize);
if let (Some(b), Some(a)) = (before, after)
&& a >= b
{
return Err(bad(cmd, "that would over-constrain the sketch (it is already determined by other constraints)"));
let (Some(b), Some(a)) = (before, after) else { return Ok(None) };
if a < b {
return Ok(None);
}
if !residual.is_some_and(|r| r <= solidcraft_sketch::CONSISTENT) {
return Err(bad(cmd, "that would over-constrain the sketch (it conflicts with what the other constraints already determine)"));
}
Ok(Some(determined_by(s, p, cmd, cid)))
}
/// The constraints that already determine constraint `cid` of the target sketch, as
/// "id (Name)".
fn determined_by(s: &Session, p: &Value, cmd: &str, cid: &str) -> Vec<String> {
let Some(sk) = target_sketch(s, p, cmd).ok().and_then(|id| s.doc.sketch(id).ok()) else { return Vec::new() };
let Some(ci) = sk.constraints.iter().position(|c| c.id == cid) else { return Vec::new() };
solidcraft_sketch::determined_by(sk, ci)
.into_iter()
.filter_map(|k| sk.constraints.get(k))
.map(|c| format!("{} ({})", c.id, c.kind.name()))
.collect()
}
/// "k3 (Vertical), k9 (Vertical)", or "other constraints" when they are not known.
pub(super) fn listed(by: &[String]) -> String {
if by.is_empty() { "other constraints".to_string() } else { by.join(", ") }
}
/// A redundant constraint is kept (and shown as redundant) unless the caller asked for
/// `if_redundant: "refuse"` (drawing-time inferences, which only add what is not already there).
pub(super) fn keep_redundant(p: &Value, cmd: &str, by: &[String]) -> Result<()> {
if str_(p, "if_redundant").is_some_and(|m| m.eq_ignore_ascii_case("refuse")) {
return Err(bad(cmd, format!("that would over-constrain the sketch (it is already determined by {})", listed(by))));
}
Ok(())
}
/// The result of a constraint command: the constraint, and whether it is redundant (and what
/// determines it).
pub(super) fn constraint_result(p: &Value, cmd: &str, id: String, info: Value, by: Option<Vec<String>>, extra: Value) -> Result<Value> {
let mut out = json!({"constraint": id, "sketch": info});
if let Some(by) = by {
keep_redundant(p, cmd, &by)?;
out["redundant"] = json!(true);
out["determined_by"] = json!(by);
}
if let (Some(o), Value::Object(e)) = (out.as_object_mut(), extra) {
o.extend(e);
}
Ok(out)
}
/// The residual of the constraint `id` where the sketch is now (see
/// [`solidcraft_sketch::constraint_residual`]).
pub(super) fn residual_of(sk: &Sketch, id: &str) -> Option<f64> {
sk.constraints.iter().position(|c| c.id == id).and_then(|ci| solidcraft_sketch::constraint_residual(sk, ci))
}
fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.horizontal_vertical";
let mode = str_(p, "mode").map(str::to_ascii_lowercase);
@@ -1372,15 +1527,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 +1578,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 +1608,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 +1620,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());
@@ -1582,6 +1747,8 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
let text_at = p.get("text_at").and_then(vec2);
let driven = bool_(p, "driven").unwrap_or(false);
let mut before = None;
let (mut residual, mut new_id) = (None, String::new());
let start = s.doc.clone();
let ((param, kind_name, current), info) = edit(s, p, cmd, true, |sk, doc| {
before = Some(dof_now(sk));
let ent = |r: &str| -> Result<(Option<usize>, Option<usize>)> {
@@ -1738,8 +1905,13 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
{
(k, cur) = (ka, ca);
}
// How far the value is from what the geometry measures now: zero for a dimension that
// only restates what the sketch already has.
let want = vals.get(&pname).and_then(|v| v.to_kind(if is_angle { Kind::Angle } else { Kind::Length }).ok());
residual = want.map(|w| (w - cur).abs()).filter(|r| r.is_finite());
let name = k.name();
let id = sk.add_constraint(k, Some(pname.clone()))?;
new_id = id.clone();
place_text(sk, &id, text_at);
Ok((pname, name, measured(is_angle, cur)))
})?;
@@ -1748,8 +1920,28 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
if driven {
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}));
}
if reject_redundant(before, &info, cmd).is_err() {
return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true"));
// A dimension the sketch already determines cannot drive it: it is refused, or with
// `if_redundant: "driven"` placed as a driven (reference) dimension instead.
let by = redundancy(s, p, cmd, before, &info, residual, &new_id)
.map_err(|_| bad(cmd, "that dimension conflicts with the sketch: other constraints already determine it with a different value"))?;
if let Some(by) = by {
if str_(p, "if_redundant").is_some_and(|m| m.eq_ignore_ascii_case("driven")) {
s.doc = start;
let mut q = p.clone();
q["driven"] = json!(true);
if let Some(o) = q.as_object_mut() {
o.remove("if_redundant");
o.remove("value");
}
let mut out = dimension(s, &q)?;
out["redundant"] = json!(true);
out["determined_by"] = json!(by);
return Ok(out);
}
return Err(bad(
cmd,
format!("that dimension is already determined by {}; add it as a driven (reference) dimension with driven: true", listed(&by)),
));
}
let v = s.doc.param(&param).map(|p| p.expr.clone()).unwrap_or_default();
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info}))
@@ -1999,6 +2191,11 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
let (d, rep) = edit_solved(s, id, false, |sk, doc| {
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?;
if let Some(link) = super::sketch_create::text_link(sk, r).map(str::to_owned) {
let moving = sk.link_points(&link).into_iter().filter_map(|i| sk.point(i).map(|p| (i, p))).collect();
super::sketch_create::move_text(sk, &link, to - from, 0.0, Vec2::ZERO, false)?;
return Ok((drag::Dragged { reached: 1.0, moving }, Left::Unsolved));
}
// The held solves work on the dimensions' current values (a solved step applies them).
doc.apply_dimension_values(&doc.param_values().0, sk)?;
let (d, left) = drag_entity(sk, e, from, to, live);
@@ -2055,6 +2252,7 @@ fn options_json(v: &solidcraft_sketch::SketchView) -> Value {
"show_dimensions": !v.hide_dimensions,
"show_constraints": !v.hide_constraints,
"show_projected": !v.hide_projected,
"show_construction": !v.hide_construction,
"grid": !v.hide_grid,
"snap": !v.no_snap,
"slice": v.slice,
@@ -2064,7 +2262,18 @@ fn options_json(v: &solidcraft_sketch::SketchView) -> Value {
fn sketch_options(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.options";
let keys = ["show_profile", "show_points", "show_dimensions", "show_constraints", "show_projected", "grid", "snap", "slice", "sketch_3d"];
let keys = [
"show_profile",
"show_points",
"show_dimensions",
"show_constraints",
"show_projected",
"show_construction",
"grid",
"snap",
"slice",
"sketch_3d",
];
if let Some(o) = p.as_object()
&& let Some(k) = o.keys().find(|k| *k != "sketch" && !keys.contains(&k.as_str()))
{
@@ -2087,6 +2296,7 @@ fn sketch_options(s: &mut Session, p: &Value) -> Result<Value> {
put("show_dimensions", &mut v.hide_dimensions, true);
put("show_constraints", &mut v.hide_constraints, true);
put("show_projected", &mut v.hide_projected, true);
put("show_construction", &mut v.hide_construction, true);
put("grid", &mut v.hide_grid, true);
put("snap", &mut v.no_snap, true);
put("slice", &mut v.slice, false);
@@ -2119,9 +2329,15 @@ 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;
#[cfg(test)]
#[path = "sketch_ids_tests.rs"]
mod ids_tests;
#[cfg(test)]
#[path = "sketch_redundant_tests.rs"]
mod redundant_tests;
@@ -150,7 +150,10 @@ fn wire_frame_include_other_component_refresh_break_lost_and_reopen() {
assert_eq!(s.doc.to_json(), before);
// Lost references retain the last coordinates and explicit marker, with no guessing.
let sk = s.doc_mut().sketch_mut(sketch).unwrap();
sk.links.iter_mut().find(|l| l.id == link).unwrap().source = solidcraft_sketch::LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO };
let entry = sk.links.iter_mut().find(|l| l.id == link).unwrap();
entry.source = solidcraft_sketch::LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO };
// The link is the edge it names; without the name it is the edge nearest its point.
entry.edge_name = None;
run(&mut s, "sketch.line", json!({"points":[[2,0],[3,0]]}));
let sk = s.doc.sketch(sketch).unwrap();
assert!(sk.link(&link).unwrap().lost);
@@ -0,0 +1,239 @@
//! 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();
// (A redundant one is kept unless refused: refused here.)
assert!(s.execute("sketch.constraint.parallel", &json!({"a": "b", "b": "x", "if_redundant": "refuse"})).is_err(), "already horizontal");
assert_eq!(s.doc.to_json(), before_redundant);
assert!(axis_lines(&sketch(&s)).is_empty());
}
+19 -10
View File
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
use super::sketch::{add_c, dof_now, edit, reject_redundant};
use super::sketch::{add_c, constraint_result, dof_now, edit, listed, redundancy, residual_of};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, num, req_vec2, str_, string_list, vec2};
use crate::{EngineError, Result, Session};
@@ -56,15 +56,17 @@ pub static COMMANDS: &[CommandSpec] = &[
fn c_smooth(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.curvature";
let mut before = None;
let (mut before, mut residual) = (None, None);
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let a = curve(sk, p.get("a"), cmd)?;
let b = curve(sk, p.get("b"), cmd)?;
before = Some(dof_now(sk));
add_c(sk, ConstraintKind::Smooth { a, b })
let id = add_c(sk, ConstraintKind::Smooth { a, b })?;
residual = residual_of(sk, &id);
Ok(id)
})?;
reject_redundant(before, &info, cmd)?;
Ok(json!({"constraint": id, "sketch": info}))
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
constraint_result(p, cmd, id, info, by, Value::Null)
}
fn curve(sk: &Sketch, v: Option<&Value>, cmd: &str) -> Result<usize> {
@@ -349,16 +351,18 @@ fn guess(sk: &Sketch, ents: &[String], cmd: &str) -> Result<ConstraintKind> {
fn smart_constrain(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constrainer";
let ents = string_list(p, "entities");
let mut before = None;
let (mut before, mut residual) = (None, None);
let mut name = "";
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let k = guess(sk, &ents, cmd)?;
name = k.name();
before = Some(dof_now(sk));
add_c(sk, k)
let id = add_c(sk, k)?;
residual = residual_of(sk, &id);
Ok(id)
})?;
reject_redundant(before, &info, cmd)?;
Ok(json!({"constraint": id, "type": name, "sketch": info}))
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
constraint_result(p, cmd, id, info, by, json!({"type": name}))
}
fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
@@ -397,7 +401,10 @@ fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
c.driven = true;
}
before = Some(dof_now(&sk));
reject_redundant(before, &info, cmd)?;
// It holds (its parameter takes the value it measures), so it can only be redundant.
if let Some(by) = redundancy(s, p, cmd, before, &info, Some(0.0), &cid)? {
return Err(bad(cmd, format!("that dimension is already determined by {}; it stays driven", listed(&by))));
}
}
s.doc_mut().prune_model_params();
Ok(json!({"constraint": cid, "driven": driven, "sketch": info}))
@@ -468,6 +475,8 @@ fn glyphs(s: &mut Session, p: &Value) -> Result<Value> {
"param": c.param,
"at": anchor(&ss.sketch, &c.kind),
"failing": failing.contains(&c.id),
// Implied by the others (it removes no freedom) but holding: shown dimmed.
"redundant": ss.report.redundant.contains(&c.id),
})
})
.collect();
@@ -25,17 +25,17 @@ fn sketch(s: &Session) -> solidcraft_sketch::Sketch {
}
#[test]
fn redundant_constraints_and_dimensions_are_refused_driven_ones_measure() {
fn redundant_dimensions_are_refused_driven_ones_measure() {
let mut s = new_sketch();
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"])[0].clone();
// Already horizontal (inferred).
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": l})).unwrap_err().to_string();
assert!(e.contains("over-constrain"), "{e}");
// Already horizontal (inferred): kept as redundant, unless asked to refuse it.
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": l, "if_redundant": "refuse"})).unwrap_err().to_string();
assert!(e.contains("over-constrain") && e.contains("already determined by"), "{e}");
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{l}.start"), "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": 10}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 0, "{si}");
// A second length on it over-constrains: refused, unless driven.
// A second length on it is already determined: refused, unless driven.
assert!(s.execute("sketch.dimension", &json!({"entities": [format!("{l}.start"), format!("{l}.end")]})).is_err());
let d = run(&mut s, "sketch.dimension", json!({"entities": [format!("{l}.start"), format!("{l}.end")], "driven": true}));
assert_eq!(d["driven"], true);
+58 -8
View File
@@ -10,6 +10,10 @@ use super::{CommandSpec, in_sketch};
use crate::params::{bad, num, req_vec2, str_, string_list};
use crate::{Result, Session};
#[cfg(test)]
#[path = "sketch_text_move_tests.rs"]
mod text_move_tests;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
.at("SKETCH", "CREATE")
@@ -75,19 +79,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 +110,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> {
@@ -120,6 +137,39 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"link": l, "curves": curves}))
}
/// The text source owning an outline or one of its linked points.
pub(super) fn text_link<'a>(sk: &'a Sketch, entity: &str) -> Option<&'a str> {
let link = sk
.curve_index(entity)
.and_then(|i| sk.curves.get(i))
.and_then(|c| c.link.as_deref())
.or_else(|| sk.resolve_point(entity).and_then(|i| sk.points.get(i)).and_then(|p| p.link.as_deref()))?;
sk.link(link).filter(|source| source.kind == LinkKind::Text).map(|source| source.id.as_str())
}
/// Transform a text source as a unit; generated outlines remain linked and editable.
pub(super) fn move_text(sk: &mut Sketch, link: &str, translate: Vec2, rotation: f64, center: Vec2, copy: bool) -> Result<Vec<usize>> {
let Some(LinkSource::Text { text, at, height, angle, font }) = sk.link(link).map(|l| l.source.clone()) else {
return Err(bad("sketch.move", "the selected source is not text"));
};
let q = at - center;
let (sin, cos) = rotation.sin_cos();
let at = center + Vec2::new(q.x * cos - q.y * sin, q.x * sin + q.y * cos) + translate;
let angle = angle + rotation;
let geom = solidcraft_sketch::text_geometry_in(font.as_deref(), &text, at, height, angle)?;
let source = LinkSource::Text { text, at, height, angle, font };
let link = if copy {
sk.add_link(LinkKind::Text, source, &geom)?
} else {
sk.set_link_source(link, source);
if sk.update_link(link, &geom)? {
return Err(bad("sketch.move", "moving text must preserve its outline topology; edit the text source first"));
}
link.to_owned()
};
Ok(sk.link_curves(&link))
}
fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.edit_text";
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
@@ -131,12 +181,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));
+3 -1
View File
@@ -95,7 +95,9 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => return Err(bad(cmd, "unsupported dimension role")),
};
let mut dimension = json!({"entities": entities, "type": ty, "value": value});
// A typed size the sketch already has (a rectangle drawn onto another's side, the same
// height) is placed as a driven (reference) dimension rather than refused.
let mut dimension = json!({"entities": entities, "type": ty, "value": value, "if_redundant": "driven"});
if let Some(sketch) = params.get("sketch") {
dimension["sketch"] = sketch.clone();
}
+28 -7
View File
@@ -15,7 +15,7 @@
use std::f64::consts::{PI, TAU};
use solidcraft_geom::Vec2;
use solidcraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch};
use solidcraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch, conic_rho_from_point};
use super::{Ent, Left, counted_solve};
@@ -47,13 +47,18 @@ enum Path {
Radius { c: usize, from: f64, to: f64 },
/// An arc's ends move along their rays from the centre `o` to the cursor's radius.
ArcRadius { o: Vec2, ends: Vec<(usize, Vec2)>, from: f64, to: f64 },
/// A conic's `rho` follows the cursor, read off its position between the chord's midpoint
/// and the apex (see [`solidcraft_sketch::conic_rho_from_point`]). `a`, `b` and `apex` stay
/// put; only the curve's fullness changes, the way an ellipse's outline changes its minor
/// radius rather than sliding the ellipse.
ConicRho { c: usize, from: f64, to: f64 },
}
impl Path {
fn length(&self) -> f64 {
match self {
Path::Slide { d, .. } => d.len(),
Path::Radius { from, to, .. } | Path::ArcRadius { from, to, .. } => (to - from).abs(),
Path::Radius { from, to, .. } | Path::ArcRadius { from, to, .. } | Path::ConicRho { from, to, .. } => (to - from).abs(),
}
}
@@ -61,7 +66,7 @@ impl Path {
fn points(&self) -> impl Iterator<Item = usize> + '_ {
let pts = match self {
Path::Slide { pts, .. } | Path::ArcRadius { ends: pts, .. } => pts.as_slice(),
Path::Radius { .. } => &[],
Path::Radius { .. } | Path::ConicRho { .. } => &[],
};
pts.iter().map(|(q, _)| *q)
}
@@ -90,13 +95,19 @@ impl Path {
*r = from + (to - from) * t;
}
}
Path::ConicRho { c, from, to } => {
if let Some(CurveKind::Conic { rho, .. }) = sk.curves.get_mut(*c).map(|x| &mut x.kind) {
*rho = from + (to - from) * t;
}
}
}
}
}
/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line,
/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes
/// radius about its centre) and the rest of the sketch moves as little as it can, staying on the
/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line or spline
/// slides by the cursor's move; a circle or arc grabbed on its edge changes radius about its
/// centre; a conic grabbed on its curve reads its fullness, `rho`, off the cursor) and the rest
/// of the sketch moves as little as it can, staying on the
/// solution it started on. Fixed geometry stays. A step that cannot be solved with the entity
/// held (the path crosses where the geometry cannot reach) is approached by halving the way to
/// it, and the next steps are tried from the last solved state, the last of them being the whole
@@ -430,8 +441,18 @@ fn plan(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold)> {
hold.radii.push(c);
Path::Radius { c, from: r, to: if minor > 1e-9 { minor } else { r } }
}
CurveKind::Conic { a, b, apex, rho } => {
// Grabbed on the curve itself: its fullness (`rho`) follows the drag, the
// way an ellipse's outline changes its minor radius rather than sliding the
// ellipse. `a`, `b` and the apex (the tangent intersection, dragged
// separately as an ordinary point) stay where they are.
let (pa, px, pb) = (sk.point(a)?, sk.point(apex)?, sk.point(b)?);
hold.points.extend([a, b, apex]);
let to_rho = conic_rho_from_point(pa, px, pb, to);
Path::ConicRho { c, from: rho, to: to_rho }
}
k => {
// Lines, splines and conics slide with the cursor.
// Lines and splines slide with the cursor.
let mut ids = k.point_ids();
ids.sort_unstable();
ids.dedup();
+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 (or a panel's rim): 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
&& 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);
}
}
+23 -5
View File
@@ -68,8 +68,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.enabled(in_sketch)
.params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"),
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy)
.at("SKETCH", "MODIFY")
.icon("move")
.enabled(in_sketch)
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool", created_doc!())),
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool; picking a text outline or linked point transforms its whole text source once, and copies remain editable text", created_doc!())),
CommandSpec::new("sketch.mirror", "Mirror", mirror)
.at("SKETCH", "CREATE")
.icon("sketch_mirror")
@@ -1458,19 +1460,35 @@ fn transform_in_place(sk: &mut Sketch, cs: &[usize], ps: &[usize], f: &dyn Fn(Ve
fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.move";
let t = p.get("translate").and_then(vec2).unwrap_or(Vec2::ZERO);
let ang = num(p, "angle").unwrap_or(0.0).to_radians();
let t = if p.get("translate").is_some() { req_vec2(cmd, p, "translate")? } else { Vec2::ZERO };
let ang = if p.get("angle").is_some() { num(p, "angle").ok_or_else(|| bad(cmd, "`angle` must be finite"))? } else { 0.0 }.to_radians();
let copy = bool_(p, "copy").unwrap_or(false);
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (cs, ps) = entities(sk, p, cmd)?;
let (mut cs, mut ps) = entities(sk, p, cmd)?;
let c = if p.get("center").is_some() { point_or_ref(sk, p, "center", cmd)? } else { Vec2::ZERO };
let rot = rotate(c, ang);
let f = move |q: Vec2| rot(q) + t;
let mut links = Vec::new();
for entity in string_list(p, "entities") {
if let Some(link) = super::sketch_create::text_link(sk, &entity)
&& !links.iter().any(|old| old == link)
{
links.push(link.to_owned());
}
}
cs.retain(|&i| !sk.curves.get(i).and_then(|c| c.link.as_ref()).is_some_and(|link| links.contains(link)));
ps.retain(|&i| !sk.points.get(i).and_then(|p| p.link.as_ref()).is_some_and(|link| links.contains(link)));
let mut text_curves = Vec::new();
for link in links {
text_curves.extend(super::sketch_create::move_text(sk, &link, t, ang, c, copy)?);
}
if copy {
let (m, _) = copy_through(sk, &cs, &ps, &f, false, &|_| false, 1.0)?;
let (mut m, _) = copy_through(sk, &cs, &ps, &f, false, &|_| false, 1.0)?;
m.extend(text_curves);
return Ok(ids_of(sk, &m));
}
transform_in_place(sk, &cs, &ps, &f, 1.0, cmd)?;
cs.extend(text_curves);
Ok(ids_of(sk, &cs))
})?;
Ok(json!({"curves": out, "sketch": info}))
@@ -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();
+66 -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| {
@@ -202,9 +226,12 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki
} else {
None
};
// The link is the edge that was picked, not whichever is nearest the picked point later.
let edge_name = solidcraft_doc::project::resolved_edge_name(&st, &r);
let link = sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?;
if let Some(l) = sk.links.iter_mut().find(|l| l.id == link) {
l.face_name = name;
l.edge_name = edge_name;
}
Ok(link)
}
@@ -454,7 +481,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 +518,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 +566,15 @@ 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_project_edge_tests.rs"]
mod edge_tests;
#[cfg(test)]
#[path = "sketch_model_plane_tests.rs"]
mod model_plane_tests;
@@ -0,0 +1,228 @@
//! A projected edge stays the edge that was picked: its link keeps the edge's persistent name,
//! so the edge is found by that name wherever a change of the body takes it, and no other edge
//! is taken for it when it is split or gone.
use serde_json::{Value, json};
use solidcraft_geom::{Vec2, Vec3};
use solidcraft_sketch::{Link, LinkSource, Shape};
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}"),
}
}
/// A plate `width` (100) x 50 x 20 with a corner at the origin: body `Body1`.
fn plate(s: &mut Session) {
run(s, "parameters.add", json!({"name": "width", "expression": "100 mm"}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 50]}));
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "width"}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"sketch": "Base", "distance": "20 mm", "name": "Plate"}));
}
/// Sketch `Top` on the plate's top (z = 20) with the top edge of the plate's right-hand side,
/// from (100, 0, 20) to (100, 50, 20), projected into it: picked at `y` along it.
fn project_right_edge(s: &mut Session, y: f64) {
run(s, "sketch.create", json!({"plane": "XY", "offset": "20 mm", "name": "Top"}));
run(s, "sketch.project", json!({"refs": [{"edge": [100, y, 20], "body": "Body1"}]}));
run(s, "sketch.finish", json!({}));
}
fn set_width(s: &mut Session, width: f64) {
run(s, "parameters.change", json!({"name": "width", "expression": format!("{width} mm")}));
}
/// What sketch `Top` has to report.
fn trouble(s: &Session) -> String {
let r = s.model.result(s.doc.find_feature("Top").unwrap().id).unwrap();
format!("{}{}", r.error.clone().unwrap_or_default(), r.warning.clone().unwrap_or_default())
}
/// The link of sketch `Top` as the model has it.
fn link(s: &Session) -> Link {
s.model.state().sketch(s.doc.find_feature("Top").unwrap().id).unwrap().sketch.links[0].clone()
}
/// The link of sketch `Top` as the design stores it.
fn stored(s: &Session) -> Link {
s.doc.sketch(s.doc.find_feature("Top").unwrap().id).unwrap().links[0].clone()
}
/// The ends of the line the link of sketch `Top` gives.
fn projected(s: &Session) -> (Vec2, Vec2) {
let st = s.model.state();
let sk = &st.sketch(s.doc.find_feature("Top").unwrap().id).unwrap().sketch;
match sk.shape(sk.link_curves(&sk.links[0].id)[0]) {
Some(Shape::Line { a, b }) => (a, b),
other => panic!("not a line: {other:?}"),
}
}
/// The projected line runs along y at `x`, between `y0` and `y1`.
#[track_caller]
fn assert_line(s: &Session, x: f64, (y0, y1): (f64, f64), when: &str) {
let (a, b) = projected(s);
let ends = (a.y.min(b.y), a.y.max(b.y));
assert!(
(a.x - x).abs() < 1e-6 && (b.x - x).abs() < 1e-6 && (ends.0 - y0).abs() < 1e-6 && (ends.1 - y1).abs() < 1e-6,
"{when}: expected the line x = {x}, y = {y0}..{y1}: {a:?} {b:?}"
);
}
/// The name of the edge of `Body1` whose middle is `mid`.
fn edge_named_at(s: &mut Session, mid: [f64; 3]) -> String {
let edges = run(s, "model.edges", json!({"body": "Body1"}));
let at = |e: &Value| Vec3::new(e["mid"][0].as_f64().unwrap(), e["mid"][1].as_f64().unwrap(), e["mid"][2].as_f64().unwrap());
let edge = edges["edges"].as_array().unwrap().iter().find(|e| at(e).dist(Vec3::new(mid[0], mid[1], mid[2])) < 1e-6).unwrap();
edge["name"].as_str().unwrap().to_string()
}
/// The picked point (100, 25, 20) is 25 from the front and back edges of the top: wider than
/// 125 they are nearer to it than the picked edge is. At 60 the picked edge is 40 from it,
/// further than a reference found by its point is looked for (a quarter of the body's diagonal,
/// 20.2). The link follows the edge by its name through all of them.
#[test]
fn a_projected_edge_stays_that_edge_when_the_body_changes_size() {
let mut s = Session::default();
plate(&mut s);
project_right_edge(&mut s, 25.0);
assert_line(&s, 100.0, (0.0, 50.0), "drawn");
let name = edge_named_at(&mut s, [100.0, 25.0, 20.0]);
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
for width in [150.0, 60.0, 400.0] {
set_width(&mut s, width);
assert_eq!(trouble(&s), "", "width {width}");
assert_line(&s, width, (0.0, 50.0), &format!("width {width}"));
assert!(!link(&s).lost, "width {width}");
}
// Saved and opened again.
let opened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(stored(&opened).edge_name.as_deref(), Some(name.as_str()));
assert_line(&opened, 400.0, (0.0, 50.0), "opened");
// Undo and redo of the change.
run(&mut s, "edit.undo", json!({}));
assert_line(&s, 60.0, (0.0, 50.0), "undone");
run(&mut s, "edit.redo", json!({}));
assert_line(&s, 400.0, (0.0, 50.0), "redone");
// Editing the sketch keeps the edge, and stores the link's point where the edge is now.
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
assert_eq!(trouble(&s), "");
assert_line(&s, 400.0, (0.0, 50.0), "edited");
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
assert!(matches!(&stored(&s).source, LinkSource::Edge { body, at } if body == "Body1" && at.dist(Vec3::new(400.0, 25.0, 20.0)) < 1e-6));
}
/// A slot cut through the plate from x = 80 to 120, y = 20 to 30 leaves the picked edge in two
/// pieces, y = 0..20 and y = 30..50. The link, picked at y = 10, gives the piece it was picked
/// on and says the edge was split; without the slot it is the whole edge again.
#[test]
fn a_projected_edge_that_was_split_gives_its_nearest_piece_and_says_so() {
let mut s = Session::default();
plate(&mut s);
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "SlotSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [80, 20], "p1": [120, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "SlotSketch", "distance": "20 mm", "operation": "cut", "name": "Slot"}));
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": true}));
project_right_edge(&mut s, 10.0);
assert_line(&s, 100.0, (0.0, 50.0), "drawn");
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": false}));
assert_line(&s, 100.0, (0.0, 20.0), "split");
assert!(trouble(&s).contains("was split"), "{}", trouble(&s));
assert!(!link(&s).lost);
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": true}));
assert_line(&s, 100.0, (0.0, 50.0), "whole again");
assert_eq!(trouble(&s), "");
}
/// A fillet of radius 4 takes the picked edge away. Its two tangent edges are 4 from the picked
/// point, the nearest edges there are now: the link is lost and keeps its line, it takes
/// neither of them. Without the fillet the link has its edge again.
#[test]
fn a_projected_edge_that_is_gone_is_lost_not_replaced() {
let mut s = Session::default();
plate(&mut s);
run(&mut s, "solid.fillet", json!({"edges": [[100, 25, 20]], "radius": "4 mm", "name": "Round"}));
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": true}));
project_right_edge(&mut s, 25.0);
assert_line(&s, 100.0, (0.0, 50.0), "drawn");
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": false}));
let said = trouble(&s);
assert!(said.contains("projected reference lost: edge") && said.contains("no longer exists"), "{said}");
assert!(link(&s).lost);
assert_line(&s, 100.0, (0.0, 50.0), "the last geometry is kept");
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": true}));
assert_eq!(trouble(&s), "");
assert!(!link(&s).lost);
assert_line(&s, 100.0, (0.0, 50.0), "found again");
}
/// A boss 20 x 20 x 10 made as its own body on the plate and joined into it afterwards: no body
/// has the link's body name any more. The join left the boss's top edge from (30, 10, 30) to
/// (30, 30, 30) as it was, and the link finds it by its name on the body that has it now.
#[test]
fn a_projected_edge_whose_body_was_joined_into_another_is_found_there() {
let mut s = Session::default();
plate(&mut s);
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "20 mm", "name": "BossSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [30, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "BossSketch", "distance": "10 mm", "operation": "new", "name": "Boss", "body_name": "Boss"}));
run(&mut s, "solid.combine", json!({"target": "Body1", "tools": ["Boss"], "operation": "join"}));
run(&mut s, "timeline.suppress", json!({"feature": "Combine1", "suppressed": true}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "30 mm", "name": "Top"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [30, 20, 30], "body": "Boss"}]}));
run(&mut s, "sketch.finish", json!({}));
assert_line(&s, 30.0, (10.0, 30.0), "drawn");
assert!(stored(&s).edge_name.is_some());
run(&mut s, "timeline.suppress", json!({"feature": "Combine1", "suppressed": false}));
assert!(s.model.state().body("Boss").is_none(), "the boss is part of the plate now");
assert_eq!(trouble(&s), "");
assert!(!link(&s).lost);
assert_line(&s, 30.0, (10.0, 30.0), "joined");
assert!(matches!(&link(&s).source, LinkSource::Edge { body, .. } if body == "Body1"));
}
/// A design saved before links kept the edge's name (format 16) has a link with a point only.
/// It opens and is found by that point, as it was, and takes the edge's name when its sketch is
/// next edited: undoing the edit takes the name away again, redoing it brings it back.
#[test]
fn an_edge_link_saved_without_a_name_is_found_as_before_and_named_by_the_next_edit() {
let mut s = Session::default();
plate(&mut s);
project_right_edge(&mut s, 25.0);
let name = edge_named_at(&mut s, [100.0, 25.0, 20.0]);
let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
let links = saved["features"].as_array_mut().unwrap().iter_mut().filter_map(|f| f.pointer_mut("/sketch/links")?.as_array_mut());
let removed = links.flatten().filter_map(|l| l.as_object_mut()?.remove("edge_name")).count();
assert_eq!(removed, 1, "the one projected edge");
saved["format"] = json!("solidcraft/16");
let mut s = Session::new(solidcraft_doc::Document::from_json(&saved.to_string()).unwrap());
assert_eq!(s.doc.format, solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT), "upgraded on opening");
assert_eq!(stored(&s).edge_name, None);
assert_eq!(trouble(&s), "");
assert_line(&s, 100.0, (0.0, 50.0), "opened");
// Found by its point: 0.2 further out the picked edge is still the nearest one.
set_width(&mut s, 100.2);
assert_eq!(stored(&s).edge_name, None);
assert_eq!(trouble(&s), "");
assert_line(&s, 100.2, (0.0, 50.0), "width 100.2");
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
run(&mut s, "edit.undo", json!({}));
assert_eq!(stored(&s).edge_name, None);
run(&mut s, "edit.redo", json!({}));
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
run(&mut s, "sketch.finish", json!({}));
set_width(&mut s, 150.0);
assert_eq!(trouble(&s), "");
assert_line(&s, 150.0, (0.0, 50.0), "width 150");
}
@@ -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");
}
@@ -526,3 +526,62 @@ fn faces_split_by_hidden_seams_project_whole() {
assert_eq!(radii.len(), 4, "{round}");
assert!(radii[..2].iter().all(|r| (r - 17.).abs() < 1e-6) && radii[2..].iter().all(|r| (r - 20.).abs() < 1e-6), "{round}");
}
/// #635: a rectangle whose corner is made coincident with a projected edge of the solid under
/// the sketch, so that its bottom side lies on that edge, is still one closed profile (the
/// projected line stays, and names the shared edge as it was there first), and it extrudes to
/// its own volume, also when the design is opened again.
#[test]
fn a_rectangle_put_onto_a_projected_edge_stays_a_closed_profile() {
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": 20}));
let block = s.model.state().bodies[0].name.clone();
let id = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
// The solid's bottom edge along X, from (0, 0) to (40, 0).
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 0, 0]}]}));
let projected = r["links"][0]["curves"][0].as_str().unwrap().to_string();
// A 20 x 15 rectangle clear of it is a closed profile of its own.
let sides = ids(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 10], "p1": [25, 25]}))["curves"]);
run(&mut s, "sketch.dimension", json!({"entities": [sides[0]], "value": 20}));
run(&mut s, "sketch.dimension", json!({"entities": [sides[1]], "value": 15}));
let mut own = sides.clone();
own.sort();
let profile_curves = |si: &Value| -> Vec<Vec<String>> {
si["profiles"]
.as_array()
.unwrap()
.iter()
.map(|p| {
let mut c = ids(&p["curves"]);
c.sort();
c
})
.collect()
};
assert_eq!(profile_curves(&inspect(&mut s)), vec![own.clone()]);
// Its bottom-left corner onto the projected line: the bottom side lies on it.
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", sides[0]), "b": projected}));
let si = inspect(&mut s);
assert_eq!(si["status"], "solved", "{si}");
let curve = |id: &str| si["curves"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone();
let bottom = curve(&sides[0]);
for end in ["start_at", "end_at"] {
assert!(bottom[end][1].as_f64().unwrap().abs() < 1e-9, "{bottom}");
}
assert!(curve(&projected).get("link").is_some(), "the projected line stays");
let mut on_line = vec![projected.clone(), sides[1].clone(), sides[2].clone(), sides[3].clone()];
on_line.sort();
assert_eq!(profile_curves(&si), vec![on_line], "{si}");
assert!((si["profiles"][0]["area_mm2"].as_f64().unwrap() - 300.0).abs() < 1e-6, "{si}");
// Picked by a point inside, it extrudes to a 20 x 15 x 10 block of its own.
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": id, "profiles": [{"point": [15, 7.5]}], "distance": 10, "operation": "new", "body_name": "Pad"}));
let volume = |s: &Session, name: &str| s.model.state().body(name).unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
assert!((volume(&s, "Pad") - 3000.0).abs() < 1e-6, "{}", volume(&s, "Pad"));
assert!((volume(&s, &block) - 24000.0).abs() < 1e-6, "{}", volume(&s, &block));
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!((volume(&reopened, "Pad") - 3000.0).abs() < 1e-6, "{}", volume(&reopened, "Pad"));
}
@@ -0,0 +1,252 @@
//! Redundant but consistent constraints (#687): rectangles of one size drawn around a shared
//! corner (a "clover"), or onto each other's sides, imply some of their constraints twice. Those
//! are kept (and reported as redundant) or not added at all; only constraints that conflict
//! with the sketch are refused.
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}"),
}
}
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s
}
fn sketch(s: &Session) -> solidcraft_sketch::Sketch {
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.clone()
}
fn report(s: &Session) -> solidcraft_sketch::SolveReport {
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().report.clone()
}
fn glyphs(s: &mut Session) -> Value {
run(s, "sketch.glyphs", json!({}))
}
/// The four quadrants around `c`, each `w` square: bottom-left, bottom-right, top-right,
/// top-left (each next one shares a side with the one before; the last shares two).
fn quadrants(c: [f64; 2], w: f64) -> [([f64; 2], [f64; 2]); 4] {
let [x, y] = c;
[([x - w, y - w], [x, y]), ([x, y - w], [x + w, y]), ([x, y], [x + w, y + w]), ([x - w, y], [x, y + w])]
}
fn horizontal_vertical(sk: &solidcraft_sketch::Sketch) -> usize {
sk.constraints
.iter()
.filter(|c| matches!(c.kind, solidcraft_sketch::ConstraintKind::Horizontal { .. } | solidcraft_sketch::ConstraintKind::Vertical { .. }))
.count()
}
/// The owner's steps: rectangles of one size (typed 10 × 10) drawn snapped onto a shared
/// corner, 2, 3 and 4 of them. Every one is accepted; a size the sketch already has becomes a
/// driven (reference) dimension, and the sketch ends fully constrained.
#[test]
fn a_clover_of_same_size_rectangles_drawn_by_snapping_is_accepted() {
for n in 2..=4 {
let mut s = new_sketch();
for (i, (a, b)) in quadrants([0.0, 0.0], 10.0).iter().take(n).enumerate() {
let r = s.execute(
"sketch.draw.dimensioned",
&json!({"command": "sketch.rectangle.two_point", "params": {"p0": a, "p1": b}, "dimensions": {"width": 10, "height": 10}}),
);
let r = r.unwrap_or_else(|e| panic!("rectangle {i} of {n}: {e}"));
assert_eq!(r["sketch"]["solved"], true, "{r}");
}
let g = glyphs(&mut s);
assert_eq!(g["fully_constrained"], true, "{n} rectangles: {g}");
assert_eq!(g["conflicts"], json!([]), "{g}");
let sk = sketch(&s);
assert_eq!(sk.curves.len(), 4 * n);
// Shared sides carry one horizontal/vertical, not two: 4 for the first rectangle, 3 for
// each with one shared side, 2 for the last of four (two shared sides).
let expected = [0, 4, 7, 10, 12][n];
assert_eq!(horizontal_vertical(&sk), expected, "{n} rectangles");
// Each later rectangle's size along a shared side is a reference dimension.
let driven = sk.constraints.iter().filter(|c| c.driven).count();
assert_eq!(driven, [0, 0, 1, 2, 4][n], "{n} rectangles");
for (a, b) in quadrants([0.0, 0.0], 10.0).iter().take(n) {
for corner in [Vec2::new(a[0], a[1]), Vec2::new(b[0], b[1])] {
assert!(sk.points.iter().any(|p| p.pos.dist(corner) < 1e-9), "{n} rectangles: corner {corner:?} moved");
}
}
}
}
/// Undimensioned, the same clover is drawn without a single redundant constraint: shared
/// sides are not constrained twice, and the freedom left is the two column widths and the two
/// row heights.
#[test]
fn rectangles_sharing_sides_do_not_constrain_a_side_twice() {
let mut s = new_sketch();
for (a, b) in quadrants([50.0, 50.0], 10.0) {
run(&mut s, "sketch.rectangle.two_point", json!({"p0": a, "p1": b}));
}
let rep = report(&s);
assert!(rep.ok(), "{rep:?}");
assert_eq!(rep.redundant, Vec::<String>::new());
// Free: the shared corner (2), two column widths, two row heights.
assert_eq!(rep.dof, 6);
assert_eq!(horizontal_vertical(&sketch(&s)), 12);
}
/// Two rectangles on one side: the second only adds its width.
#[test]
fn shared_edge_rectangles() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [25, 10]}));
// Three of its sides' directions and its two corners on the first one.
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(5), "{r}");
let rep = report(&s);
assert_eq!(rep.dof, 3, "the first one's width and height, the second one's width");
assert!(rep.redundant.is_empty());
// Sized and placed, all of it is fixed.
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 10}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
run(&mut s, "sketch.dimension", json!({"entities": ["l5"], "value": 15}));
assert_eq!(glyphs(&mut s)["fully_constrained"], true);
}
/// Two separately drawn 10 × 10 squares snapped corner to corner: the second snap is implied by
/// the first and the equal sizes. It is kept, reported as redundant along with what implies
/// it, and the degrees of freedom and the fully constrained state are unchanged.
#[test]
fn snapping_a_second_corner_of_same_size_rectangles_is_redundant_not_refused() {
let mut s = new_sketch();
for (a, b) in [([0.0, 0.0], [10.0, 10.0]), ([20.0, 0.0], [30.0, 10.0])] {
run(
&mut s,
"sketch.draw.dimensioned",
json!({"command": "sketch.rectangle.two_point", "params": {"p0": a, "p1": b}, "dimensions": {"width": 10, "height": 10}}),
);
}
// The second square's bottom-left corner onto the first one's bottom-right.
run(&mut s, "sketch.constraint.coincident", json!({"a": "l5.start", "b": "l1.end"}));
let dof = report(&s).dof;
assert_eq!(dof, 0, "the first square is on the origin, the second one placed by its corner");
// Its top-left corner is already on the first one's top-right.
let r = run(&mut s, "sketch.constraint.coincident", json!({"a": "l7.end", "b": "l2.end"}));
assert_eq!(r["redundant"], true, "{r}");
assert!(!r["determined_by"].as_array().is_none_or(Vec::is_empty), "{r}");
let rep = report(&s);
assert_eq!((rep.ok(), rep.dof), (true, 0));
assert_eq!(rep.redundant, vec![r["constraint"].as_str().unwrap().to_string()]);
let g = glyphs(&mut s);
assert_eq!(g["fully_constrained"], true);
let flagged: Vec<&Value> = g["glyphs"].as_array().unwrap().iter().filter(|x| x["redundant"] == true).collect();
assert_eq!(flagged.len(), 1, "{g}");
// Drawing-time inferences ask for redundant ones to be left out.
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": "l8", "if_redundant": "refuse"})).unwrap_err().to_string();
assert!(e.contains("already determined by"), "{e}");
}
/// A size the sketch already has, dimensioned again: refused as already determined (naming
/// what determines it), or placed as a driven dimension; an equal constraint the sketch already
/// implies is kept as redundant.
#[test]
fn redundant_equal_dimensions() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [20, 10]}));
let height = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
let k = sketch(&s).constraints.iter().find(|c| c.param.as_deref() == height["param"].as_str()).map(|c| c.id.clone()).unwrap();
// The second one's right side is as tall as the shared side.
let e = s.execute("sketch.dimension", &json!({"entities": ["l6"], "value": 10})).unwrap_err().to_string();
assert!(e.contains("already determined by") && e.contains(&k), "{e}");
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l6"], "value": 10, "if_redundant": "driven"}));
assert_eq!((d["driven"].clone(), d["redundant"].clone()), (json!(true), json!(true)), "{d}");
// Equal widths, twice: the second is implied by the first.
run(&mut s, "sketch.constraint.equal", json!({"a": "l1", "b": "l5"}));
let before = report(&s).dof;
let r = run(&mut s, "sketch.constraint.equal", json!({"a": "l3", "b": "l7"}));
assert_eq!(r["redundant"], true, "{r}");
assert_eq!(report(&s).dof, before);
// Its driving value changes still solve with the redundant constraints present.
let pname = height["param"].as_str().unwrap().to_string();
run(&mut s, "parameters.change", json!({"name": pname, "expression": "14 mm"}));
let sk = sketch(&s);
assert!(report(&s).ok());
let len = |id: &str| {
let c = sk.curve_index(id).unwrap();
let solidcraft_sketch::CurveKind::Line { a, b } = sk.curves[c].kind else { panic!() };
sk.points[a].pos.dist(sk.points[b].pos)
};
assert!((len("l6") - 14.0).abs() < 1e-7 && (len("l4") - 14.0).abs() < 1e-7);
}
/// A constraint that does not hold where the others put the geometry still conflicts.
#[test]
fn a_conflicting_constraint_is_still_refused() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [20, 10]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
let before = sketch(&s).constraints.len();
// The second one's right side cannot be 12 while the shared side is 10.
let e = s.execute("sketch.dimension", &json!({"entities": ["l6"], "value": 12})).unwrap_err().to_string();
assert!(e.contains("over-constrain") || e.contains("conflicts"), "{e}");
let e = s.execute("sketch.dimension", &json!({"entities": ["l6"], "value": 12, "if_redundant": "driven"})).unwrap_err().to_string();
assert!(e.contains("over-constrain") || e.contains("conflicts"), "{e}");
// A vertical side cannot be horizontal.
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": "l2", "mode": "horizontal"})).unwrap_err().to_string();
assert!(e.contains("over-constrain"), "{e}");
assert_eq!(sketch(&s).constraints.len(), before);
assert!(report(&s).redundant.is_empty());
}
/// Dragging the shared corner of a clover moves all four rectangles with it, redundant
/// constraints and all.
#[test]
fn dragging_the_shared_corner_moves_every_rectangle() {
let mut s = new_sketch();
for (a, b) in quadrants([50.0, 50.0], 10.0) {
run(&mut s, "sketch.rectangle.two_point", json!({"p0": a, "p1": b}));
}
// A redundant equal and a redundant vertical, so the drag has some to carry.
run(&mut s, "sketch.constraint.equal", json!({"a": "l1", "b": "l13"}));
let r = run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l4"}));
assert_eq!(r["redundant"], true, "{r}");
assert!(!report(&s).redundant.is_empty());
let centre = Vec2::new(50.0, 50.0);
let id = sketch(&s).points.iter().find(|p| p.pos.dist(centre) < 1e-9).map(|p| p.id.clone()).unwrap();
run(&mut s, "sketch.drag", json!({"entity": id, "from": [50, 50], "to": [56, 47]}));
let sk = sketch(&s);
let rep = report(&s);
assert!(rep.ok(), "{rep:?}");
let to = Vec2::new(56.0, 47.0);
// Each rectangle still has a corner on the moved one and is still a rectangle.
let near: usize = sk.points.iter().filter(|p| p.pos.dist(to) < 1e-6).count();
assert!(near >= 4, "{near} corners at the dragged spot");
for l in &sk.curves {
let solidcraft_sketch::CurveKind::Line { a, b } = l.kind else { continue };
let d = sk.points[b].pos - sk.points[a].pos;
assert!(d.x.abs() < 1e-7 || d.y.abs() < 1e-7, "{} is no longer axis-aligned: {d:?}", l.id);
}
assert!(sk.points.iter().all(|p| p.pos.dist(centre) > 1e-6), "nothing stayed behind at the old corner");
}
/// A horizontal on a line that already is (it was drawn so, inferred) is redundant, not an
/// error; the degrees of freedom do not change, and the sketch still turns fully constrained.
#[test]
fn an_explicit_redundant_constraint_keeps_the_dof_and_full_constraint() {
let mut s = new_sketch();
let l = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"][0].as_str().unwrap().to_string();
let r = run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": l}));
assert_eq!(r["redundant"], true, "{r}");
// Its start is on the origin (snapped): only its length is free.
assert_eq!(report(&s).dof, 1);
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": 10}));
let g = glyphs(&mut s);
assert_eq!((g["dof"].clone(), g["fully_constrained"].clone()), (json!(0), json!(true)), "{g}");
assert!(report(&s).point_determined.iter().all(|d| *d));
}
@@ -0,0 +1,108 @@
use super::*;
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
s.execute(cmd, &params).unwrap()
}
fn setup() -> (Session, String, String) {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
let result = run(&mut s, "sketch.text", json!({"text":"II","at":[3,4],"height":10,"angle":15}));
(s, result["link"].as_str().unwrap().into(), result["curves"][0].as_str().unwrap().into())
}
#[test]
fn text_move_updates_the_source_once_preserves_ids_and_roundtrips() {
let (mut s, link, curve) = setup();
let before = s.doc.to_json();
let ids: Vec<_> = s.doc.sketch(1).unwrap().curves.iter().map(|c| c.id.clone()).collect();
let undo = s.undo.len();
run(&mut s, "sketch.move", json!({"entities":[curve,ids[1]],"translate":[10,-2],"center":[3,4],"angle":30}));
let sk = s.doc.sketch(1).unwrap();
assert_eq!(sk.curves.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), ids);
let LinkSource::Text { at, angle, text, height, font } = &sk.link(&link).unwrap().source else { panic!() };
assert!(at.dist(Vec2::new(13.0, 2.0)) < 1e-9);
assert!((*angle - 45_f64.to_radians()).abs() < 1e-12);
assert_eq!((text.as_str(), *height, font), ("II", 10.0, &None));
assert_eq!(s.undo.len(), undo + 1);
let moved = s.doc.to_json();
let reopened = solidcraft_doc::Document::from_json(&moved).unwrap();
assert_eq!(reopened.to_json(), moved);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.to_json(), moved);
}
#[test]
fn text_copy_is_a_new_editable_source_and_original_is_unchanged() {
let (mut s, link, curve) = setup();
let original = s.doc.sketch(1).unwrap().link(&link).unwrap().source.clone();
let result = run(&mut s, "sketch.move", json!({"entities":[curve],"translate":[20,0],"copy":true}));
let copied_curve = result["curves"][0].as_str().unwrap();
let new_link = text_link(s.doc.sketch(1).unwrap(), copied_curve).unwrap().to_owned();
assert_ne!(new_link, link);
run(&mut s, "sketch.edit_text", json!({"link":new_link,"text":"O"}));
assert_eq!(s.doc.sketch(1).unwrap().link(&link).unwrap().source, original);
}
#[test]
fn text_drag_moves_all_letters_and_rebuilds_downstream_on_release() {
let (mut s, link, curve) = setup();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":3}));
run(&mut s, "sketch.edit", json!({"sketch":1}));
let sk = s.doc.sketch(1).unwrap();
let before = sk.link_points(&link).into_iter().map(|i| (i, sk.point(i).unwrap())).collect::<Vec<_>>();
for frame in 0..3 {
let result = run(&mut s, "sketch.drag", json!({"entity":curve,"from":[frame,0],"to":[frame+1,1],"live":true}));
assert_eq!(result["reached"], 1.0);
assert!(s.model.is_stale());
}
run(&mut s, "sketch.drag_end", json!({}));
assert!(!s.model.is_stale());
assert!(s.model.result(2).is_some_and(|r| r.error.is_none()));
let sk = s.doc.sketch(1).unwrap();
for (i, old) in before {
assert!(sk.point(i).unwrap().dist(old + Vec2::new(3.0, 3.0)) < 1e-8);
}
assert!(matches!(sk.link(&link).unwrap().source, LinkSource::Text { .. }));
}
#[test]
fn text_move_in_a_rotated_component_preserves_font_and_local_coordinates() {
let mut s = Session::default();
let component = run(&mut s, "component.create", json!({"name":"Label"}));
run(&mut s, "occurrence.move", json!({"occurrence":component["occurrence"],"translate":[30,20,10],"axis":[0,0,1],"angle":90}));
let created = run(&mut s, "sketch.create", json!({"plane":"XY"}));
let sid = created["sketch"].as_u64().unwrap();
let result = run(&mut s, "sketch.text", json!({"text":"I","at":[0,0],"height":10,"font":solidcraft_sketch::DEFAULT_FONT_FAMILY}));
let link = result["link"].as_str().unwrap();
let before = s.world_state().sketch(sid).unwrap().plane;
run(&mut s, "sketch.move", json!({"entities":[result["curves"][0]],"translate":[5,2]}));
let sk = s.doc.sketch(sid).unwrap();
let LinkSource::Text { at, font, .. } = &sk.link(link).unwrap().source else { panic!() };
assert!(at.dist(Vec2::new(5.0, 2.0)) < 1e-9);
assert_eq!(font.as_deref(), Some(solidcraft_sketch::DEFAULT_FONT_FAMILY));
let state = s.world_state();
assert!(state.sketch(sid).unwrap().plane.to_world(*at).dist(before.to_world(Vec2::new(5.0, 2.0))) < 1e-9);
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(reopened.doc.sketch(sid).unwrap().link(link).unwrap().source, sk.link(link).unwrap().source);
}
#[test]
fn text_move_rejects_malformed_values_without_changing_history() {
let (mut s, _, curve) = setup();
let before = s.doc.to_json();
let undo = s.undo.len();
for params in [
json!({"entities":[curve],"translate":[1]}),
json!({"entities":[curve],"angle":"invalid"}),
json!({"entity":curve,"from":[0,0],"to":["invalid",2]}),
] {
let cmd = if params.get("entity").is_some() { "sketch.drag" } else { "sketch.move" };
assert!(s.execute(cmd, &params).is_err());
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.undo.len(), undo);
}
}
+83
View File
@@ -482,6 +482,63 @@ fn palette_options_are_kept_with_each_sketch() {
assert!(!sketch(&s).view.hide_points);
}
/// Construction Geometries is a display option of one sketch, saved with it, and it changes
/// nothing about the geometry: construction curves were already outside every profile, and
/// they still are while hidden. Centerlines are a line type of their own and keep showing. A
/// design written before the option existed carries no key for it and loads with construction
/// geometry shown.
#[test]
fn hiding_construction_geometry_is_per_sketch_saved_and_leaves_profiles_and_centerlines_alone() {
use crate::view::sketch_curve_visible;
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.line", json!({"points": [[0, 45], [40, 45]], "connect": false, "infer": false}));
run(&mut s, "sketch.line", json!({"points": [[0, 55], [40, 55]], "connect": false, "infer": false}));
run(&mut s, "sketch.construction", json!({"curves": ["l5"], "value": true}));
run(&mut s, "sketch.centerline", json!({"curves": ["l6"], "value": true}));
let (rect, construction, centerline) = (0, 4, 5);
let profiles = run(&mut s, "sketch.inspect", json!({}))["profiles"].clone();
assert_eq!(profiles.as_array().map(Vec::len), Some(1), "the rectangle is one profile: {profiles}");
assert_eq!(run(&mut s, "sketch.options", json!({}))["options"]["show_construction"], true);
let o = run(&mut s, "sketch.options", json!({"show_construction": false}));
assert_eq!(o["options"]["show_construction"], false);
let sk = sketch(&s);
assert!(sk.view.hide_construction);
assert!(!sketch_curve_visible(&sk, construction), "construction geometry is hidden");
assert!(sketch_curve_visible(&sk, centerline), "a centerline is its own line type");
assert!(sketch_curve_visible(&sk, rect), "ordinary geometry stays");
// Hiding is display only: the curves, their line types and the profiles are untouched.
let after = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(after["profiles"], profiles);
assert_eq!(after["curves"][construction]["construction"], true);
assert!(sketch(&s).curves[centerline].centerline, "the centerline keeps its line type");
// Undo puts the option back.
run(&mut s, "edit.undo", json!({}));
assert!(!sketch(&s).view.hide_construction);
run(&mut s, "sketch.options", json!({"show_construction": false}));
// Another sketch shows construction geometry; the first keeps the option after finishing.
let first = s.active_sketch.expect("sketch");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
assert_eq!(run(&mut s, "sketch.options", json!({}))["options"]["show_construction"], true);
assert_eq!(run(&mut s, "sketch.options", json!({"sketch": first}))["options"]["show_construction"], false);
// Saved with the design.
let text = serde_json::to_string(s.doc.sketch(first).expect("first")).expect("json");
assert!(text.contains("hide_construction"), "{text}");
let back: Sketch = serde_json::from_str(&text).expect("parse");
assert!(back.view.hide_construction);
// An older design file has no `hide_construction` key at all: `SketchView`'s serde default
// fills it in, and construction geometry shows, the way it did before the option existed.
let older = r#"{"points":[{"id":"origin","pos":[0.0,0.0],"fixed":true}],"curves":[],"constraints":[],
"view":{"hide_projected":true,"slice":true}}"#;
let old: Sketch = serde_json::from_str(older).expect("an older sketch still loads");
assert!(old.view.hide_projected && old.view.slice && !old.view.hide_construction);
}
// ---------------------------------------------------------------------------------------------
// Dragging
@@ -534,6 +591,32 @@ fn dragging_circles_and_arcs() {
assert!(pt(&s, "a1.start").dist(v(70.0, 0.0)) < 1e-9, "{:?}", pt(&s, "a1.start"));
}
#[test]
fn dragging_a_conic_reads_rho_off_the_cursor_and_its_apex_slides_like_any_point() {
let mut s = new_sketch();
run(&mut s, "sketch.conic", json!({"start": [0, 0], "end": [40, 0], "apex": [20, 20]}));
let rho = |s: &Session| match sketch(s).curves[0].kind {
solidcraft_sketch::CurveKind::Conic { rho, .. } => rho,
ref k => panic!("not a conic: {k:?}"),
};
assert!((rho(&s) - 0.5).abs() < 1e-9, "default rho: {}", rho(&s));
// Grabbed on the curve itself (its midpoint, at the default rho of 0.5): `rho` follows the
// cursor, read off where it falls between the chord's midpoint (20, 0) and the apex (20,
// 20). The three defining points stay exactly where they were.
drag(&mut s, "k1", [20.0, 10.0], [20.0, 14.0]);
assert!((rho(&s) - 0.7).abs() < 1e-6, "rho after drag: {}", rho(&s));
assert!(pt(&s, "k1.start").dist(v(0.0, 0.0)) < 1e-9);
assert!(pt(&s, "k1.end").dist(v(40.0, 0.0)) < 1e-9);
assert!(pt(&s, "k1.apex").dist(v(20.0, 20.0)) < 1e-9);
// Grabbed on the apex itself: it slides like any other point (not a `rho` edit), and `rho`
// is unaffected.
drag(&mut s, "k1.apex", [20.0, 20.0], [10.0, 30.0]);
assert!(pt(&s, "k1.apex").dist(v(10.0, 30.0)) < 1e-9);
assert!((rho(&s) - 0.7).abs() < 1e-6, "rho after apex drag: {}", rho(&s));
}
#[test]
fn constrained_geometry_resists_drags() {
let mut s = new_sketch();

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