Compare commits

...

77 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
276 changed files with 36374 additions and 3524 deletions
+77 -49
View File
@@ -5,68 +5,96 @@ 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.8:** this is a minor bump because the design file format moved to version 16, and
**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**
- **Face actions work on far more faces.** Sketches, offset planes and press-pull now attach to
Cut, Hole and chamfer faces, each pattern or mirror copy, side walls a Join merged and the
pieces a later feature split a face into
([#625](https://github.com/bherbruck/solidcraft/pull/625)), to moved or pasted components
([#610](https://github.com/bherbruck/solidcraft/pull/610)) and beside long thin holes
([#629](https://github.com/bherbruck/solidcraft/pull/629)). Across the test models, flat-face
actions pass 70% of the time, up from 61% (sketch 52% → 71%, offset plane 52% → 71%,
press-pull 41% → 60%).
- **Fillet and chamfer** handle a single edge whose ends meet curved or slanted faces, and blend
smooth chains of edges ([#646](https://github.com/bherbruck/solidcraft/pull/646)): 83% → 93%
of edges.
- **New:** user-defined physical materials stored in the design, linked components (insert
another design), saved body visibility, named views through `view.apply`, sketch text fonts,
corner reliefs, flanges on flanges, edge flanges that trim the base along their edge, grouped
sketch tools, Home thumbnails, and an improved Select Other list.
- **Fixes:** filleting beside a modeled thread no longer freezes the app, a loft standing on a
face fuses with it, and many sheet metal, hole and sketch fixes.
- **Booleans are checked.** A Boolean retry that would change the part away from the tool is now
refused instead of returning wrong geometry, which fixes stray cuts
([#616](https://github.com/bherbruck/solidcraft/issues/616)). MCP feature replies include
warnings.
- **Opening a design** keeps the window draggable, shows progress, and can be cancelled with
Cancel or Esc.
- **Under the hood:** compact MCP replies, a kernel-neutral native B-rep format, and an opt-in
preview from the native cache while a design rebuilds.
- **Quality:** every ordinary action is run on every face, edge, vertex and body of the test
models; CI blocks regressions and the full matrix runs nightly.
- **Security:** private vulnerability reporting is on; see
[SECURITY.md](https://github.com/bherbruck/solidcraft/blob/main/SECURITY.md).
- **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.
**File format 16.** Designs are now format 16 (`solidcraft/16`). SolidCraft 0.7 and older can't
open designs saved by 0.8, as a container or as JSON; keep a copy of a file you may still open
in 0.7. Files from every earlier version still open in 0.8.
**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.
**Linux app ID.** The Linux app ID is now `ai.storyteller.solidcraft` (it was
`ai.storyteller.solvecraft`). Flatpak users from the SolidCraft feed: `flatpak update` reports
the old app as end-of-life and asks to replace it; say yes, and your settings come along. The
`.deb`, `.rpm` and AppImage remove the old menu entry on upgrade or first launch.
**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.
**Known limitations**
- Some faces still can't take an attached sketch: after Revolve, Offset Face or Boss
([#494](https://github.com/bherbruck/solidcraft/issues/494)), sheet metal thickness faces
([#495](https://github.com/bherbruck/solidcraft/issues/495)), Loft, Sweep and Coil faces
([#561](https://github.com/bherbruck/solidcraft/issues/561)), faces after a Draft or a tapered
Extrude ([#563](https://github.com/bherbruck/solidcraft/issues/563)) and a Shell's rim
([#564](https://github.com/bherbruck/solidcraft/issues/564)).
- A Hole on a sheet metal face is refused
([#560](https://github.com/bherbruck/solidcraft/issues/560)); some through-all holes along a
wall or beside curved faces still fail
([#559](https://github.com/bherbruck/solidcraft/issues/559)).
- Some 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)).
- Cutting a coaxial cylinder from a thin disc can lose a little volume
([#500](https://github.com/bherbruck/solidcraft/issues/500)); an edge flange with different
- 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
+4 -2
View File
@@ -37,8 +37,10 @@ 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/demo-0.6.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.6.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
| docs/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 |
Generated
+15 -13
View File
@@ -3281,7 +3281,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"eframe",
"egui",
@@ -3298,7 +3298,7 @@ dependencies = [
[[package]]
name = "solidcraft-cli"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3307,7 +3307,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"log",
"serde",
@@ -3322,7 +3322,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"log",
"serde",
@@ -3338,7 +3338,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"serde",
"serde_json",
@@ -3347,7 +3347,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"quick-xml",
"serde",
@@ -3362,12 +3362,14 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"log",
"rayon",
"serde",
"serde_json",
"solidcraft-geom",
"spade",
"thiserror 2.0.21",
"truck-meshalgo",
"truck-modeling",
@@ -3378,7 +3380,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3386,7 +3388,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"serde",
@@ -3396,7 +3398,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"serde",
"serde_json",
@@ -3407,7 +3409,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -3422,7 +3424,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"eframe",
"egui",
@@ -4845,7 +4847,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"serde_json",
]
+5 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.8.0"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -37,6 +37,10 @@ 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"] }
+13 -3
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 0.6: model a Raspberry Pi 4 case from an empty design: a sketch dimensioned with parameters, extrude, fillets and shell, standoffs patterned on the board's mounting holes, port cut-outs, vent slots, a colour and a height change in Parameters](docs/screenshots/demo-0.6.gif)
![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)
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots and a colour, then height 24 → 32 mm in Parameters and it all rebuilds. 6× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.6.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
*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,
@@ -91,7 +101,7 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
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.
+19 -5
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,16 +49,25 @@ 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; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular) chosen by the rule | in progress | 36 |
| 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
@@ -81,11 +90,16 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
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.
+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}}))
+88 -8
View File
@@ -47,6 +47,10 @@ const SLOW: [&str; 7] = [
const EDIT_SAMPLE: u64 = 6;
/// An action that takes longer than this is recorded as a timeout.
const ACTION_TIMEOUT: Duration = Duration::from_secs(90);
/// Building a design and listing its actions, before the first one runs: the biggest designs
/// (oracle 41's tube with radial holes, about 25 s here) take several times that on a loaded CI
/// runner, and a design that never loads loses all its actions, not one.
const LOAD_TIMEOUT: Duration = Duration::from_secs(300);
/// Caps per body, so one large imported or patterned body cannot dominate the run.
const MAX_FACES: usize = 120;
const MAX_EDGES: usize = 240;
@@ -725,7 +729,9 @@ fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut seen = 0usize;
// Until the child writes, it is loading the design (again, after a restart).
let loaded_at = std::fs::metadata(out).map(|m| m.len() as usize).unwrap_or(0);
let mut seen = loaded_at;
let mut last_change = Instant::now();
let (status, timed_out) = loop {
if let Some(status) = child.try_wait().unwrap() {
@@ -735,7 +741,7 @@ fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
if n != seen {
seen = n;
last_change = Instant::now();
} else if last_change.elapsed() > ACTION_TIMEOUT {
} else if last_change.elapsed() > if seen == loaded_at { LOAD_TIMEOUT } else { ACTION_TIMEOUT } {
let _ = child.kill();
let _ = child.wait();
break (None, true);
@@ -1046,7 +1052,6 @@ fn a_through_hole_into_a_shelled_box_rim() {
}
#[test]
#[ignore = "fails until #560 is fixed"]
fn a_hole_through_a_sheet_metal_base() {
let mut c: Vec<Value> = rectangle(json!("XY"), [0., 0.], [60., 40.]).into();
c.extend([
@@ -1057,7 +1062,6 @@ fn a_hole_through_a_sheet_metal_base() {
}
#[test]
#[ignore = "fails until #561 is fixed"]
fn sketches_on_loft_and_sweep_faces() {
let mut loft: Vec<Value> = rectangle(json!("XY"), [-20., -20.], [20., 20.]).into();
loft.extend([
@@ -1096,7 +1100,6 @@ fn press_pull_on_the_pi_case_floor() {
}
#[test]
#[ignore = "fails until #563 is fixed"]
fn sketches_after_a_draft_and_on_a_tapered_extrude() {
reproduces(
None,
@@ -1117,7 +1120,6 @@ fn sketches_after_a_draft_and_on_a_tapered_extrude() {
}
#[test]
#[ignore = "fails until #564 is fixed"]
fn a_sketch_on_the_rim_a_shell_leaves() {
let mut c: Vec<Value> = rectangle(json!("XY"), [-35., -25.], [35., 25.]).into();
c.extend([
@@ -1173,8 +1175,36 @@ fn a_shell_of_a_swept_bar() {
);
}
/// The construction a drill-tip script ends with.
struct TipConstruction(solidcraft_engine::doc::construct::ConstructGeom);
impl TipConstruction {
fn axis(&self) -> (Vec3, Vec3) {
match &self.0 {
solidcraft_engine::doc::construct::ConstructGeom::Axis { origin, dir } => (*origin, *dir),
other => panic!("expected an axis, got {other:?}"),
}
}
fn plane(&self) -> solidcraft_engine::geom::Plane {
match &self.0 {
solidcraft_engine::doc::construct::ConstructGeom::Plane(p) => *p,
other => panic!("expected a plane, got {other:?}"),
}
}
}
/// Replays the script and returns its last construction.
fn drill_tip_construction(commands: Vec<Value>) -> TipConstruction {
let mut s = Session::default();
for c in &commands {
let id = c["command"].as_str().unwrap();
s.execute(id, &c["params"]).unwrap_or_else(|e| panic!("{id}: {e}"));
}
let state = s.model.state();
TipConstruction(state.construct.last().map(|c| c.geom.clone()).expect("a construction"))
}
#[test]
#[ignore = "fails until #567 is fixed"]
fn an_axis_through_a_drill_tip() {
let mut c: Vec<Value> = rectangle(json!("XY"), [0., 0.], [60., 40.]).into();
c.extend([
@@ -1182,7 +1212,30 @@ fn an_axis_through_a_drill_tip() {
json!({"command": "solid.hole", "params": {"position": [50, 20, 15], "direction": [0, 0, -1], "diameter": 6, "depth": 10, "tip_angle": 118}}),
json!({"command": "construct.axis.through_cylinder", "params": {"face": {"face": [48.44468907075447, 21.22448979591836, 4.399139380972439]}}}),
]);
reproduces(None, Value::Array(c));
reproduces(None, Value::Array(c.clone()));
// Not just "no error": the axis runs along the drill, through the hole's centre line.
let (origin, dir) = drill_tip_construction(c).axis();
assert!((origin.x - 50.).abs() < 1e-6 && (origin.y - 20.).abs() < 1e-6, "{origin:?}");
assert!(dir.z.abs() > 1. - 1e-9, "{dir:?}");
}
#[test]
fn a_tangent_plane_on_a_drill_tip() {
let mut c: Vec<Value> = rectangle(json!("XY"), [0., 0.], [60., 40.]).into();
c.extend([
json!({"command": "solid.extrude", "params": {"distance": 15}}),
json!({"command": "solid.hole", "params": {"position": [50, 20, 15], "direction": [0, 0, -1], "diameter": 6, "depth": 10, "tip_angle": 118}}),
json!({"command": "construct.plane.tangent", "params": {"face": {"face": [48.44468907075447, 21.22448979591836, 4.399139380972439]}}}),
]);
reproduces(None, Value::Array(c.clone()));
// The plane touches the cone at the picked point and leans like the cone does: a 118 degree tip
// puts the wall 59 degrees off the drill axis, so its normal is 31 degrees off it.
let plane = drill_tip_construction(c).plane();
let picked = Vec3::new(48.44468907075447, 21.22448979591836, 4.399139380972439);
let distance = (picked - plane.origin).dot(plane.normal());
// The pick lies on the tessellated cone and the plane is the exact one, so allow for chord error.
assert!(distance.abs() < 0.05, "the pick is {distance} mm off the plane");
assert!((plane.normal().z.abs() - 31f64.to_radians().cos()).abs() < 1e-2, "{:?}", plane.normal());
}
#[test]
@@ -1199,3 +1252,30 @@ fn a_plane_on_a_patterned_counterbore_after_a_thickness_change() {
]);
reproduces(None, Value::Array(c));
}
/// #707: the same design builds the same body every time, in one process too. Faces were put in
/// the hash order of their addresses in a few places (truck's connected components, the planar
/// boolean's rebuild, healing's merges), so a later boolean on the ribbed lid passed or failed
/// from run to run.
#[test]
fn the_ribbed_lid_builds_the_same_body_every_time() {
let path = root().join("examples/regressions/lid_ribs_flush_with_window.json");
let fingerprint = || {
let s = load(&path).unwrap();
let st = s.model.state();
st.bodies
.iter()
.map(|b| {
let mesh = b.body.tessellate(0.1).unwrap();
let bits = mesh.positions.iter().fold(0u64, |acc, p| {
acc.rotate_left(7).wrapping_add(p.x.to_bits() ^ p.y.to_bits().rotate_left(21) ^ p.z.to_bits().rotate_left(42))
});
format!("{} {} {bits:x}", b.name, b.body.face_count())
})
.collect::<Vec<_>>()
};
let first = fingerprint();
for _ in 0..5 {
assert_eq!(fingerprint(), first);
}
}
+1 -19
View File
@@ -137,13 +137,7 @@ fn body_names_saved_before_exact_history_keep_their_pieces() {
/// ignored test below that checks its faces; a listed plane whose face attaches fails the main
/// check until it is removed from here.
type Planes = &'static [([f64; 3], f64)];
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 5] = [
("m0-revolve-primitives", "Body1", None, 494),
("naming-split-pieces", "Body1", None, 494),
("modern-plastic", "Body1", None, 494),
("modern-sheet-metal", "Body1", None, 495),
("issue-616-lid-ribs", "Lid", None, 628),
];
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)> {
@@ -262,18 +256,6 @@ fn known_faces_attach(issue: u32) {
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
#[test]
#[ignore = "fails until #494 is fixed"]
fn faces_after_revolve_offset_face_and_boss_take_an_attached_sketch() {
known_faces_attach(494);
}
#[test]
#[ignore = "fails until #495 is fixed"]
fn sheet_metal_thickness_faces_take_an_attached_sketch() {
known_faces_attach(495);
}
#[test]
#[ignore = "fails until #628 is fixed"]
fn faces_of_a_lid_with_joined_ribs_take_an_attached_sketch() {
@@ -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);
}
}
+6 -2
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);
}
}
@@ -265,7 +265,10 @@ impl FeatureKind {
FeatureKind::Thread { 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, .. } => pt(face),
FeatureKind::SheetConvert { face, bends, .. } => {
pt(face);
bends.iter_mut().for_each(pt);
}
FeatureKind::SheetFold { a, b, fixed, .. } => {
pt(a);
pt(b);
@@ -318,6 +321,7 @@ impl FeatureKind {
}
FeatureKind::Revolve { .. }
| FeatureKind::Extrude { .. }
| FeatureKind::SurfaceExtrude { .. }
| FeatureKind::Combine { .. }
| FeatureKind::Loft { .. }
| FeatureKind::Sweep { .. }
+67 -57
View File
@@ -47,6 +47,7 @@ impl FeatureKind {
}
}
FeatureKind::Extrude { sketch, .. }
| FeatureKind::SurfaceExtrude { sketch, .. }
| FeatureKind::Revolve { sketch, .. }
| FeatureKind::Emboss { sketch, .. }
| FeatureKind::SheetBase { sketch, .. }
@@ -243,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, .. }
@@ -373,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)?;
@@ -383,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);
}
}
+92 -23
View File
@@ -421,6 +421,40 @@ impl<'a> ExactSketchCurves<'a> {
(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)
}
}
fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool {
@@ -581,6 +615,14 @@ pub fn lineage_frame(body: &ModelBody, plane: &solidcraft_geom::Plane) -> Option
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)
@@ -599,7 +641,9 @@ impl Document {
/// 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).
/// 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,
@@ -610,17 +654,24 @@ impl Document {
if !names.verified(index) {
return None;
}
let name = crate::naming::strip_piece(names.faces.get(index)?).to_string();
let origin = name.strip_prefix('F')?.split_once(':')?.0.parse::<u64>().ok()?;
let 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 target = crate::config::ConfigFaceTarget {
body: body.definition_name().into(),
origin_feature: origin,
name,
point: crate::apply_point(&inverse, point),
certificate: Some(FaceCertificate::Lineage),
};
self.configuration_face_indices(body, &target).ok()?.contains(&index).then_some(target)
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
@@ -628,8 +679,20 @@ impl Document {
/// follow exact history and lie on one oriented plane.
pub(crate) fn lineage_face_indices(&self, body: &ModelBody, name: &str) -> Result<Vec<usize>> {
let names = crate::naming::names(body);
let mut indices: Vec<usize> =
names.faces.iter().enumerate().filter(|(_, face)| crate::naming::strip_piece(face) == name).map(|(index, _)| index).collect();
// `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();
@@ -638,6 +701,16 @@ impl Document {
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)
}
@@ -658,16 +731,12 @@ impl Document {
Some(_) => return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into())),
}
}
if let (Some(plane), true) = (first, indices.len() > 1) {
let tolerance = 1e-6 * (1. + scale);
let on_plane = |line: &Vec<solidcraft_geom::Vec3>| {
!line.is_empty() && line.iter().all(|p| (*p - plane.origin).dot(plane.normal()).abs() <= tolerance)
};
if body.body.split_lines().is_some_and(|lines| lines.iter().any(on_plane)) {
return Err(DocError::Invalid(
"this face was split on purpose (Split Face): pick an exact face piece's own plane with fixed:true".into(),
));
}
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(())
}
+6 -1
View File
@@ -124,7 +124,12 @@ 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()))?;
+163 -8
View File
@@ -782,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 {
@@ -920,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,
@@ -989,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 {
@@ -1011,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.
@@ -1206,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, .. }
@@ -1337,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",
@@ -1403,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",
@@ -1423,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(),
}
@@ -1437,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()
}
@@ -1460,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(),
}
}
@@ -1483,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);
@@ -1504,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, .. } => {
@@ -1777,6 +1821,10 @@ 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>>,
@@ -1843,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)
}
@@ -1883,6 +1991,7 @@ impl Document {
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(),
@@ -2067,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 {
@@ -2098,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);
@@ -2105,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)
}
@@ -2119,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
+236 -57
View File
@@ -265,7 +265,8 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
/// A constant fillet of `edges`; with [`FilletFit::Reduce`](crate::FilletFit::Reduce), a radius too
/// large for the geometry around them is reduced where it doesn't fit, to the largest that works
/// for each group of edges sharing a face; the radius per edge is returned.
/// for each group of edges sharing a face; the radius per edge is returned. With
/// [`FilletFit::Taper`](crate::FilletFit::Taper) it tapers along each edge it doesn't fit.
fn fillet_fitted(
body: &kernel::Body,
edges: &[Vec3],
@@ -278,6 +279,7 @@ fn fillet_fitted(
Some((used, b)) => Ok((b, used.iter().any(|u| *u < r).then_some(used))),
None => Err(kernel::KernelError::TooLarge(why)),
},
Err(kernel::KernelError::TooLarge(_)) if fit == crate::FilletFit::Taper => kernel::fillet_tapered(body, edges, r).map(|(b, _)| (b, None)),
other => other.map(|b| (b, None)),
}
}
@@ -296,6 +298,30 @@ fn reduced_note(used: &[f64], asked: f64) -> String {
}
}
/// The warning of a fillet that tapered on some of its edges (a taper per edge, None where the
/// radius is constant), naming the smallest radius a taper ends at.
fn tapered_note(tapers: &[Option<kernel::Taper>]) -> Option<String> {
let ends: Vec<f64> = tapers.iter().flatten().flat_map(|t| [t.spline.radii.first(), t.spline.radii.last()]).flatten().copied().collect();
let low = ends.into_iter().reduce(f64::min)?;
let n = tapers.iter().flatten().count();
Some(format!("the radius tapers to the room on {n} of {} edges, down to {} mm", tapers.len(), show_mm(low)))
}
/// Blend `edges` one at a time with `blend`, each blend's lineage over the one before: the
/// result and the lineage of what the last blend started from.
fn one_by_one(input: Traced, edges: &[Vec3], blend: impl Fn(&kernel::Body, Vec3) -> kernel::Result<kernel::Body>) -> Result<(kernel::Body, Traced)> {
let mut b = input;
let mut from = None;
for p in edges {
let next = Traced::step(blend(&b.body, *p)?, &[&b]);
from = Some(std::mem::replace(&mut b, next));
}
match from {
Some(prev) => Ok((b.body, prev)),
None => Err(DocError::Invalid("no edges to fillet".into())),
}
}
/// Add `note` to a feature's warning.
fn append_warning(warning: &mut Option<String>, note: String) {
*warning = Some(match warning.take() {
@@ -322,6 +348,11 @@ impl ModelState {
self.fitted.get(&feature)?.iter().fold(None, |acc, r| Some(acc.map_or((*r, *r), |(lo, hi): (f64, f64)| (lo.min(*r), hi.max(*r)))))
}
/// Each edge fillet `feature` tapered (a point on it) with the radius spline it was given.
pub fn tapered(&self, feature: u64) -> Option<&[(Vec3, kernel::Taper)]> {
self.tapered.get(&feature).map(Vec::as_slice)
}
/// Each edge fillet `feature` blended (a point on it) with the radius it used, when it
/// reduced a radius too large for some of its edges.
pub fn fitted_edges(&self, feature: u64) -> Option<Vec<(Vec3, f64)>> {
@@ -353,6 +384,9 @@ pub struct ModelState {
/// The radius per edge a fillet used when it reduced a radius too large for some of its edges
/// (by feature id, see `FilletFit::Reduce`).
fitted: BTreeMap<u64, Vec<f64>>,
/// The radius spline of each edge a fillet tapered (a point on the edge with it, by feature
/// id, see `FilletFit::Taper`).
tapered: BTreeMap<u64, Vec<(Vec3, kernel::Taper)>>,
}
impl ModelState {
@@ -384,6 +418,16 @@ impl ModelState {
r.to_bits().hash(&mut h);
}
}
for (feature, tapers) in &self.tapered {
feature.hash(&mut h);
for (p, t) in tapers {
let s = &t.spline;
for x in [p.x, p.y, p.z, t.start.x, t.start.y, t.start.z].iter().chain(&s.knots).chain(&s.radii) {
x.to_bits().hash(&mut h);
}
s.degree.hash(&mut h);
}
}
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
h.finish()
@@ -459,6 +503,8 @@ impl ModelState {
pub struct EvalCache {
map: std::collections::HashMap<u64, CachedEval>,
order: std::collections::VecDeque<u64>,
/// What the stored results cost together.
bytes: usize,
pub hits: usize,
}
@@ -469,11 +515,20 @@ struct CachedEval {
boolean_diagnostics: kernel::BooleanDiagnostics,
ms: f64,
state: Arc<ModelState>,
/// What the state costs, worked out once when it was stored (see
/// [`ModelState::retained_bytes`]).
bytes: usize,
}
/// Results kept (oldest dropped first).
const CACHE_ENTRIES: usize = 4096;
/// Bytes of results kept (oldest dropped first). A cached result is as big as the model it
/// holds, so bounding the cache by entry count alone let a long editing session keep every
/// superseded state: the entries nothing else refers to any more are exactly the ones a byte
/// budget drops.
const CACHE_BYTES: usize = 64 << 20;
impl EvalCache {
fn get(&mut self, key: u64) -> Option<CachedEval> {
let r = self.map.get(&key).cloned();
@@ -483,18 +538,43 @@ impl EvalCache {
r
}
fn put(&mut self, key: u64, e: CachedEval) {
if self.map.insert(key, e).is_none() {
self.order.push_back(key);
let bytes = e.bytes;
match self.map.insert(key, e) {
Some(old) => self.bytes = self.bytes.saturating_sub(old.bytes),
None => self.order.push_back(key),
}
while self.order.len() > CACHE_ENTRIES {
if let Some(k) = self.order.pop_front() {
self.map.remove(&k);
self.bytes = self.bytes.saturating_add(bytes);
// One result is always kept, so a single state larger than the budget cannot empty the
// cache and force a full recompute on every undo.
while self.order.len() > CACHE_ENTRIES || (self.bytes > CACHE_BYTES && self.order.len() > 1) {
if let Some(k) = self.order.pop_front()
&& let Some(old) = self.map.remove(&k)
{
self.bytes = self.bytes.saturating_sub(old.bytes);
}
}
}
}
impl SolvedSketch {
/// Rough heap footprint (see [`ModelState::retained_bytes`]).
pub fn retained_bytes(&self) -> usize {
let entities = self.sketch.points.len() + self.sketch.curves.len() + self.sketch.constraints.len() + self.sketch.links.len();
let loops: usize = self.profiles.iter().map(|p| p.region.outer.segs.len() + p.region.holes.iter().map(|h| h.segs.len()).sum::<usize>()).sum();
entities * 192 + loops * 64 + 512
}
}
impl ModelState {
/// Rough heap footprint, for the evaluation cache's byte budget. The bodies' B-rep
/// topology is what makes a kept timeline snapshot expensive, so it dominates; sketches
/// and the rest are counted from their entity counts.
pub fn retained_bytes(&self) -> usize {
let bodies: usize = self.bodies.iter().map(|b| b.body.retained_bytes()).sum();
let sketches: usize = self.sketches.iter().map(|s| s.retained_bytes()).sum();
bodies + sketches + self.sheets.len() * 4096 + self.threads.len() * 192 + self.construct.len() * 192
}
pub fn body(&self, name: &str) -> Option<&ModelBody> {
self.bodies.iter().find(|b| b.name == name)
}
@@ -1048,6 +1128,7 @@ impl Model {
warning: warning.clone(),
boolean_diagnostics: boolean_diagnostics.clone(),
ms,
bytes: state.retained_bytes(),
state: state.clone(),
},
);
@@ -1872,6 +1953,63 @@ fn hole_spots_raw(st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
}
}
/// The cutting tools of a hole feature at every spot, each with its exact geometry when it is a
/// straight cylinder (see [`hole_tools_and_prisms`]).
fn hole_feature_tools(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> Result<Vec<(Traced, Option<more::PrimitivePrism>)>> {
let FeatureKind::Hole { diameter, depth, hole, to, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) };
let (spots, dir) = hole_spots(st, f)?;
if spots.is_empty() || spots.len() > 10_000 {
return Err(DocError::Invalid("a hole needs 1…10000 points".into()));
}
let mut tools = Vec::new();
for at in spots {
let depth = hole_depth_to(st, &at, dir, to, depth)?;
tools.extend(hole_tools_and_prisms(vals, st, &at, dir, diameter, &depth, hole)?);
}
Ok(tools)
}
/// A hole drilled square into sheet metal whose tools are straight cylinders (a plain hole, a
/// counterbore) is machined like the equivalent extrude cut, so the sheet keeps it as a cut-out
/// or pocket of its flat pattern (#560). Returns the sheet bodies it machined; none, with the
/// model as it was, when a tool that meets a sheet can't be kept that way (the ordinary cut then
/// refuses it).
fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option<more::PrimitivePrism>)]) -> Result<Vec<String>> {
if st.sheets.is_empty() {
return Ok(Vec::new());
}
let saved = (st.sheets.clone(), st.bodies.clone());
let mut handled: Vec<String> = Vec::new();
for (tool, prism) in tools {
let tool = &tool.body;
let done = match prism {
Some(prism) => more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut)?,
None => Vec::new(),
};
if done.is_empty() {
let mut meets = false;
for sheet in &st.sheets {
if let Some(mb) = st.body(&sheet.body)
&& kernel::boolean(&mb.body, tool, BoolOp::Intersect)?.is_some()
{
meets = true;
break;
}
}
if meets {
(st.sheets, st.bodies) = saved;
return Ok(Vec::new());
}
}
for name in done {
if !handled.contains(&name) {
handled.push(name);
}
}
}
Ok(handled)
}
/// The cutting tools of one hole at `position`, drilling along `dir`.
#[allow(clippy::too_many_arguments)]
fn hole_tools(
@@ -1882,7 +2020,24 @@ fn hole_tools(
diameter: &str,
depth: &Option<String>,
hole: &HoleKind,
) -> Result<Vec<Body>> {
) -> Result<Vec<Traced>> {
Ok(hole_tools_and_prisms(vals, st, position, dir, diameter, depth, hole)?.into_iter().map(|(tool, _)| tool).collect())
}
/// [`hole_tools`], each with its exact geometry when it is a straight cylinder along the axis
/// (a plain hole, a counterbore, a drill point left in the air past a through hole), so sheet
/// metal can keep it as a flat cut-out or pocket. Each tool's faces are named by the part of the
/// hole it cuts (`bore`, `cbore`, `csink`: `F7:cbore:base` is a counterbore's floor).
#[allow(clippy::too_many_arguments)]
fn hole_tools_and_prisms(
vals: &BTreeMap<String, Value>,
st: &ModelState,
position: &Vec3,
dir: Vec3,
diameter: &str,
depth: &Option<String>,
hole: &HoleKind,
) -> Result<Vec<(Traced, Option<more::PrimitivePrism>)>> {
let r = val(vals, diameter, Kind::Length)? / 2.0;
if !(r > 1e-6) {
return Err(DocError::Invalid("hole diameter must be positive".into()));
@@ -1905,12 +2060,15 @@ fn hole_tools(
seam.set(seam.get() + 0.613);
Region2 { outer: solidcraft_geom::Loop2::circle_from(Vec2::ZERO, rad, seam.get()), holes: vec![] }
};
let cyl = |rad: f64, lo: f64, hi: f64| -> Result<Body> {
kernel::extrude(&plane, &[disc(rad)], lo, hi)?.pop().ok_or_else(|| DocError::Invalid("hole tool".into()))
let cyl = |rad: f64, lo: f64, hi: f64, part: &str| -> Result<(Traced, Option<more::PrimitivePrism>)> {
let tool = kernel::extrude(&plane, &[disc(rad)], lo, hi)?.pop().ok_or_else(|| DocError::Invalid("hole tool".into()))?;
let axis = plane.normal();
let prism = more::PrimitivePrism::Cylinder { base: *position + axis * lo, axis, radius: rad, height: hi - lo };
Ok((crate::lineage::part_tool(tool, part), Some(prism)))
};
let mut tools = Vec::new();
match hole {
HoleKind::Simple => tools.push(cyl(r, bottom, top)?),
HoleKind::Simple => tools.push(cyl(r, bottom, top, "bore")?),
HoleKind::Drilled { tip_angle } => {
let half = val(vals, tip_angle, Kind::Angle)? / 2.0;
// A hole exactly as deep as the material: the point is in the air past the exit
@@ -1927,9 +2085,13 @@ fn hole_tools(
let rp = Plane::new(*position, side, up).ok_or_else(|| DocError::Invalid("hole axis".into()))?;
let pts = [Vec2::new(0.0, top), Vec2::new(r, top), Vec2::new(r, bottom), Vec2::new(0.0, bottom - tip)];
let region = Region2 { outer: solidcraft_geom::Loop2::polygon(&pts), holes: vec![] };
tools.extend(kernel::revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?);
tools.extend(
kernel::revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?
.into_iter()
.map(|tool| (crate::lineage::part_tool(tool, "bore"), None)),
);
} else {
tools.push(cyl(r, bottom, top)?);
tools.push(cyl(r, bottom, top, "bore")?);
}
}
HoleKind::Counterbore { cb_diameter, cb_depth } => {
@@ -1938,8 +2100,8 @@ fn hole_tools(
return Err(DocError::Invalid("the counterbore must be wider than the hole and shallower than it".into()));
}
// Wide and shallow first: the narrow hole then crosses the pocket floor.
tools.push(cyl(rc, -dc, top)?);
tools.push(cyl(r, bottom, top)?);
tools.push(cyl(rc, -dc, top, "cbore")?);
tools.push(cyl(r, bottom, top, "bore")?);
}
HoleKind::Countersink { cs_diameter, cs_angle } => {
let (rc, half) = (val(vals, cs_diameter, Kind::Length)? / 2.0, val(vals, cs_angle, Kind::Angle)? / 2.0);
@@ -1951,8 +2113,8 @@ fn hole_tools(
let r_end = r * 0.9;
let len = (r_top - r_end) / half.tan();
let top_plane = plane.offset(top);
tools.push(kernel::extrude_tapered(&top_plane, &disc(r_top), len, -1.0, -half)?);
tools.push(cyl(r, bottom, top)?);
tools.push((crate::lineage::part_tool(kernel::extrude_tapered(&top_plane, &disc(r_top), len, -1.0, -half)?, "csink"), None));
tools.push(cyl(r, bottom, top, "bore")?);
}
}
Ok(tools)
@@ -2104,10 +2266,6 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc
}
/// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state.
fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -> Result<Vec<Body>> {
Ok(feature_tools_with_constructor(doc, vals, f, st, &mut None)?.into_iter().map(|t| t.body).collect())
}
fn feature_tools_with_constructor(
doc: &Document,
vals: &BTreeMap<String, Value>,
@@ -2181,12 +2339,7 @@ fn feature_tools_with_constructor(
};
// To Object gives a signed distance from the start: one side only.
let direction = if extent.to.is_some() { crate::Direction::Positive } else { extent.direction };
let (a, b) = match (&extent.distance2, direction) {
(Some(d2), _) => (-val(vals, d2, Kind::Length)?, d),
(None, crate::Direction::Positive) => (0.0, d),
(None, crate::Direction::Negative) => (-d, 0.0),
(None, crate::Direction::Symmetric) => (-d, d),
};
let (a, b) = extent_sides(vals, extent, d, direction)?;
// A negative distance flips the side.
let (mut lo, mut hi) = if a <= b { (a, b) } else { (b, a) };
// To a curved (or slanted) face: run right through the target body, then keep what
@@ -2417,7 +2570,8 @@ fn feature_tools_with_constructor(
let id = pt.point.as_deref().unwrap_or_default();
let q = ps.sketch.points.iter().find(|p| p.id == id).ok_or_else(|| DocError::Unknown(format!("point `{id}` in {}", ps.name)))?;
let body = kernel::loft_to_point(&ss.plane, &r.outer, ps.plane.to_world(q.pos))?;
Ok(vec![crate::lineage::ProfileRoles::swept(None, None).tool(body, None)])
// Its sides are named by the profile's curves, its base `start`.
Ok(vec![crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None)])
}
FeatureKind::Coil { base, axis, diameter, pitch, turns, section_size, section, position, start_angle, clockwise, .. } => {
let base = &point(vals, base)?;
@@ -2460,6 +2614,7 @@ fn feature_tools_with_constructor(
}
FeatureKind::Loft { sections, .. } => {
let mut secs = Vec::new();
let mut sketches = Vec::new();
for sec in sections {
let ss = st
.sketch(sec.sketch)
@@ -2467,11 +2622,13 @@ fn feature_tools_with_constructor(
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, &sec.profiles)?);
let [r] = &regions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
secs.push((ss.plane, r.outer.clone()));
sketches.push(ss);
}
// Its caps are the first and last sections; which section's curve a side follows is
// not recorded, so the sides are named from geometry.
// Its caps are the first and last sections, its sides named by the curves they run
// between.
let body = kernel::loft(&secs)?;
Ok(vec![crate::lineage::ProfileRoles::swept(None, None).tool(body, None)])
let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() };
Ok(vec![roles.tool(body)])
}
FeatureKind::Sweep { sketch, profiles, path_sketch, path, .. } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
@@ -2536,18 +2693,7 @@ fn feature_tools_with_constructor(
FeatureKind::Torus { center, major, minor, .. } => {
Ok(unnamed(vec![kernel::torus(point(vals, center)?, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?]))
}
FeatureKind::Hole { diameter, depth, hole, to, .. } => {
let (spots, dir) = hole_spots(st, f)?;
if spots.is_empty() || spots.len() > 10_000 {
return Err(DocError::Invalid("a hole needs 1…10000 points".into()));
}
let mut tools = Vec::new();
for at in spots {
let depth = hole_depth_to(st, &at, dir, to, depth)?;
tools.extend(hole_tools(vals, st, &at, dir, diameter, &depth, hole)?);
}
Ok(unnamed(tools))
}
FeatureKind::Hole { .. } => Ok(hole_feature_tools(vals, st, f)?.into_iter().map(|(tool, _)| tool).collect()),
_ => Err(DocError::Invalid(format!("{} cannot be patterned or mirrored yet", f.name))),
}
}
@@ -2741,6 +2887,17 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<k
.collect())
}
/// Where an extrude of distance `d` starts and ends along the sketch normal, before any start
/// offset: one side, the other, both (symmetric), or a second distance on the other side.
pub(crate) fn extent_sides(vals: &BTreeMap<String, Value>, extent: &crate::Extent, d: f64, direction: crate::Direction) -> Result<(f64, f64)> {
Ok(match (&extent.distance2, direction) {
(Some(d2), _) => (-val(vals, d2, Kind::Length)?, d),
(None, crate::Direction::Positive) => (0.0, d),
(None, crate::Direction::Negative) => (-d, 0.0),
(None, crate::Direction::Symmetric) => (-d, d),
})
}
/// Operation and targets of a feature that makes tools.
fn feature_op(f: &Feature) -> (Operation, Vec<String>) {
match &f.kind {
@@ -2997,7 +3154,8 @@ fn replay(
},
_ => kernel::fillet(&mb.body, &moved, r)?,
};
let nb = Traced::step(nb, &[&mb.traced()]);
// A copy blends edges the pattern's own copies made: named after the body so far.
let nb = mb.traced().then(nb);
if let Some(slot) = st.bodies.get_mut(ti) {
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
}
@@ -3011,13 +3169,18 @@ fn replay(
// a blind hole's depth counts from the face at the copy, not from the bottom of the
// original hole under the original spot.
let (spots, dir) = hole_spots_raw(st, src)?;
for m in mats {
for (k, m) in mats.iter().enumerate() {
let d = crate::apply_vector(m, dir).normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
for p in &spots {
let at = hole_on_surface(st, crate::apply_point(m, *p), d);
let to = to.map(|t| crate::apply_point(m, t));
let depth = hole_depth_to(st, &at, d, &to, depth)?;
copies.extend(hole_tools(vals, st, &at, d, diameter, &depth, hole)?.into_iter().map(Traced::unnamed));
// Each copy's faces are named as the hole's, with the instance.
copies.extend(
hole_tools(vals, st, &at, d, diameter, &depth, hole)?
.into_iter()
.map(|t| Traced { lineage: t.lineage.map(|l| l.copied(src.id, k + 1)), body: t.body }),
);
}
}
} else {
@@ -3352,7 +3515,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::Stitch { .. }
| FeatureKind::Thicken { .. }
| FeatureKind::SurfaceTrim { .. }
| FeatureKind::SurfaceExtend { .. } => surface::eval(doc, vals, f, st),
| FeatureKind::SurfaceExtend { .. }
| FeatureKind::SurfaceExtrude { .. } => surface::eval(doc, vals, f, st),
FeatureKind::Emboss { .. }
| FeatureKind::Rib { .. }
| FeatureKind::ReplaceFace { .. }
@@ -3419,8 +3583,14 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
FeatureKind::Hole { thread, diameter, .. } => {
let (spots, dir) = hole_spots(st, f)?;
let tools = feature_tools(doc, vals, f, st)?;
apply_op(st, f, tools, Operation::Cut, &[])?;
let tools = hole_feature_tools(vals, st, f)?;
let handled = sheet_hole_cut(st, f, &tools)?;
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
if handled.is_empty() {
apply_traced(st, f, tools, Operation::Cut, &[])?;
} else {
apply_beside_sheet_cut(st, f, tools, Operation::Cut, &[], &handled)?;
}
if let Some(d) = thread {
// A point on each hole's wall, a little below the entry.
let r = val(vals, diameter, Kind::Length)? / 2.0;
@@ -3752,17 +3922,26 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
for (t, e) in mid {
pts.push((*t, val(vals, e, Kind::Length)?));
}
// One edge at a time: each blend's lineage over the one before.
let mut b = input;
let mut from = None;
for p in &edges {
let next = Traced::step(kernel::fillet_variable_points(&b.body, *p, *start, &pts, *smooth)?, &[&b]);
from = Some(std::mem::replace(&mut b, next));
one_by_one(input, &edges, |b, p| kernel::fillet_variable_points(b, p, *start, &pts, *smooth))?
}
FeatureKind::Fillet { style: crate::FilletStyle::Spline { start, knots, radii }, .. } => {
let mut values = vec![r];
for e in radii {
values.push(val(vals, e, Kind::Length)?);
}
match from {
Some(prev) => (b.body, prev),
None => return Err(DocError::Invalid("no edges to fillet".into())),
let law = crate::FilletStyle::spline(knots, values);
one_by_one(input, &edges, |b, p| kernel::fillet_variable_spline(b, p, *start, &law))?
}
FeatureKind::Fillet { fit: crate::FilletFit::Taper, .. } => {
let (b, tapers) = kernel::fillet_tapered(&mb.body, &edges, r)?;
if let Some(note) = tapered_note(&tapers) {
append_warning(warning, note);
}
let tapered: Vec<(Vec3, kernel::Taper)> = edges.iter().copied().zip(tapers).filter_map(|(p, t)| Some((p, t?))).collect();
if !tapered.is_empty() {
st.tapered.insert(f.id, tapered);
}
(b, input)
}
FeatureKind::Fillet { fit, .. } => {
let (b, used) = fillet_fitted(&mb.body, &edges, r, *fit)?;
+66 -30
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()?;
@@ -340,20 +351,17 @@ fn on_sheet_material(st: &ModelState, i: usize, p: Vec3) -> bool {
offsets.iter().any(|&dx| offsets.iter().any(|&dy| offsets.iter().any(|&dz| mesh.contains(p + Vec3::new(dx, dy, dz)))))
}
/// A region mirrored in the sketch x axis (y → −y); curves other than lines and arcs as polylines.
/// 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()
@@ -549,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);
@@ -568,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))),
}
@@ -718,6 +749,7 @@ pub(super) fn sheet_primitive_cut(f: &Feature, st: &mut ModelState, tool: &Body,
}
/// 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 },
@@ -1117,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) {
@@ -1156,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 {
@@ -1182,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(())
}
@@ -1215,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
@@ -1234,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 } => {
@@ -1261,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)?;
@@ -1310,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"));
}
}
+68 -2
View File
@@ -73,13 +73,44 @@
//! 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 = 16;
pub const FORMAT: u32 = 17;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -96,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; 15] = [
const JSON_STEPS: [JsonStep; 16] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -112,6 +143,7 @@ const JSON_STEPS: [JsonStep; 15] = [
v13_to_v14,
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
@@ -345,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)));
+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 {
+11 -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(())
}
@@ -317,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));
}
}
+19 -3
View File
@@ -114,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));
@@ -126,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;
@@ -135,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;
@@ -753,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);
+7 -2
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));
@@ -466,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 {
+20 -1
View File
@@ -10,11 +10,19 @@ use std::sync::{
};
/// Shared between the worker evaluating a design and the window showing its progress.
#[derive(Debug, Default)]
#[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 {
@@ -25,6 +33,11 @@ impl Progress {
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))
@@ -85,6 +98,12 @@ impl Evaluation {
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()
})
+96 -19
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 {
@@ -273,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
@@ -282,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();
@@ -305,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>> {
@@ -392,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,
})
}
@@ -519,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| {
@@ -528,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) {
+1166 -104
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();
@@ -153,3 +182,36 @@ fn ribs_flush_with_a_window_change_nothing_away_from_them() {
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}");
}
+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}");
}
}
@@ -241,7 +241,7 @@ fn user_defined_material_survives_save_and_reopen() {
run(&mut s, "material.create", json!({"name": "Leather", "density_g_cm3": 0.86, "appearance": "Rubber - Black"}));
run(&mut s, "material.assign", json!({"bodies": ["Tab"], "material": "Leather"}));
run(&mut s, "file.save", json!({"path": path}));
assert!(s.doc.to_json().contains("\"solidcraft/16\""));
assert!(s.doc.to_json().contains("\"solidcraft/17\""));
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
+386 -3
View File
@@ -25,11 +25,28 @@ pub static COMMANDS: &[CommandSpec] = &[
.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 (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)
@@ -187,6 +204,302 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
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") {
@@ -342,6 +655,12 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
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)?;
@@ -417,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);
@@ -429,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"))?;
+122 -45
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: 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)"),
.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; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place)"),
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();
@@ -394,6 +394,10 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
(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()),
};
let face_names = {
@@ -495,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() {
@@ -528,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> {
@@ -559,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] }))
}
@@ -612,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>> {
@@ -664,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)]
}
@@ -679,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")?;
@@ -690,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)? })
@@ -754,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")}));
@@ -763,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}))
}
@@ -799,15 +846,41 @@ 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 radii the fillet used, when `fit: reduce` reduced it.
@@ -1591,3 +1664,7 @@ 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
+163
View File
@@ -164,3 +164,166 @@ fn a_piece_of_a_face_split_on_purpose_still_needs_a_fixed_plane() {
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);
}
+32
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;
@@ -38,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;
@@ -53,6 +65,7 @@ mod sketch_modify;
mod sketch_project;
mod surface;
mod surface_analysis;
mod view_navigation;
use serde::Serialize;
use serde_json::Value;
@@ -87,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 {
@@ -104,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);
@@ -159,6 +180,7 @@ impl CommandSpec {
params: self.params,
enabled: e.is_ok(),
disabled_reason: e.err(),
planned: self.planned,
}
}
}
@@ -174,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> {
@@ -184,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();
@@ -219,7 +249,9 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
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:?}");
}
+44 -7
View File
@@ -260,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:
@@ -305,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);
}
}
+23 -4
View File
@@ -34,11 +34,17 @@ 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); \
corner_relief_shape?: notch|round|rectangular (what is cut where two bends meet at a corner of the base, `corner_relief` across; default notch); \
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"),
@@ -272,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> {
@@ -309,7 +324,7 @@ fn rules(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(shape) = p.get("corner_relief_shape").filter(|v| !v.is_null()) {
let shape = shape.as_str().and_then(solidcraft_doc::sheet::ReliefShape::parse);
rule.corner_relief_shape = shape.ok_or_else(|| bad(cmd, "`corner_relief_shape` must be notch, round or rectangular"))?.name().into();
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()))?;
@@ -599,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());
}
+363 -2
View File
@@ -1029,6 +1029,152 @@ fn the_bend_table_and_the_dxf_follow_what_flat_cuts_leave_of_a_bend() {
assert!(close(&rows(&mut s), &[(1, 40.0, 100.0, 140.0), (1, 40.0, 160.0, 200.0)]), "{:?}", rows(&mut s));
}
/// The flat pattern's area: its outline loops less its cut-outs.
fn flat_area(f: &Value) -> f64 {
let loops = |k: &str| -> f64 {
let all = f[k].as_array().cloned().unwrap_or_default();
all.iter()
.map(|l| {
let pts: Vec<(f64, f64)> = l
.as_array()
.cloned()
.unwrap_or_default()
.iter()
.map(|q| (q[0].as_f64().unwrap_or(f64::NAN), q[1].as_f64().unwrap_or(f64::NAN)))
.collect();
polygon_area(&pts)
})
.sum()
};
loops("outline") - loops("cutouts")
}
#[test]
fn an_edge_flange_made_after_a_cut_runs_along_the_edge_the_cut_left() {
// A 200 x 100 plate, its bottom left 100 x 60 cut away, then a flange picked on what is left of
// the bottom edge (x 100 … 200): it used to span the whole original edge, x 0 … 200, its left
// half standing beside nothing.
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "2 mm", "active": true}));
base(&mut s, 200.0, 100.0);
rectangle_tool(&mut s, "Corner", [-10.0, -10.0], [100.0, 60.0]);
run(&mut s, "solid.extrude", json!({"sketch": "Corner", "through_all": true, "direction": "symmetric", "operation": "cut"}));
assert!((volume(&mut s) - 14_000.0 * 2.0).abs() < 1e-6);
run(&mut s, "sheet.flange", json!({"edges": [[150, 0, 2]], "height": 40, "angle": 90, "radius": 1, "position": "outside"}));
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
let f = flat(&mut s);
let bends = f["bends"].as_array().cloned().unwrap_or_default();
assert_eq!(bends.len(), 1, "{f}");
let x = |k: &str| bends[0][k][0].as_f64().unwrap_or(f64::NAN);
assert!(
(x("start") - 100.0).abs() < 1e-9 && (x("end") - 200.0).abs() < 1e-9 && (bends[0]["length"].as_f64().unwrap_or(0.0) - 100.0).abs() < 1e-9,
"{f}"
);
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}");
// The flat pattern: the plate less the cut, and the flange (π of bend, 37 of panel) on 100 mm.
assert!((flat_area(&f) - (14_000.0 + 100.0 * (PI + 37.0))).abs() < 1e-6, "{}", flat_area(&f));
// Folded: the bend keeps its volume (K 0.5); the flange stands on x 100 … 200 only.
let want = (14_000.0 + 100.0 * (PI + 37.0)) * 2.0;
assert!((volume(&mut s) - want).abs() < 1e-3 * want, "{} vs {want}", volume(&mut s));
assert!(contains(&s, [150.0, -2.0, 20.0]) && !contains(&s, [50.0, -2.0, 20.0]));
let state = s.model.state();
let body = &state.bodies[0].body;
assert!(body.validity().is_empty() && body.shell_count() == 1);
for command in ["sheet.unfold", "sheet.refold"] {
run(&mut s, command, json!({}));
assert!((volume(&mut s) - want).abs() < 1e-3 * want, "{command}: {}", volume(&mut s));
}
}
/// A 200 x 100 plate (2 mm, K 0.5) with `cut`, a rectangle between two corners, cut through it.
fn plate_with_a_cut(s: &mut Session, cut: [[f64; 2]; 2]) {
run(s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
base(s, 200.0, 100.0);
rectangle_tool(s, "Cut", cut[0], cut[1]);
run(s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "direction": "symmetric", "operation": "cut"}));
}
#[test]
fn edge_flanges_on_either_side_of_a_notch_span_the_side_they_were_picked_on() {
// A notch 40 wide and 30 deep in the middle of the bottom edge leaves x 0 … 80 and 120 … 200 of
// it. A 90° flange 40 high set inside (setback 3, BA π, panel 37) spans the side it was picked
// on and takes its setback off that side alone; picked on both sides, each has its own.
for (edges, sides) in
[(json!([[160, 0, 2]]), [false, true]), (json!([[40, 0, 2]]), [true, false]), (json!([[40, 0, 2], [160, 0, 2]]), [true, true])]
{
let mut s = Session::default();
plate_with_a_cut(&mut s, [[80.0, -10.0], [120.0, 30.0]]);
run(&mut s, "sheet.flange", json!({"edges": edges, "height": 40, "angle": 90}));
assert!(no_errors_or_warnings(&s), "{edges}: {:?}", s.model.results);
let f = flat(&mut s);
let want: Vec<(f64, f64)> = [(0.0, 80.0), (120.0, 200.0)].into_iter().zip(sides).filter_map(|(span, on)| on.then_some(span)).collect();
let bends: Vec<(f64, f64)> = f["bends"]
.as_array()
.cloned()
.unwrap_or_default()
.iter()
.map(|b| (b["start"][0].as_f64().unwrap_or(f64::NAN), b["end"][0].as_f64().unwrap_or(f64::NAN)))
.collect();
assert!(
bends.len() == want.len() && bends.iter().zip(&want).all(|(b, w)| (b.0 - w.0).abs() < 1e-9 && (b.1 - w.1).abs() < 1e-9),
"{edges}: {f}"
);
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{edges}: {f}");
// The plate less the notch, and for each flange 80 × (π + 37) less the 80 × 3 it set back.
let area = 20_000.0 - 40.0 * 30.0 + want.len() as f64 * 80.0 * (PI + 37.0 - 3.0);
assert!((flat_area(&f) - area).abs() < 1e-6, "{edges}: {}", flat_area(&f));
assert!(rel(volume(&mut s), area * 2.0) < 1e-4, "{edges}: {} vs {}", volume(&mut s), area * 2.0);
// A flange stands with its outer face on the edge (y 0 … 2); a side without one keeps the
// base up to the edge, which the bend's round outer corner leaves free.
for (x, on) in [40.0, 160.0].into_iter().zip(sides) {
assert_eq!(contains(&s, [x, 1.0, 20.0]), on, "{edges}: a flange at x {x}");
assert_eq!(contains(&s, [x, 0.2, 0.2]), !on, "{edges}: the base's own edge at x {x}");
}
let state = s.model.state();
let body = &state.bodies[0].body;
assert!(body.validity().is_empty() && body.shell_count() == 1, "{edges}");
for command in ["sheet.unfold", "sheet.refold"] {
run(&mut s, command, json!({}));
assert!(rel(volume(&mut s), area * 2.0) < 1e-4, "{edges}, {command}: {}", volume(&mut s));
}
}
// An edge that a cut has taken whole is refused.
let mut s = Session::default();
plate_with_a_cut(&mut s, [[-10.0, -10.0], [210.0, 30.0]]);
let e = s.execute("sheet.flange", &json!({"edges": [[100, 0, 2]], "height": 40})).map(|_| ()).unwrap_err().to_string();
assert!(e.contains("a cut-out has taken that edge away"), "{e}");
}
#[test]
fn an_edge_flange_on_a_flange_runs_along_what_a_flat_cut_left_of_its_far_edge() {
// A 40 high wall on the plate's bottom edge (outside: BA π, panel 37), its far edge at
// y = -(π + 37) in the flat. Unfolded, a cut takes x 0 … 60 of that edge, up to y = -30. A
// 15 high flange picked on the rest of the edge (the wall's inner face is y = -1, its top
// z = 40) spans x 60 … 200.
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
base(&mut s, 200.0, 100.0);
run(&mut s, "sheet.flange", json!({"edges": [[100, 0, 2]], "height": 40, "angle": 90, "position": "outside"}));
run(&mut s, "sheet.unfold", json!({}));
rectangle_tool(&mut s, "Corner", [-10.0, -50.0], [60.0, -30.0]);
run(&mut s, "solid.extrude", json!({"sketch": "Corner", "through_all": true, "direction": "symmetric", "operation": "cut"}));
run(&mut s, "sheet.refold", json!({}));
run(&mut s, "sheet.flange", json!({"edges": [[130, -1, 40]], "height": 15, "angle": 90, "position": "outside"}));
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
let f = flat(&mut s);
let bends = f["bends"].as_array().cloned().unwrap_or_default();
let spans: Vec<(f64, f64)> =
bends.iter().map(|b| (b["start"][0].as_f64().unwrap_or(f64::NAN), b["end"][0].as_f64().unwrap_or(f64::NAN))).collect();
let close = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9;
assert!(spans.len() == 2 && close(spans[0], (0.0, 200.0)) && close(spans[1], (60.0, 200.0)), "{f}");
// The plate, the wall less the cut's 60 × (π + 7), and 140 × (π + 12) of the flange on it.
let area = 20_000.0 + 200.0 * (PI + 37.0) - 60.0 * (PI + 7.0) + 140.0 * (PI + 12.0);
assert!((flat_area(&f) - area).abs() < 1e-6, "{}", flat_area(&f));
assert!(rel(volume(&mut s), area * 2.0) < 1e-4, "{} vs {}", volume(&mut s), area * 2.0);
// It lies over the plate from the wall (z 41 … 43, y 0 … 12), on x 60 … 200 only.
assert!(contains(&s, [130.0, 6.0, 42.0]) && !contains(&s, [30.0, 6.0, 42.0]));
}
#[test]
fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() {
let mut s = Session::default();
@@ -1187,6 +1333,75 @@ fn inherited_round_hole_survives_flange_and_unfold_in_flipped_frames() {
}
}
fn faces(s: &mut Session) -> (u64, u64) {
let body = run(s, "inspect.measure", json!({}))["bodies"][0].clone();
(body["faces"].as_u64().unwrap(), body["face_types"]["cylinder"].as_u64().unwrap_or(0))
}
#[test]
fn elliptic_cut_out_stays_exact_in_the_flat_pattern_and_follows_arcs_when_folded() {
use solidcraft_geom::Seg2;
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
let (uncut, blank) = (volume(&mut s), s.model.state().sheets[0].flat_regions().unwrap()[0].area());
let (plain_faces, plain_cylinders) = faces(&mut s);
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
run(&mut s, "sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"}));
// The ellipse takes π·15·8 of the 2.5 mm sheet (a body's volume is measured on its mesh).
let cut = PI * 15.0 * 8.0;
let folded = volume(&mut s);
assert!((uncut - folded - cut * 2.5).abs() < 0.5, "{}", uncut - folded);
// The flat pattern holds the ellipse itself, so its area is the blank less π·15·8.
let regions = s.model.state().sheets[0].flat_regions().unwrap();
let hole = &regions[0].holes[0];
assert!(hole.segs.len() == 4 && hole.segs.iter().all(|x| matches!(x, Seg2::Conic { .. })), "{} segments", hole.segs.len());
assert!((regions[0].area() - (blank - cut)).abs() < 1e-9, "{}", regions[0].area());
// The flat pattern JSON lists it as those four curves, one `polyline` each, with every
// point on the ellipse ((x − 40)/15)² + ((y − 30)/8)² = 1.
let shapes = flat(&mut s)["cutout_shapes"].as_array().cloned().unwrap_or_default();
let curves: Vec<Vec<Value>> = shapes[0]["segments"].as_array().into_iter().flatten().filter_map(|x| x["polyline"].as_array().cloned()).collect();
assert!(shapes.len() == 1 && curves.len() == 4 && shapes[0]["segments"].as_array().is_some_and(|a| a.len() == 4), "{} curves", curves.len());
let off = |p: &Value| ((p[0].as_f64().unwrap_or(0.0) - 40.0) / 15.0).powi(2) + ((p[1].as_f64().unwrap_or(0.0) - 30.0) / 8.0).powi(2) - 1.0;
assert!(curves.iter().all(|c| c.len() > 2 && c.iter().all(|p| off(p).abs() < 1e-9)));
// Folded, the cut-out's wall is cylinders over tangent arcs: fewer than half the 264
// chords of 0.001 mm (four quarters of √(2·17/0.008) = 66 each).
let (cut_faces, cut_cylinders) = faces(&mut s);
let wall = cut_faces - plain_faces;
assert!(wall < 132 && cut_cylinders - plain_cylinders + 4 >= wall, "{wall} wall faces, {} cylinders", cut_cylinders - plain_cylinders);
// Unfolded, it is the four conic faces of the ellipse.
run(&mut s, "sheet.unfold", json!({}));
assert!((volume(&mut s) - (blank - cut) * 2.5).abs() < 0.5);
assert_eq!(faces(&mut s).0, regions[0].outer.segs.len() as u64 + 4 + 2);
run(&mut s, "sheet.refold", json!({}));
assert!((volume(&mut s) - folded).abs() < 0.1);
}
#[test]
fn flipped_base_flange_keeps_its_elliptic_outline() {
for flip in [false, true] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Oval"}));
run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [25, 0], "minor_radius": 12}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Oval", "flip": flip}));
let regions = s.model.state().sheets[0].flat_regions().unwrap();
let outline = &regions[0].outer.segs;
assert!(
outline.len() == 4 && outline.iter().all(|x| matches!(x, solidcraft_geom::Seg2::Conic { .. })),
"flip {flip}: {} segments",
outline.len()
);
// π·25·12 of the 2.5 mm sheet: exactly in the flat pattern; within 0.5 mm³ in the body,
// which follows the outline by arcs and is measured on its mesh (a 64-sided outline
// would be 3.8 mm³ short).
assert!((regions[0].area() - PI * 300.0).abs() < 1e-9, "flip {flip}: {}", regions[0].area());
assert!((volume(&mut s) - PI * 300.0 * 2.5).abs() < 0.5, "flip {flip}: {}", volume(&mut s));
}
}
#[test]
fn crossing_only_disconnecting_cut_preserves_all_material_states() {
let mut s = Session::default();
@@ -2263,6 +2478,76 @@ fn corner_reliefs_of_the_rule_reach_the_part_the_flat_pattern_and_the_dxf() {
}
}
#[test]
fn a_tear_of_the_rule_reaches_the_part_the_flat_pattern_and_the_dxf() {
use std::f64::consts::SQRT_2;
// The tray at 90° and 72° (see the doc tests): its bends meet at (s0, s0), s0 = 3·tan(θ/2), and
// are BA = 2θ wide in the flat. The flange on y = 0 is closed to x_end(w) = g/√2 + T·sinθ − w·cosθ.
// Torn, its bend's end is cut from (s0, s0) straight to (x_end(s0), s0 − BA), where the flange's
// end starts: grow = s0 − x_end(s0) = sinθ − 0.5/√2 past the corner, instead of square across the
// bend and back along the flange's edge (the notch's step).
for deg in [90.0f64, 72.0] {
let th = deg.to_radians();
let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0);
let x_end = |w: f64| 0.5 / SQRT_2 + 2.0 * th.sin() - w * th.cos();
let grow = s0 - x_end(s0);
assert!((grow - (th.sin() - 0.5 / SQRT_2)).abs() < 1e-12);
let points = |f: &Value| -> Vec<(f64, f64)> {
let line = f["outline"][0].as_array();
line.map(|l| l.iter().map(|p| (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN))).collect()).unwrap_or_default()
};
let mut notch = Session::default();
alu_tray(&mut notch, deg, json!({}));
let (v0, stepped) = (volume(&mut notch), points(&flat(&mut notch)));
run(&mut notch, "sheet.unfold", json!({}));
let flat0 = volume(&mut notch);
let mut s = Session::default();
alu_tray(&mut s, deg, json!({"corner_relief_shape": "tear"}));
assert!(s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{deg}: {:?}", s.model.results);
let listed = run(&mut s, "sheet.rules", json!({}));
assert_eq!(listed["rules"][0]["rule"]["corner_relief_shape"], "tear");
assert_eq!(listed["rules"][0]["values"]["corner_relief_shape"], "tear");
// The flat pattern, at the corner at the origin: the flange's end, the cut, the corner of the
// bends, and the same on the flange on x = 0. The step is gone: a corner less at each of the
// eight bend ends.
let f = flat(&mut s);
let outline = points(&f);
let at = |pts: &[(f64, f64)], x: f64, y: f64| pts.iter().any(|p| (p.0 - x).abs() < 1e-6 && (p.1 - y).abs() < 1e-6);
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{deg}: {f}");
for (x, y) in [(x_end(38.6), s0 - ba - (38.6 - s0)), (x_end(s0), s0 - ba), (s0, s0)] {
assert!(at(&outline, x, y) && at(&outline, y, x), "{deg}: ({x}, {y}) not in {outline:?}");
}
assert!(at(&stepped, s0, s0 - ba) && !at(&outline, s0, s0 - ba) && !at(&outline, s0 - ba, s0), "{deg}");
assert_eq!(outline.len() + 8, stepped.len(), "{deg}");
// The bend table: every centre line runs between the middles of its bend's two ends.
let lengths: Vec<f64> =
f["bends"].as_array().map(|b| b.iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect()).unwrap_or_default();
for (got, edge) in lengths.iter().zip([120.0, 90.0, 120.0, 90.0]) {
assert!(lengths.len() == 4 && (got - (edge - 2.0 * s0 + grow)).abs() < 1e-6, "{deg}: {lengths:?}");
}
// The folded part gains what the flat pattern gains: the triangle BA·grow/2 at each of the
// eight bend ends (K = 0.5 keeps the volume of a bend).
let (v, want) = (volume(&mut s), 8.0 * ba * grow / 2.0 * 2.0);
assert!((v - v0 - want).abs() < 0.05, "{deg}: {v} - {v0} vs {want}");
// The DXF: the outline in straight lines, each cut one of them, and four bend lines.
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
assert_eq!(dxf.matches("0\nLINE\n").count(), outline.len() + 4, "{deg}");
assert!(dxf.contains(&format!("{:.6}\n20\n{:.6}\n", x_end(s0), s0 - ba)), "{deg}");
// Unfold and refold keep it, and a cut made folded still goes through: a Ø20 hole in the base.
run(&mut s, "sheet.unfold", json!({}));
assert!((volume(&mut s) - flat0 - want).abs() < 1e-6, "{deg}: unfolded {} - {flat0} vs {want}", volume(&mut s));
run(&mut s, "sheet.refold", json!({}));
assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{deg}");
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
run(&mut s, "sketch.circle.center", json!({"center": [60, 45], "diameter": 20}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 10, "operation": "cut", "through_all": true}));
assert!((v - volume(&mut s) - PI * 100.0 * 2.0).abs() < 0.5, "{deg}: {v} - {}", volume(&mut s));
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!((volume(&mut reopened) - volume(&mut s)).abs() < 1e-6, "{deg}");
}
}
#[test]
fn the_corner_relief_shape_is_a_rule_setting_checked_and_undone() {
let mut s = Session::default();
@@ -2270,10 +2555,10 @@ fn the_corner_relief_shape_is_a_rule_setting_checked_and_undone() {
let shape = |s: &mut Session| run(s, "sheet.rules", json!({}))["rules"][0]["rule"]["corner_relief_shape"].clone();
let v0 = volume(&mut s);
assert_eq!(shape(&mut s), "notch");
// Only the three names are shapes; a refused one changes nothing.
// Only the four names are shapes; a refused one changes nothing.
for wrong in [json!("star"), json!("square"), json!(""), json!(2)] {
let e = s.execute("sheet.manage_rules", &json!({"name": "AL2", "corner_relief_shape": wrong})).map(|_| ()).unwrap_err().to_string();
assert!(e.contains("`corner_relief_shape` must be notch, round or rectangular"), "{e}");
assert!(e.contains("`corner_relief_shape` must be notch, round, rectangular or tear"), "{e}");
}
assert!(shape(&mut s) == "notch" && volume(&mut s) == v0);
// Changing the rule changes the parts made with it; other settings leave the shape alone.
@@ -3023,3 +3308,79 @@ fn fold_fixed_point_must_touch_the_sheet_but_any_face_will_do() {
assert!(fold["warning"].as_str().is_some_and(|w| w.contains("fixed point is not on the sheet")), "{fold}");
assert!((bbox(&mut f)["max"][0].as_f64().unwrap_or(f64::NAN) - x).abs() < 1e-6, "the right side stays");
}
/// The middle of the largest triangle of each flat face of the first body: well inside it.
fn flat_face_points(s: &Session) -> Vec<(usize, solidcraft_geom::Vec3, 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_map(|index| {
let normal = body.planar_face(index)?.normal();
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, normal))
})
.collect()
}
/// Every flat face of a sheet takes an attached sketch (#495): its panels, the rims along the
/// panels' straight edges (named by the base sketch line or the flange's far edge and sides) and
/// the flat ends of a bend. Each lies on its face, facing out of the sheet, and a rim's sketch
/// follows the rim when the base grows. A press-pull on a rim pushes it out, and a plain hole into
/// a panel is kept as a cut-out of the flat pattern (#560).
#[test]
fn every_flat_face_of_a_sheet_takes_an_attached_sketch() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "W", "expression": "100 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["p1"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "W"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
let faces = flat_face_points(&s);
// The base: its two faces and three rims (the fourth edge bends); the flange: its two faces,
// its far rim and two sides; the two ends of the bend.
assert_eq!(faces.len(), 12);
let mut on_side = None;
for (index, point, normal) in faces {
let made = run(&mut s, "sketch.create", json!({"plane": {"face": point, "body": "Body1", "index": index}}));
run(&mut s, "sketch.finish", json!({}));
assert_eq!(made["attachment"], "sheet_face", "face {index} at {point:?}");
let id = made["sketch"].as_u64().unwrap();
let solidcraft_doc::FaceAttachmentStatus::Sheet { plane: Some(plane) } = s.placed_document().sketch_face_attachment(id, &s.model) else {
panic!("face {index} at {point:?}")
};
assert!(plane.normal().dot(normal) > 1.0 - 1e-9 && (point - plane.origin).dot(normal).abs() < 1e-6, "face {index}: {plane:?}");
if (point.x - 100.0).abs() < 1e-6 && point.y > 10.0 {
on_side = Some(id);
}
}
// The base's rim along x = W (the sketch's l2) moves with W.
let side = on_side.expect("the base rim on x = 100");
run(&mut s, "parameters.change", json!({"name": "W", "expression": "120 mm"}));
let solidcraft_doc::FaceAttachmentStatus::Sheet { plane: Some(plane) } = s.placed_document().sketch_face_attachment(side, &s.model) else {
panic!("the rim sketch after the edit")
};
assert!((plane.origin.x - 120.0).abs() < 1e-9 && (plane.normal().x - 1.0).abs() < 1e-9, "{plane:?}");
// Press-pull the rim 5 mm out: the base grows by 5 x 60 x 2.5 less what the flange covers.
let v0 = volume(&mut s);
run(&mut s, "solid.press_pull", json!({"face": [120, 30, 1.25], "distance": 5}));
assert!(volume(&mut s) > v0 + 5.0 * 50.0 * 2.5, "{} {v0}", volume(&mut s));
// A plain hole into the base is a cut-out of the flat pattern.
let mut t = Session::default();
plate_with_flange(&mut t);
let v0 = volume(&mut t);
run(&mut t, "solid.hole", json!({"position": [50, 30, 2.5], "direction": [0, 0, -1], "diameter": 6}));
assert!((v0 - volume(&mut t) - PI * 9.0 * 2.5).abs() < 0.1, "{}", v0 - volume(&mut t));
let f = flat(&mut t);
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1), "{f}");
}
+200 -15
View File
@@ -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")
@@ -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)
@@ -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))
})
}
@@ -821,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.
@@ -855,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))
@@ -887,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))
@@ -1174,19 +1245,24 @@ fn draw_point(s: &mut Session, p: &Value) -> 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.
@@ -1330,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);
@@ -1610,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>)> {
@@ -1766,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)))
})?;
@@ -1776,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}))
@@ -2027,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);
@@ -2083,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,
@@ -2092,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()))
{
@@ -2115,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);
@@ -2156,3 +2338,6 @@ 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);
@@ -232,7 +232,8 @@ fn bad_line_references_are_errors_and_change_nothing() {
// A constraint the sketch refuses rolls the axis line back with it.
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "b", "mode": "horizontal"}));
let before_redundant = s.doc.to_json();
assert!(s.execute("sketch.constraint.parallel", &json!({"a": "b", "b": "x"})).is_err(), "already horizontal");
// (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);
+37
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")
@@ -133,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"))?;
+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();
+3 -3
View File
@@ -105,10 +105,10 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
let inverse = mat_inverse(&frame).ok_or_else(|| bad(cmd, "invalid selected face frame"))?;
let local_point = apply_point(&inverse, point);
let tolerance = (body.body.size() * 1e-4).max(1e-5);
// A flat face of a sheet metal body attaches to its panel: the exact plane comes from
// the panel's flat region and fold, and the sketch keeps the face's name to follow it.
// 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
&& !internal
&& let Some(sheet) = state.sheets.iter().find(|sheet| sheet.body == body.name)
&& let Some((face, plane)) = sheet.panel_face_at(local_point, tolerance * 10.0)
{
+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}))
+7
View File
@@ -226,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)
}
@@ -568,6 +571,10 @@ mod tests;
#[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");
}
@@ -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();
+164 -2
View File
@@ -1,11 +1,11 @@
//! Surface commands: Patch, Stitch, Thicken and Trim (surface bodies and turning them into
//! solids).
//! solids), and the planned surface toolset (`planned!`: registered, not built yet).
use serde_json::Value;
use solidcraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, expr::Kind};
use super::CommandSpec;
use super::features::{add_feature, check_expr, feature_sketch, operation, plane_param, profiles};
use super::features::{add_feature, check_expr, extent_param, feature_sketch, operation, plane_param, profiles};
use crate::params::{bad, bool_, expr, str_, string_list, vec3};
use crate::{Result, Session};
@@ -30,6 +30,54 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SURFACE", "MODIFY")
.icon("trim")
.params("body: surface name; plane: XY|XZ|YZ|plane name|{origin, normal} or tool: {body, point} (a face, its surface extended); keep: [x,y,z] (a point on the side to keep)"),
CommandSpec::new("surface.extrude", "Surface Extrude", surface_extrude)
.at("SURFACE", "CREATE")
.icon("surface_extrude")
.params("sketch?: id|name; curves?: [curve ids] (open or closed chains of curves meeting end to end) and/or profiles? (their boundaries; the default when no curves are given); distance: expr; direction?: positive|negative|symmetric; distance2?: expr; start_offset?: expr; name?, body_name? (a surface body: walls only, no caps)"),
planned!("surface.revolve", "Surface Revolve", "757")
.at("SURFACE", "CREATE")
.icon("surface_revolve")
.params("planned: the parameters of solid.revolve, with open curve chains as well as profiles (no caps); caps?: bool"),
planned!("surface.sweep", "Surface Sweep", "757")
.at("SURFACE", "CREATE")
.icon("surface_sweep")
.params("planned: the parameters of solid.sweep, with an open profile chain as well as a closed one (no caps); caps?: bool"),
planned!("surface.loft", "Surface Loft", "757")
.at("SURFACE", "CREATE")
.icon("surface_loft")
.params("planned: the parameters of solid.loft, with open sections as well as closed ones (no caps); caps?: bool"),
planned!("surface.offset", "Offset Surface", "758")
.at("SURFACE", "CREATE")
.icon("surface_offset")
.params("planned: faces: [{body, point}]; distance: expr (signed, along the normals; 0 copies the faces); chain?: bool (the whole tangent chain of each pick); tolerance?: expr (B-spline fit, default 0.01 mm)"),
planned!("surface.ruled", "Ruled Surface", "762")
.at("SURFACE", "CREATE")
.icon("ruled")
.params("planned: body; edges: [[x,y,z]] (points on an edge chain); distance: expr; mode?: normal|tangent|angle|direction; angle?: expr; direction?: [x,y,z]; flip?: bool; corners?: mitre|round"),
planned!("surface.boundary", "Boundary Surface", "763")
.at("SURFACE", "CREATE")
.icon("boundary")
.params("planned: u: [curves], v?: [curves] (sketch curves, 3D sketch wires or {body, point} edges; 2–4 boundaries, more make a network); continuity?: [{curve, g: 0|1|2}] for edge boundaries; operation?: new|join"),
planned!("surface.blend", "Blend Surface", "764")
.at("SURFACE", "CREATE")
.icon("surface_blend")
.params("planned: from, to: {body, edges: [[x,y,z]]} (two edge chains apart); continuity?: [g0|g1|g2, g0|g1|g2] (each side); bulge?: [number, number]; align?: auto|[[x,y,z], [x,y,z]]; stitch?: bool"),
planned!("surface.untrim", "Untrim", "761")
.at("SURFACE", "MODIFY")
.icon("untrim")
.params("planned: face: {body, point}; holes?: all|[[x,y,z]] (points on the inner loops to remove); outer?: bool (restore the natural bounds); in_place?: bool (a surface body only)"),
planned!("surface.unstitch", "Unstitch", "766")
.at("SURFACE", "MODIFY")
.icon("unstitch")
.params("planned: body; faces?: [[x,y,z]] (points on the faces to split off; default: every face its own surface body)"),
planned!("surface.reverse_normal", "Reverse Normal", "766")
.at("SURFACE", "MODIFY")
.icon("reverse_normal")
.params("planned: body (a surface); faces?: [[x,y,z]] (default: all of them)"),
planned!("surface.fillet", "Surface Fillet", "769")
.at("SURFACE", "MODIFY")
.icon("surface_fillet")
.params("planned: a, b: {body, point} (two faces of separate bodies that meet or cross; the points pick the side the ball rolls on); radius: expr; trim?: both|first|none"),
];
fn patch(s: &mut Session, p: &Value) -> Result<Value> {
@@ -47,6 +95,21 @@ fn patch(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::Patch { sketch: Some(sketch), profiles, body: None, edges: Vec::new() })
}
fn surface_extrude(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.extrude";
let sketch = feature_sketch(s, p, cmd)?;
let curves = string_list(p, "curves");
if curves.len() > 1000 {
return Err(bad(cmd, "at most 1000 curves"));
}
let profiles = if curves.is_empty() || p.get("profiles").is_some() { Some(profiles(p, cmd)?) } else { None };
let extent = extent_param(s, p, cmd)?;
if extent.through_all || extent.taper.is_some() || extent.to.is_some() || extent.from.is_some() {
return Err(bad(cmd, "not supported yet: through all, to or from an object, or a taper"));
}
add_feature(s, p, FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent })
}
fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
@@ -129,6 +192,105 @@ mod tests {
solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume
}
fn area(s: &Session, name: &str) -> f64 {
solidcraft_kernel::measure(&s.world_state().body(name).unwrap().body).unwrap().area
}
/// Surface Extrude of open curves: a line and an arc joined end to end (listed in either
/// order) make one sheet of two walls; both sides of the sketch; undo, redo and reopening
/// give the same sheet.
#[test]
fn surface_extrude_open_curves() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "ids": ["l1"]}));
let arc = run(&mut s, "sketch.arc.three_point", json!({"start": [10, 0], "end": [20, 0], "through": [15, 5]}))["created"]["curves"][0]
.as_str()
.unwrap()
.to_string();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "surface.extrude", json!({"sketch": "S", "curves": [arc, "l1"], "distance": 4, "body_name": "Wall"}));
let st = s.world_state();
let wall = &st.body("Wall").unwrap().body;
assert!(wall.is_surface() && wall.face_count() == 2);
// The line's wall and the half circle's (radius 5): length × height.
let want = (10.0 + std::f64::consts::PI * 5.0) * 4.0;
assert!((area(&s, "Wall") - want).abs() < want * 1e-3, "{} vs {want}", area(&s, "Wall"));
run(&mut s, "surface.extrude", json!({"sketch": "S", "curves": ["l1"], "distance": 2, "direction": "symmetric", "body_name": "Both"}));
let bb = solidcraft_kernel::measure(&s.world_state().body("Both").unwrap().body).unwrap().bbox;
assert!((bb.min.z + 2.0).abs() < 1e-9 && (bb.max.z - 2.0).abs() < 1e-9, "{bb:?}");
run(&mut s, "edit.undo", json!({}));
assert!(s.world_state().body("Both").is_none());
run(&mut s, "edit.redo", json!({}));
assert!(s.world_state().body("Both").is_some());
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!((area(&cold, "Wall") - area(&s, "Wall")).abs() < 1e-9);
// Refused: no such curve, a zero distance, and an extent a sheet does not take yet.
assert!(s.execute("surface.extrude", &json!({"sketch": "S", "curves": ["nope"], "distance": 4})).is_err());
assert!(s.execute("surface.extrude", &json!({"sketch": "S", "curves": ["l1"], "distance": 0})).is_err());
assert!(s.execute("surface.extrude", &json!({"sketch": "S", "curves": ["l1"], "distance": 4, "through_all": true})).is_err());
}
/// Surface Extrude of a profile gives its walls alone; patching both ends and stitching
/// makes the solid that solid.extrude makes of the same profile.
#[test]
fn surface_extrude_profile_walls_stitch_into_the_solid() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 6]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "surface.extrude", json!({"sketch": "R", "distance": 3, "body_name": "Walls"}));
let st = s.world_state();
let walls = &st.body("Walls").unwrap().body;
assert!(walls.is_surface() && walls.face_count() == 4);
assert!((area(&s, "Walls") - 96.0).abs() < 1e-9);
let edges = |z: f64| json!([[5, 0, z], [10, 3, z], [5, 6, z], [0, 3, z]]);
run(&mut s, "surface.patch", json!({"edges": edges(0.0), "body": "Walls", "body_name": "Bottom"}));
run(&mut s, "surface.patch", json!({"edges": edges(3.0), "body": "Walls", "body_name": "Top"}));
run(&mut s, "surface.stitch", json!({"bodies": ["Walls", "Bottom", "Top"]}));
let st = s.world_state();
assert!(!st.body("Walls").unwrap().body.is_surface());
run(&mut s, "solid.extrude", json!({"sketch": "R", "distance": 3, "body_name": "Solid"}));
assert!((volume(&s, "Walls") - volume(&s, "Solid")).abs() < 1e-9);
assert!((volume(&s, "Walls") - 180.0).abs() < 1e-9);
}
/// The planned surface commands are registered but refuse to run: disabled with the
/// issue that tracks them, an error (never a panic) from every caller, and the design left
/// as it was.
#[test]
fn planned_commands_refuse_and_leave_the_design_alone() {
let planned = [
"surface.revolve",
"surface.sweep",
"surface.loft",
"surface.offset",
"surface.ruled",
"surface.boundary",
"surface.blend",
"surface.untrim",
"surface.unstitch",
"surface.reverse_normal",
"surface.fillet",
"inspect.continuity",
];
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
let before = s.doc.features.len();
for id in planned {
let spec = crate::find_command(id).unwrap_or_else(|| panic!("{id} is not registered"));
let info = spec.info(&s);
assert!(!info.enabled, "{id} must stay disabled until it is built");
let why = info.disabled_reason.unwrap_or_default();
assert!(why.starts_with("not supported yet (issue #"), "{id}: {why}");
assert!(!spec.icon.is_empty() && !spec.params.is_empty(), "{id}: icon and params doc");
assert!(s.execute(id, &json!({"body": "B"})).is_err(), "{id} ran");
let e = (spec.run)(&mut s, &json!({})).unwrap_err().to_string();
assert!(e.starts_with("not supported yet: ") && e.contains("(issue #"), "{id}: {e}");
}
assert_eq!(s.doc.features.len(), before, "a planned command changed the design");
}
/// A rectangle patched, trimmed by a plane and thickened; a box's open top patched; a rim
/// that is not planar patched.
#[test]
@@ -34,6 +34,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("curvature_map")
.noundo()
.params("radius?: mm shown red (default: a tenth of the model's size); clear?: true turns the analysis off"),
planned!("inspect.continuity", "Continuity Analysis", "770")
.at("SURFACE", "INSPECT")
.icon("continuity")
.noundo()
.params("planned: body?; edges?: [[x,y,z]] (default: every edge of the body); returns each edge's worst gap (G0), normal angle (G1) and curvature jump (G2); clear?: true turns the analysis off"),
];
fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result<Value> {
+131
View File
@@ -0,0 +1,131 @@
//! Stateless camera commands shared by the navigation cube and programmatic callers.
use serde_json::{Value, json};
use solidcraft_render::{Camera, StandardView};
use super::CommandSpec;
use crate::params::{bad, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("view.navigate", "Navigate View", navigate)
.icon("orbit")
.noundo()
.params("camera: Camera; direction?: [x,y,z] in the camera's up-axis frame; or action: left|right|up|down|clockwise|counterclockwise|home|fit → camera, fit")];
fn navigate(_s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "view.navigate";
let mut cam: Camera =
serde_json::from_value(p.get("camera").cloned().ok_or_else(|| bad(cmd, "camera is required"))?).map_err(|_| bad(cmd, "invalid camera"))?;
if !cam.is_valid()
|| !cam.fov.is_finite()
|| !(0.0..std::f64::consts::PI).contains(&cam.fov)
|| cam.yaw.abs() > 1e6
|| cam.roll.abs() > 1e6
|| cam.pitch.abs() > std::f64::consts::FRAC_PI_2
{
return Err(bad(cmd, "invalid camera"));
}
let mut fit = false;
if let Some(value) = p.get("direction") {
let dir = vec3(value)
.filter(|v| v.is_finite() && v.len() <= 2.0)
.and_then(|v| v.normalized())
.ok_or_else(|| bad(cmd, "direction must be a finite nonzero vector of length at most 2"))?;
let heading = cam.yaw;
cam = cam.looking_from(cam.from_frame(dir));
if dir.x.abs() < 1e-9 && dir.y.abs() < 1e-9 {
cam.yaw = square_heading(heading);
}
} else {
use std::f64::consts::FRAC_PI_2;
let (r, u, _) = cam.basis();
match p.get("action").and_then(Value::as_str) {
Some("left") => cam = cam.looking_from(-r),
Some("right") => cam = cam.looking_from(r),
Some("up") => cam = cam.looking_from(u),
Some("down") => cam = cam.looking_from(-u),
Some("clockwise") => cam.roll = (cam.roll + FRAC_PI_2).rem_euclid(std::f64::consts::TAU),
Some("counterclockwise") => cam.roll = (cam.roll - FRAC_PI_2).rem_euclid(std::f64::consts::TAU),
Some("home") => {
cam.set_view(StandardView::Iso);
fit = true;
}
Some("fit") => fit = true,
_ => return Err(bad(cmd, "unknown navigation action")),
}
}
Ok(json!({"camera": cam, "fit": fit}))
}
/// At a halfway tie prefer the heading nearer the front plan view.
pub(crate) fn square_heading(yaw: f64) -> f64 {
if !yaw.is_finite() {
return 0.0;
}
let q = std::f64::consts::FRAC_PI_2;
let lo = (yaw / q).floor() * q;
let hi = lo + q;
if ((yaw - lo) - (hi - yaw)).abs() < 1e-6 {
let off_front = |a: f64| ((a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI).abs();
if off_front(lo) <= off_front(hi) { lo } else { hi }
} else if yaw - lo < hi - yaw {
lo
} else {
hi
}
}
#[cfg(test)]
mod tests {
use super::*;
use solidcraft_geom::Vec3;
use solidcraft_render::UpAxis;
#[test]
fn every_cube_direction_preserves_framing_without_editing_the_design() {
for up in [UpAxis::Z, UpAxis::Y] {
let mut s = Session::default();
let before = s.doc.to_json();
let cam = Camera { up, target: Vec3::new(10.0, 20.0, 30.0), distance: 123.0, ..Default::default() };
for x in [-1.0, 0.0, 1.0] {
for y in [-1.0, 0.0, 1.0] {
for z in [-1.0, 0.0, 1.0] {
let Some(dir) = Vec3::new(x, y, z).normalized() else { continue };
let result = s.execute("view.navigate", &json!({"camera":cam,"direction":[x,y,z]})).unwrap();
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
assert!(to.back().dist(cam.from_frame(dir)) < 2e-6);
assert_eq!((to.target, to.distance, to.fov, to.up), (cam.target, cam.distance, cam.fov, cam.up));
}
}
}
assert_eq!(s.doc.to_json(), before);
assert!(s.undo.is_empty());
}
}
#[test]
fn arrows_roll_and_home_are_programmatic_and_reject_hostile_input() {
let mut s = Session::default();
let cam = Camera::default();
let (r, u, _) = cam.basis();
for (action, dir) in [("left", -r), ("right", r), ("up", u), ("down", -u)] {
let result = navigate(&mut s, &json!({"camera":cam,"action":action})).unwrap();
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
assert!(to.back().dist(dir) < 2e-6);
}
let result = navigate(&mut s, &json!({"camera":cam,"action":"clockwise"})).unwrap();
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
assert_eq!(to.back(), cam.back());
assert!((to.basis().0 - u).len() < 1e-9);
for action in ["home", "fit"] {
assert_eq!(navigate(&mut s, &json!({"camera":cam,"action":action})).unwrap()["fit"], true);
}
for params in [
json!({}),
json!({"camera":cam,"direction":[0,0,0]}),
json!({"camera":cam,"direction":[1e308,0,0]}),
json!({"camera":cam,"action":"unknown"}),
] {
assert!(s.execute("view.navigate", &params).is_err());
}
}
}
+230
View File
@@ -0,0 +1,230 @@
//! Sizes a command works out from what it was given keep the meaning those inputs had when the
//! design's units change (#716): a sphere or a cylinder made with a bare `diameter`, a coil's
//! pitch or revolutions from its height, a box placed by its centre, a Press Pull. A radius from
//! a bare diameter used to be stored as `(20) / 2`: a unit-less expression that is not a bare
//! number is read in the design's units of the moment, so switching a millimetre design to
//! inches made such a sphere 25.4 times larger.
use std::f64::consts::PI;
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 rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
/// A body's volume (mm³) and bounding box (min x, y, z, max x, y, z in mm).
#[derive(Debug, Clone, Copy)]
struct Shape {
volume: f64,
bbox: [f64; 6],
}
impl Shape {
fn size(&self, axis: usize) -> f64 {
self.bbox[axis + 3] - self.bbox[axis]
}
fn same(&self, other: &Shape) -> bool {
rel(self.volume, other.volume) < 1e-9 && self.bbox.iter().zip(&other.bbox).all(|(a, b)| (a - b).abs() < 1e-7)
}
}
/// Every body by name.
fn shapes(s: &mut Session) -> Vec<(String, Shape)> {
measured(s, json!({}))
}
fn measured(s: &mut Session, p: Value) -> Vec<(String, Shape)> {
let m = run(s, "inspect.measure", p);
let bodies = m["bodies"].as_array().cloned().unwrap_or_default();
bodies
.iter()
.map(|b| {
let c = |k: &str, i: usize| b["bbox"][k][i].as_f64().unwrap_or(f64::NAN);
let bbox = [c("min", 0), c("min", 1), c("min", 2), c("max", 0), c("max", 1), c("max", 2)];
(b["name"].as_str().unwrap_or_default().to_string(), Shape { volume: b["volume_mm3"].as_f64().unwrap_or(f64::NAN), bbox })
})
.collect()
}
/// One body, measured alone.
fn shape(s: &mut Session, body: &str) -> Shape {
measured(s, json!({ "bodies": [body] })).into_iter().next().map(|(_, x)| x).unwrap_or_else(|| panic!("no body {body}"))
}
/// Switch the design to each unit in turn; every body keeps its volume and its bounding box.
fn keeps_shape_through(s: &mut Session, units: &[&str]) {
let before = shapes(s);
assert!(!before.is_empty());
for u in units {
let r = run(s, "document.units", json!({ "units": u }));
let now = shapes(s);
assert_eq!(now.len(), before.len(), "{u}: {r}");
for ((name, a), (_, b)) in before.iter().zip(&now) {
assert!(a.same(b), "{name} after switching to {u} ({r}): {a:?} became {b:?}");
}
}
}
/// The expressions of a feature's inputs.
fn inputs(s: &Session, feature: usize) -> Vec<String> {
s.doc.features.get(feature).map(|f| f.kind.inputs().into_iter().map(|(_, e, _)| e).collect()).unwrap_or_default()
}
#[test]
fn bare_diameters_keep_their_size_when_the_units_change() {
let mut s = Session::default();
run(&mut s, "solid.sphere", json!({"diameter": 20, "body_name": "Ball"}));
run(&mut s, "solid.cylinder", json!({"diameter": 20, "height": 10, "base": [50, 0, 0], "body_name": "Can"}));
let ball = shape(&mut s, "Ball");
assert!(rel(ball.volume, 4.0 / 3.0 * PI * 1000.0) < 1e-3, "{ball:?}");
// The bounding box comes from the tessellation: a little inside the surface.
assert!((ball.size(0) - 20.0).abs() < 1e-3 && (ball.bbox[0] + 10.0).abs() < 1e-3, "{ball:?}");
let can = shape(&mut s, "Can");
assert!(rel(can.volume, PI * 100.0 * 10.0) < 1e-3, "{can:?}");
assert!((can.bbox[0] - 40.0).abs() < 1e-3 && (can.bbox[3] - 60.0).abs() < 1e-3 && (can.size(2) - 10.0).abs() < 1e-6, "{can:?}");
keeps_shape_through(&mut s, &["in", "cm", "ft", "mm", "in"]);
// The radius says what the diameter was, with the unit it was typed in.
assert_eq!(inputs(&s, 0), ["(20 mm) / 2"]);
assert_eq!(inputs(&s, 1), ["(20 mm) / 2", "10"]);
}
#[test]
fn bare_diameters_typed_in_an_inch_design_mean_inches() {
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
run(&mut s, "solid.sphere", json!({"diameter": 2, "body_name": "Ball"}));
// The base is a point: its bare numbers are millimetres.
run(&mut s, "solid.cylinder", json!({"diameter": 1, "height": 2, "base": [100, 0, 0], "body_name": "Can"}));
let ball = shape(&mut s, "Ball");
assert!(rel(ball.volume, 4.0 / 3.0 * PI * 25.4f64.powi(3)) < 1e-3, "{ball:?}");
assert!((ball.size(0) - 50.8).abs() < 1e-3, "{ball:?}");
let can = shape(&mut s, "Can");
assert!(rel(can.volume, PI * 12.7 * 12.7 * 50.8) < 1e-3, "{can:?}");
assert!((can.bbox[0] - 87.3).abs() < 1e-3 && (can.size(2) - 50.8).abs() < 1e-6, "{can:?}");
keeps_shape_through(&mut s, &["mm", "cm", "in"]);
}
/// A body's extent: the diagonal of its vertices' bounding box. Measuring a coil takes seconds;
/// this does not.
fn extent(s: &Session, body: &str) -> f64 {
s.model.state().body(body).map_or(f64::NAN, |b| b.body.size())
}
#[test]
fn coil_pitch_and_revolutions_keep_their_size_when_the_units_change() {
use std::f64::consts::TAU;
let mut s = Session::default();
// Height and revolutions give the pitch.
run(&mut s, "solid.coil", json!({"diameter": 40, "revolutions": 2, "height": 16, "section_size": 4, "body_name": "A"}));
// Height and pitch give the revolutions: a height with its unit next to a bare pitch, both
// millimetres here.
run(&mut s, "solid.coil", json!({"diameter": 40, "height": "16 mm", "pitch": 8, "section_size": 4, "base": [100, 0, 0], "body_name": "B"}));
let made = shapes(&mut s);
let want = PI * 4.0 * TAU * 20.0 * 2.0;
for (body, x) in &made {
assert!(rel(x.volume, want) < 1e-3, "{body}: {x:?}");
assert!((x.size(2) - made[0].1.size(2)).abs() < 1e-6, "{body} is as tall as A: {made:?}");
}
assert_eq!(inputs(&s, 0)[1], "(16 mm) / (2)", "A's pitch");
assert_eq!(inputs(&s, 1)[2], "(16 mm) / (8 mm)", "B's revolutions");
let sizes = |s: &Session| [extent(s, "A"), extent(s, "B")];
let before = sizes(&s);
for u in ["in", "cm", "mm"] {
run(&mut s, "document.units", json!({ "units": u }));
let now = sizes(&s);
assert!(now.iter().zip(&before).all(|(a, b)| rel(*a, *b) < 1e-9), "{u}: {before:?} became {now:?}");
}
// In an inch design the same bare numbers are inches.
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
run(&mut s, "solid.coil", json!({"diameter": 2, "revolutions": 2, "height": 0.8, "section_size": 0.2, "body_name": "A"}));
let a = shape(&mut s, "A");
assert!(rel(a.volume, PI * 2.54f64.powi(2) * TAU * 25.4 * 2.0) < 1e-3, "{a:?}");
let before = extent(&s, "A");
run(&mut s, "document.units", json!({"units": "mm"}));
assert!(rel(extent(&s, "A"), before) < 1e-9, "{before} became {}", extent(&s, "A"));
}
#[test]
fn a_box_placed_by_its_centre_reads_its_sizes_in_the_design_units() {
// An inch design: bare sizes are inches, the centre's bare numbers millimetres.
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
run(&mut s, "solid.box", json!({"length": 2, "width": 1, "height": 1, "center": [0, 0, 0], "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 2, "width": 1, "height": 1, "center": ["10 mm", 100, 0], "body_name": "B"}));
let a = shape(&mut s, "A");
assert!((a.bbox[0] + 25.4).abs() < 1e-6 && (a.bbox[3] - 25.4).abs() < 1e-6 && (a.bbox[1] + 12.7).abs() < 1e-6, "{a:?}");
let b = shape(&mut s, "B");
assert!((b.bbox[0] + 15.4).abs() < 1e-6 && (b.bbox[3] - 35.4).abs() < 1e-6 && (b.bbox[1] - 87.3).abs() < 1e-6, "{b:?}");
keeps_shape_through(&mut s, &["mm", "in"]);
// A millimetre design: a centre in inches and a bare size in millimetres.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "center": ["0.5 in", 0, 0], "body_name": "C"}));
let c = shape(&mut s, "C");
assert!((c.bbox[0] - 12.2).abs() < 1e-6 && (c.bbox[3] - 13.2).abs() < 1e-6, "{c:?}");
keeps_shape_through(&mut s, &["in", "mm"]);
}
#[test]
fn press_pull_keeps_its_distance_when_the_units_change() {
// A face pushed in: the cut's depth is the distance's size.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Block"}));
run(&mut s, "solid.press_pull", json!({"face": [20, 15, 20], "distance": -5}));
assert!(rel(shape(&mut s, "Block").volume, 40.0 * 30.0 * 15.0) < 1e-9);
keeps_shape_through(&mut s, &["in", "mm"]);
// A fillet's face: its radius grows by the distance.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Block"}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 10]], "radius": 3, "name": "Round"}));
let corner = |r: f64| (4.0 - PI) * r * r / 4.0 * 20.0;
let on = |r: f64| {
let d = r - r / 2f64.sqrt();
json!([40.0 - d, d, 10.0])
};
run(&mut s, "solid.press_pull", json!({"face": on(3.0), "distance": 2}));
assert!(rel(shape(&mut s, "Block").volume, 24000.0 - corner(5.0)) < 1e-4);
keeps_shape_through(&mut s, &["in", "cm", "mm"]);
// Pulled again in an inch design: 0.1 more is 2.54 mm more.
run(&mut s, "document.units", json!({"units": "in"}));
run(&mut s, "solid.press_pull", json!({"face": on(5.0), "distance": 0.1}));
assert!(rel(shape(&mut s, "Block").volume, 24000.0 - corner(7.54)) < 1e-4, "{:?}", inputs(&s, 1));
keeps_shape_through(&mut s, &["mm", "in"]);
}
/// A diameter that refers to a unit-less parameter is left as typed: the parameter is read in
/// the design's units, and when they change it keeps the old one written in, so the part keeps
/// its size as it did before.
#[test]
fn a_diameter_from_a_unitless_parameter_is_left_as_typed() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "w", "expression": "20", "unit": ""}));
run(&mut s, "solid.sphere", json!({"diameter": "w", "body_name": "Ball"}));
assert_eq!(inputs(&s, 0), ["(w) / 2"]);
assert!(rel(shape(&mut s, "Ball").volume, 4.0 / 3.0 * PI * 1000.0) < 1e-3);
keeps_shape_through(&mut s, &["in", "mm"]);
}
/// Beside a unit-less expression that is not a bare number, a bare number stays bare, as it
/// did: the whole expression is read in the design's units.
#[test]
fn bare_numbers_beside_a_unitless_expression_stay_bare() {
let mut s = Session::default();
run(&mut s, "solid.coil", json!({"diameter": 40, "height": 16, "pitch": "2 * 4", "section_size": 4, "body_name": "A"}));
assert_eq!(inputs(&s, 0)[2], "(16) / (2 * 4)", "the revolutions");
assert!(s.model.result(s.doc.features[0].id).is_some_and(|r| r.error.is_none()));
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 10, "center": ["2 * 5", 0, 0], "body_name": "B"}));
assert_eq!(s.doc.features[1].kind.point_expr().map(|p| p[0].clone()), Some("(2 * 5) - (20) / 2".to_string()));
let b = shape(&mut s, "B");
assert!(b.bbox[0].abs() < 1e-6 && (b.bbox[3] - 20.0).abs() < 1e-6, "{b:?}");
}
+2 -1
View File
@@ -63,7 +63,8 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
"plastic.boss" | "plastic.snap_fit" | "plastic.rest" => {
&[("position", Pick), ("direction", Dir), ("hook", Dir), ("along", Dir), ("body", Body)]
}
"plastic.lip" | "sheet.convert" => &[("face", Pick), ("body", Body)],
"plastic.lip" => &[("face", Pick), ("body", Body)],
"sheet.convert" => &[("face", Pick), ("bends", Picks), ("body", Body)],
"sheet.flange" | "sheet.hem" => &[("edges", Picks), ("body", Body)],
"sheet.fold" => &[("points", Picks), ("fixed", Pick), ("body", Body)],
"sketch.create" | "sketch.redefine" => &[("plane", HerePlane)],
+4
View File
@@ -803,6 +803,8 @@ mod construct_tests;
#[cfg(test)]
mod delete_face_tests;
#[cfg(test)]
mod derived_units_tests;
#[cfg(test)]
mod display_mesh_tests;
#[cfg(test)]
mod extent_tests;
@@ -823,6 +825,8 @@ mod tests;
#[cfg(test)]
mod thread_tests;
#[cfg(test)]
mod view_bounds_tests;
#[cfg(test)]
mod world_placement_cache_tests;
/// Per sketch feature: its parameter-driven dimensions' values (see [`own_values`]).
+95 -2
View File
@@ -111,6 +111,22 @@ fn a_body_cut_in_pieces_twice_gets_pieces_of_their_own_names() {
}
/// A join that bridges two bodies makes them one body, not an overlap of the first and the second.
#[test]
fn extrude_a_face_where_another_body_lies_flush_on_it() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 4, "body_name": "A"}));
run(&mut s, "solid.box", json!({"corner": [10, 0, 4], "length": 10, "width": 20, "height": 1, "body_name": "B"}));
// A's top and B's bottom both lie at the point: which one is meant has to be said.
let e = s.execute("solid.extrude", &json!({"face": [15, 5, 4], "distance": "2 mm", "operation": "new"})).unwrap_err().to_string();
assert!(e.contains("A") && e.contains("B") && e.contains("face_body"), "{e}");
let r = run(&mut s, "solid.extrude", json!({"face": [15, 5, 4], "face_body": "A", "distance": "2 mm", "operation": "new", "body_name": "C"}));
let m = run(&mut s, "inspect.measure", json!({"bodies": ["C"]}));
let v = m["total"]["volume_mm3"].as_f64().unwrap();
assert!((v - 20.0 * 20.0 * 2.0).abs() < 1e-6, "{v} {r}");
let e = s.execute("solid.extrude", &json!({"face": [15, 5, 4], "face_body": "Nope", "distance": "2 mm"})).unwrap_err().to_string();
assert!(e.contains("Nope"), "{e}");
}
#[test]
fn join_bridging_two_bodies_merges_them() {
let mut s = Session::default();
@@ -1592,6 +1608,12 @@ fn chord_and_variable_fillets() {
let removed = (1.0 - PI / 4.0) * 20.0 * (4.0 + 10.0 + 25.0) / 3.0;
assert!(rel(v, 6000.0 - removed) < 1e-4, "{v}");
assert!(s.execute("solid.fillet", &json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable"})).is_err());
// Down to zero at the far end: the round closes to the corner.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "B"}));
run(&mut s, "solid.fillet", json!({"edges": [[10, 10, 30]], "radius": 4, "type": "variable", "radius2": 0, "start": [10, 0, 30]}));
let v = solidcraft_kernel::measure(&s.world_state().body("B").unwrap().body).unwrap().volume;
assert!(rel(v, 6000.0 - (1.0 - PI / 4.0) * 20.0 * 16.0 / 3.0) < 1e-4, "{v}");
// A radius through a middle point (a user parameter), straight between: 2 → 4 → 2.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "B"}));
@@ -2904,9 +2926,80 @@ fn fillet_fit_reduce_uses_the_largest_radius_that_fits() {
// Saved only when set.
let json = serde_json::to_string(&s.doc.features).unwrap();
assert_eq!(json.matches("\"fit\":\"reduce\"").count(), 1, "{json}");
// An unknown mode is refused, and so is reduce with a radius that is not constant.
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "taper"})).is_err());
// An unknown mode is refused, and so are reduce and taper with a radius that is not constant.
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "stretch"})).is_err());
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "reduce", "type": "chord"})).is_err());
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "taper", "type": "chord"})).is_err());
}
#[test]
fn fillet_fit_taper_follows_the_room_along_the_edge() {
// #293: the top front edge of a plate whose top narrows from 20 to `tip` mm along 40 mm.
// At a 2 mm tip it can't take 3 mm; taper keeps 3 where it fits and comes down to 0.9 of the
// 2 mm there. With an 8 mm tip 3 fits and stays constant; a plate closing to a point
// tapers to 0. The warning says what was done.
let (k, edge) = (1.0 - PI / 4.0, json!([[20, 0, 15]]));
let plate = |tip: f64| {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let pts = if tip > 0.0 { json!([[0, 0], [40, 0], [40, tip], [0, 20]]) } else { json!([[0, 0], [40, 0], [0, 20]]) };
run(&mut s, "sketch.line", json!({"points": pts, "closed": true}));
run(&mut s, "solid.extrude", json!({"distance": 15}));
s
};
let warning = |s: &Session, out: &Value| out["feature"].as_u64().and_then(|id| s.model.result(id)).and_then(|r| r.warning.clone());
let mut s = plate(2.0);
assert!(s.execute("solid.fillet", &json!({"edges": edge, "radius": 3})).is_err());
let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 3, "fit": "taper"}));
let w = warning(&s, &out).unwrap_or_default();
assert!(w.contains("tapers to the room on 1 of 1 edges, down to 1.8 mm"), "{w}");
// Between the material a constant 1.8 and a constant 3 would remove.
let (v, full) = (volume(&mut s), 40.0 * 22.0 / 2.0 * 15.0);
assert!(v < full - k * 1.8 * 1.8 * 40.0 && v > full - k * 9.0 * 40.0, "{v}");
let json = serde_json::to_string(&s.doc.features).unwrap();
assert_eq!(json.matches("\"fit\":\"taper\"").count(), 1, "{json}");
let mut s = plate(8.0);
let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 3, "fit": "taper"}));
assert_eq!(warning(&s, &out), None);
assert!(rel(volume(&mut s), 40.0 * 28.0 / 2.0 * 15.0 - k * 9.0 * 40.0) < 2e-4);
let mut s = plate(0.0);
let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 16, "fit": "taper"}));
let w = warning(&s, &out).unwrap_or_default();
assert!(w.contains("on 1 of 1 edges, down to 0 mm"), "{w}");
}
#[test]
fn spline_fillet_takes_over_a_taper() {
// A tapered fillet keeps each edge's radius spline; given back as a `spline` fillet it makes
// the same part, and its control radii are then the user's to change.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 2], [0, 20]], "closed": true}));
run(&mut s, "solid.extrude", json!({"distance": 15}));
let edge = json!([[20, 0, 15]]);
let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 3, "fit": "taper"}));
let id = out["feature"].as_u64().unwrap();
let tapered = s.model.result(id).and_then(|r| r.state.tapered(id).map(<[_]>::to_vec)).unwrap();
let [(_, t)] = &tapered[..] else { panic!("{tapered:?}") };
let tapered_volume = volume(&mut s);
let radii: Vec<Value> = t.spline.radii.iter().skip(1).map(|r| json!(r)).collect();
let spline = json!({"edges": edge, "radius": t.spline.radii[0], "type": "spline", "start": [t.start.x, t.start.y, t.start.z], "knots": t.spline.knots, "radii": radii});
run(&mut s, "timeline.redefine", json!({"feature": id, "command": "solid.fillet", "params": spline}));
assert!(rel(volume(&mut s), tapered_volume) < 1e-9, "{} vs {tapered_volume}", volume(&mut s));
let json = serde_json::to_string(&s.doc.features).unwrap();
assert!(json.contains("\"type\":\"spline\""), "{json}");
// A control radius raised in the middle: more material goes.
let mut raised = spline.clone();
raised["radii"][2] = json!("4 mm");
run(&mut s, "timeline.redefine", json!({"feature": id, "command": "solid.fillet", "params": raised}));
assert!(volume(&mut s) < tapered_volume);
// Knots that don't match the radii are refused.
let mut bad = spline;
bad["knots"] = json!([0, 1]);
assert!(s.execute("solid.fillet", &bad).is_err());
}
#[test]
+36
View File
@@ -91,3 +91,39 @@ fn exported_binary_stl_with_a_bore_reconstructs_analytic_faces() {
assert_eq!(body.faces(0.01).unwrap().len(), 10);
std::fs::remove_file(path).unwrap();
}
/// #784: STL is closed as written where faces meet (a fillet, a bored hole): every edge of the
/// file, keyed by its exact f32 ends, is shared by exactly two triangles, as in 3MF.
#[test]
fn stl_export_is_closed_where_faces_meet() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 10, "operation": "new", "body_name": "BoxFillet"}));
run(&mut s, "solid.fillet", json!({"body": "BoxFillet", "edges": [[15, 0, 10]], "radius": 2}));
run(&mut s, "solid.cylinder", json!({"diameter": 30, "height": 12, "base": [60, 0, 0], "operation": "new", "body_name": "CylHole"}));
run(&mut s, "solid.hole", json!({"position": [60, 0, 12], "diameter": 6}));
let state = s.model.state();
for name in ["BoxFillet", "CylHole"] {
let body = &state.bodies.iter().find(|b| b.name == name).unwrap().body;
let expected = solidcraft_kernel::measure(body).unwrap().volume;
for format in [solidcraft_io::Format::StlBinary, solidcraft_io::Format::StlAscii] {
let bytes = solidcraft_io::export(&state, &[name.to_string()], format, name).unwrap();
let tris = solidcraft_io::read_stl(&bytes).unwrap();
let key = |p: [f32; 3]| p.map(f32::to_bits);
let mut edges = std::collections::HashMap::new();
for t in &tris {
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
let (a, b) = (key(a), key(b));
*edges.entry(if a < b { (a, b) } else { (b, a) }).or_insert(0) += 1;
}
}
let open = edges.values().filter(|n| **n != 2).count();
assert_eq!(open, 0, "{name} {format:?}: {open} edges not shared by exactly two triangles");
let p = |v: [f32; 3]| Vec3::new(v[0] as f64, v[1] as f64, v[2] as f64);
let v: f64 = tris.iter().map(|t| p(t[0]).dot(p(t[1]).cross(p(t[2]))) / 6.0).sum();
assert!((v - expected).abs() <= expected * 1e-3, "{name} {format:?}: STL volume {v}, body {expected}");
let mf = solidcraft_io::export(&state, &[name.to_string()], solidcraft_io::Format::ThreeMf, name).unwrap();
let (objs, _) = solidcraft_io::read_3mf(&mf).unwrap();
assert_eq!(tris.len(), objs.iter().map(|o| o.triangles.len()).sum::<usize>(), "{name} {format:?}: STL and 3MF triangles");
}
}
}
+23 -1
View File
@@ -202,7 +202,8 @@ fn fillet_into_an_earlier_round() {
}
}
/// Clean seeds and the original seed 5 edge contact, now rejected without distortion.
/// Clean seeds (seed 16: a partial face contact, fused), and the original seed 5 edge contact,
/// now rejected without distortion.
#[test]
fn fixed_seeds_run_clean() {
for seed in FIXED_SEEDS {
@@ -226,6 +227,27 @@ fn fixed_seeds_run_clean() {
assert!(s.model.state().bodies.first().unwrap().body.validity().is_empty());
continue;
}
if *seed == 16 {
// Seed 16 patterns a short cylinder around a block: one copy's end lies on a side of
// the block with half of it past the block's bottom edge (#674). The end and the side
// are split along each other's outline and the half disc they share goes: the block,
// half of the first cylinder (the other half is inside the block) and three whole ones.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 25.0, "width": 47.5, "height": 37.5}));
run(
&mut s,
"solid.cylinder",
json!({"axis": [1.0, 0.0, 0.0], "base": [0.0, 7.5, 0.0], "height": 2.5, "operation": "join", "radius": 5.0}),
);
run(&mut s, "solid.pattern.circular", json!({"axis": "Z", "count": 4, "features": ["Cylinder1"]}));
// (Within the measured round sides' mesh error, about 0.03 mm³ a cylinder.)
let want = 25.0 * 47.5 * 37.5 + 3.5 * PI * 25.0 * 2.5;
assert!((total_volume(&s) - want).abs() < 0.2, "{} vs {want}", total_volume(&s));
assert!(s.model.state().bodies.iter().all(|b| b.body.validity().is_empty()));
let r = fuzz::run(*seed, 8);
assert!(!r.outcome.is_bug(), "seed {seed}: {:?}\n{}", r.outcome, r.script());
continue;
}
let r = fuzz::run(*seed, 8);
assert!(!r.outcome.is_bug(), "seed {seed}: {:?}\n{}", r.outcome, r.script());
}
+66 -6
View File
@@ -39,20 +39,32 @@ pub mod colors {
pub const SECTION_CAP: Rgb = solidcraft_render::SECTION_CAP;
}
/// Bounds of everything visible.
/// Bounds of everything visible now, in world space: shown bodies where their occurrences,
/// joints and moves place them, and the sketches the view draws (not ones used by a feature,
/// hidden ones, or ones in hidden components). What Home, Fit and the orbit pivot frame.
pub fn bounds(s: &Session) -> Aabb3 {
visible_bounds(s, true)
}
/// [`bounds`], with the browser's Sketches folder shown (`sketches`) or hidden.
pub fn visible_bounds(s: &Session, sketches: bool) -> Aabb3 {
let st = s.world_state();
let mut b = Aabb3::EMPTY;
let v = &s.visibility;
for body in st.bodies.iter().filter(|x| s.body_visible(&x.name)) {
b = b.union(&body.mesh().bounds());
}
for ss in st.sketches.iter().filter(|x| !v.hidden_sketches.contains(&x.feature)) {
for ss in st.sketches.iter().filter(|x| sketch_drawn(s, x.feature, sketches)) {
// All four corners: on a tilted plane two opposite ones don't span the rectangle.
if let Some((lo, hi)) = ss.sketch.bounds() {
for p in [lo, hi] {
for p in [lo, hi, solidcraft_geom::Vec2::new(lo.x, hi.y), solidcraft_geom::Vec2::new(hi.x, lo.y)] {
b.add(ss.plane.to_world(p));
}
}
for w in ss.sketch.wires.iter().filter(|w| !w.fit.is_empty() || !ss.sketch.view.hide_projected) {
for p in &w.pts {
b.add(*p);
}
}
}
b
}
@@ -95,7 +107,7 @@ pub fn sketch_lines_with_sampler(
) -> Vec<(Vec<Vec3>, Rgb, bool)> {
let mut out = Vec::new();
for (i, c) in sk.curves.iter().enumerate() {
if c.link.is_some() && sk.view.hide_projected && !sk.is_text_curve(i) {
if !sketch_curve_visible(sk, i) {
continue;
}
let col = if c.link.is_some() && !sk.is_text_curve(i) {
@@ -139,6 +151,24 @@ pub fn sketch_lines_with_sampler(
out
}
/// Should sketch curve `i` be drawn and picked? The Sketch Palette's two geometry toggles each
/// hide one category, and a curve belongs to the category its colour already names in
/// [`sketch_lines_with_sampler`]: a linked curve is projected reference geometry (Projected
/// Geometries), anything else carrying the `construction` line type is construction geometry
/// (Construction Geometries). Text outlines are linked curves that the Text feature draws, so
/// they are never reference geometry and always show. Centerlines are their own line type and
/// no toggle hides them.
///
/// Drawing and picking share this one answer so they cannot drift: geometry that is not on
/// screen is never clickable, and geometry on screen always is.
pub fn sketch_curve_visible(sk: &Sketch, i: usize) -> bool {
let Some(c) = sk.curves.get(i) else { return false };
if c.link.is_some() && !sk.is_text_curve(i) {
return !sk.view.hide_projected;
}
!(c.construction && sk.view.hide_construction)
}
/// Should sketch point `i` be drawn? Text outlines' points are not (they are not picked or
/// constrained individually).
pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool {
@@ -284,8 +314,38 @@ pub fn section_segments(m: &solidcraft_geom::Mesh, o: Vec3, n: Vec3) -> Vec<(Vec
/// Is a sketch drawn? The active one is; others unless hidden. Finished sketches are hidden
/// once a feature uses them, like Fusion's default, unless shown one by one.
pub fn sketch_shown(s: &Session, id: u64) -> bool {
sketch_drawn(s, id, true)
}
/// Is a sketch drawn, with the browser's Sketches folder shown (`folder`) or hidden? The sketch
/// being edited always is; otherwise not when hidden, nor in a hidden component, and one used by
/// a feature only when shown on its own.
pub fn sketch_drawn(s: &Session, id: u64, folder: bool) -> bool {
let v = &s.visibility;
s.active_sketch == Some(id) || (!v.hidden_sketches.contains(&id) && (v.shown_sketches.contains(&id) || !sketch_consumed(s, id)))
if s.active_sketch == Some(id) {
return true;
}
if v.hidden_sketches.contains(&id) || !sketch_component_visible(s, id) {
return false;
}
v.shown_sketches.contains(&id) || (folder && !sketch_consumed(s, id))
}
/// Is the placement a sketch is drawn at shown (the active one while its component is being
/// edited, else the component's first)?
fn sketch_component_visible(s: &Session, id: u64) -> bool {
if s.visibility.hidden_occurrences.is_empty() {
return true;
}
let component = s.doc.feature(id).map_or(0, |f| f.component);
if component == 0 {
return true;
}
let path = match &s.active_occurrence_path {
Some(p) if s.active_component == component => p.clone(),
_ => s.doc.first_path(component),
};
s.occurrence_visible(&path)
}
/// Is a sketch used by a later feature?
+162
View File
@@ -0,0 +1,162 @@
// Home and Fit frame what is visible now: bodies where they are placed (occurrence moves,
// joints, motion), sketches only while they are drawn, nothing hidden or consumed.
use serde_json::{Value, json};
use solidcraft_geom::{Aabb3, 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}"),
}
}
/// What the viewport draws: visible bodies' meshes and the sketches it shows, from the scene.
fn drawn(s: &Session) -> Aabb3 {
let cam = solidcraft_render::Camera { distance: 1e5, ..Default::default() };
let scene = crate::view::scene(s, &cam);
let mut b = Aabb3::EMPTY;
for m in &scene.meshes {
b = b.union(&m.mesh.bounds());
}
use crate::view::colors::{AXIS_X, AXIS_Y, AXIS_Z, GRID, GRID_MAJOR};
for l in scene.lines.iter().filter(|l| ![GRID, GRID_MAJOR, AXIS_X, AXIS_Y, AXIS_Z].contains(&l.color)) {
for p in &l.points {
b.add(*p);
}
}
b
}
fn same(a: Aabb3, b: Aabb3) {
assert!(!a.is_empty() && !b.is_empty(), "{a:?} {b:?}");
assert!(a.min.dist(b.min) < 1e-6 && a.max.dist(b.max) < 1e-6, "Home frames {a:?}, the view shows {b:?}");
}
fn near(a: Aabb3, lo: [f64; 3], hi: [f64; 3]) {
let (lo, hi) = (Vec3::new(lo[0], lo[1], lo[2]), Vec3::new(hi[0], hi[1], hi[2]));
assert!(a.min.dist(lo) < 1e-6 && a.max.dist(hi) < 1e-6, "{a:?} != {lo:?}..{hi:?}");
}
/// A 10 x 10 x 5 block drawn as a sketch and extruded, in the active component.
fn block(s: &mut Session) {
run(s, "sketch.create", json!({"plane": "XY"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 5}));
}
#[test]
fn moved_body_leaves_its_used_sketch_out() {
let mut s = Session::default();
block(&mut s);
near(crate::view::bounds(&s), [0., 0., 0.], [10., 10., 5.]);
let body = s.world_state().bodies[0].name.clone();
run(&mut s, "solid.move", json!({"bodies": [body], "translate": [500, 0, 0]}));
near(crate::view::bounds(&s), [500., 0., 0.], [510., 10., 5.]);
same(crate::view::bounds(&s), drawn(&s));
}
#[test]
fn moved_component_frames_where_it_is_now() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "A"}));
block(&mut s);
run(&mut s, "component.activate", json!({"component": "root"}));
let b = run(&mut s, "component.create", json!({"name": "B"}))["occurrence"].as_u64().unwrap();
block(&mut s);
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": [800, 0, 0]}));
near(crate::view::bounds(&s), [0., 0., 0.], [810., 10., 5.]);
run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": [-780, 0, 0]}));
near(crate::view::bounds(&s), [0., 0., 0.], [30., 10., 5.]);
same(crate::view::bounds(&s), drawn(&s));
// Pending (not captured) moves too.
run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": [0, 300, 0], "capture": false}));
near(crate::view::bounds(&s), [0., 0., 0.], [30., 310., 5.]);
same(crate::view::bounds(&s), drawn(&s));
}
#[test]
fn body_consumed_by_a_join_or_cut_is_not_framed() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [200, 0, 0], "operation": "new"}));
let names: Vec<String> = s.world_state().bodies.iter().map(|b| b.name.clone()).collect();
run(&mut s, "solid.move", json!({"bodies": [names[1].clone()], "translate": [-195, 0, 0]}));
run(&mut s, "solid.combine", json!({"target": names[0].clone(), "tools": [names[1].clone()], "operation": "cut"}));
near(crate::view::bounds(&s), [0., 0., 0.], [5., 10., 10.]);
same(crate::view::bounds(&s), drawn(&s));
}
#[test]
fn hidden_component_is_not_framed() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "A"}));
block(&mut s);
run(&mut s, "component.activate", json!({"component": "root"}));
let b = run(&mut s, "component.create", json!({"name": "B"}))["occurrence"].as_u64().unwrap();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
// An unused sketch in B, drawn while B is shown.
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": [400, 0, 0]}));
near(crate::view::bounds(&s), [0., 0., 0.], [440., 40., 5.]);
same(crate::view::bounds(&s), drawn(&s));
run(&mut s, "browser.visibility", json!({"visible": false, "paths": [[b]]}));
near(crate::view::bounds(&s), [0., 0., 0.], [10., 10., 5.]);
same(crate::view::bounds(&s), drawn(&s));
}
/// Two leaves joined by a hinge (as in the joint tests).
fn hinge(s: &mut Session) {
for name in ["LeafA", "LeafB"] {
run(s, "component.activate", json!({"component": "root"}));
run(s, "component.create", json!({"name": name}));
block_leaf(s);
}
run(s, "component.activate", json!({"component": "root"}));
let a = s.doc.occurrence_of(s.doc.find_component("LeafA").unwrap()).unwrap().id;
run(s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
let b = s.doc.occurrence_of(s.doc.find_component("LeafB").unwrap()).unwrap().id;
// B starts far away; the joint brings it onto A.
run(s, "occurrence.move", json!({"occurrence": b, "translate": [600, 0, 0]}));
run(
s,
"joint.create",
json!({"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [601, 37, 4]}, "flip": true, "name": "Hinge"}),
);
}
fn block_leaf(s: &mut Session) {
// The plate drawn as a sketch (used by its extrude), and the knuckle.
run(s, "sketch.create", json!({"plane": "XY"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 3}));
run(s, "solid.cylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
}
#[test]
fn joint_and_drive_frame_the_joined_place() {
let mut s = Session::default();
hinge(&mut s);
let b = crate::view::bounds(&s);
assert!(b.max.x < 50.0, "the joint brought B onto A: {b:?}");
same(b, drawn(&s));
run(&mut s, "joint.drive", json!({"joint": "Hinge", "value": 90}));
let b = crate::view::bounds(&s);
assert!(b.max.x < 50.0 && b.max.z > 30.0, "{b:?}");
same(b, drawn(&s));
run(&mut s, "motion.study", json!({"name": "Swing", "steps": 10, "keys": [{"joint": "Hinge", "points": [[0, 0], [10, 180]]}]}));
run(&mut s, "motion.play", json!({"study": "Swing", "step": 10}));
let b = crate::view::bounds(&s);
assert!(b.max.x < 50.0 && b.max.y > 60.0, "{b:?}");
same(b, drawn(&s));
}
+41
View File
@@ -71,6 +71,47 @@ fn sampler_keeps_style_precedence_hidden_references_text_and_disjoint_parts() {
assert!(sk.link(&projected).is_some() && sk.link(&text).is_some());
}
/// Construction Geometries hides the `construction` line type and nothing else. Centerlines
/// are their own line type. A projected curve that carries the construction flag (the trace of
/// a plane or an axis) is drawn in the projected colour, so Projected Geometries owns it and
/// Construction Geometries leaves it alone. `sketch_curve_visible` is the one answer the GPU
/// viewport's picking uses as well, so nothing hidden here can still be clicked there.
#[test]
fn hiding_construction_geometry_keeps_ordinary_curves_centerlines_and_projected_traces() {
let mut sk = Sketch::new();
let ordinary = line(&mut sk, 0.);
let construction = line(&mut sk, 1.);
sk.curves[construction].construction = true;
let centerline = line(&mut sk, 2.);
sk.curves[centerline].centerline = true;
let axis = sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(Vec2::new(0., 3.), Vec2::new(2., 3.))]).unwrap();
let trace = sk.link_curves(&axis)[0];
sk.curves[trace].construction = true;
let determined = vec![false; sk.curves.len()];
let drawn = |sk: &Sketch| -> Vec<usize> {
let mut seen = Vec::new();
sketch_lines_with_sampler(sk, &Plane::XY, true, &determined, |_, i, _| {
seen.push(i);
vec![vec![Vec3::ZERO, Vec3::X]]
});
seen
};
assert_eq!(drawn(&sk), vec![ordinary, construction, centerline, trace]);
sk.view.hide_construction = true;
assert_eq!(drawn(&sk), vec![ordinary, centerline, trace], "only the construction curve goes");
for (i, want) in [(ordinary, true), (construction, false), (centerline, true), (trace, true)] {
assert_eq!(sketch_curve_visible(&sk, i), want, "curve {i}");
}
// The projected trace answers to its own toggle, in either order.
sk.view.hide_projected = true;
assert_eq!(drawn(&sk), vec![ordinary, centerline]);
sk.view.hide_construction = false;
assert_eq!(drawn(&sk), vec![ordinary, construction, centerline]);
// A curve index that does not exist is not visible, and nothing panics asking.
assert!(!sketch_curve_visible(&sk, sk.curves.len()));
}
#[test]
fn legacy_paths_match_sampler_wrapper_and_placed_wires_are_unchanged() {
let mut sk = Sketch::new();
+391
View File
@@ -0,0 +1,391 @@
//! Arc fitting: free-form segments (conics, cubic and higher Béziers) as chains of circular
//! arcs and lines within a tolerance, tangent-continuous inside each segment (biarcs).
//!
//! Code that works on lines and arcs alone (planar booleans, the folded sheet-metal solid)
//! keeps smooth curves this way with far fewer pieces than a chord polyline of the same
//! tolerance: a biarc's error falls with the cube of its length, a chord's with the square.
use std::f64::consts::{PI, TAU};
use crate::{Loop2, Region2, Seg2, Vec2};
/// Most pieces one segment is fitted with.
pub const MAX_FIT_PIECES: usize = 2048;
/// Deepest subdivision of one segment.
const MAX_DEPTH: u32 = 30;
/// Samples inside each candidate span that must lie within the tolerance.
const SAMPLES: usize = 12;
impl Seg2 {
/// A straight line or a circular arc (what [`Seg2::fit_arcs`] produces).
pub fn is_line_or_arc(&self) -> bool {
matches!(self, Seg2::Line { .. } | Seg2::Arc { .. })
}
/// Lines and circular arcs that follow the segment within `tol` (mm), in order, the first
/// starting at the segment's start and the last ending at its end; consecutive pieces share
/// their end points and tangents. Lines and arcs come back unchanged. `None` when the
/// segment is not finite or would need more than [`MAX_FIT_PIECES`] pieces.
pub fn fit_arcs(&self, tol: f64) -> Option<Vec<Seg2>> {
if self.is_line_or_arc() {
return Some(vec![*self]);
}
if !(tol.is_finite() && tol > 0.0) {
return None;
}
let tol = tol.max(1e-9);
let mut out: Vec<Seg2> = Vec::new();
// Spans still to fit; the last pushed is fitted first, so pushing the right half before
// the left one keeps the output in parameter order.
let mut todo = vec![(0.0f64, 1.0f64, 0u32)];
let mut steps = 0usize;
while let Some((t0, t1, depth)) = todo.pop() {
steps += 1;
if steps > 4 * MAX_FIT_PIECES {
return None;
}
let (p0, p1) = (self.point_at(t0), self.point_at(t1));
if !(p0.is_finite() && p1.is_finite()) {
return None;
}
if p0.dist(p1) < 1e-12 && self.span_extent(t0, t1) < tol {
continue;
}
let fit = match (self.tangent_out(t0, t1), self.tangent_in(t0, t1)) {
(Some(d0), Some(d1)) => biarc(p0, d0, p1, d1).filter(|arcs| self.within(arcs, t0, t1, tol)),
_ => None,
};
match fit {
Some(arcs) => {
out.extend(arcs);
if out.len() > MAX_FIT_PIECES {
return None;
}
}
None if depth < MAX_DEPTH => {
let tm = 0.5 * (t0 + t1);
todo.push((tm, t1, depth + 1));
todo.push((t0, tm, depth + 1));
}
None => {
// Too fine to split further (a cusp): the chord is within the tolerance
// here or nothing is.
let chord = [Seg2::Line { a: p0, b: p1 }];
if !self.within(&chord, t0, t1, tol) {
return None;
}
out.push(chord[0]);
}
}
}
// The pieces join where their spans meet; pin the outer ends to the segment's own.
if let Some(first) = out.first_mut() {
*first = with_start(*first, self.start());
}
if let Some(last) = out.last_mut() {
*last = with_end(*last, self.end());
}
Some(out)
}
/// Unit direction of travel leaving `t` towards `t1`.
fn tangent_out(&self, t: f64, t1: f64) -> Option<Vec2> {
self.derivative(t).normalized().or_else(|| (self.point_at(t + (t1 - t) * 1e-4) - self.point_at(t)).normalized())
}
/// Unit direction of travel arriving at `t1` from `t0`.
fn tangent_in(&self, t0: f64, t1: f64) -> Option<Vec2> {
self.derivative(t1).normalized().or_else(|| (self.point_at(t1) - self.point_at(t1 - (t1 - t0) * 1e-4)).normalized())
}
/// Largest distance of the span's sample points from its start (a loop's size).
fn span_extent(&self, t0: f64, t1: f64) -> f64 {
let p0 = self.point_at(t0);
(1..=SAMPLES).map(|i| self.point_at(t0 + (t1 - t0) * i as f64 / (SAMPLES + 1) as f64).dist(p0)).fold(0.0, f64::max)
}
/// Do `pieces` follow the span `t0..t1` within `tol` both ways?
fn within(&self, pieces: &[Seg2], t0: f64, t1: f64, tol: f64) -> bool {
if pieces.iter().any(|p| matches!(p, Seg2::Arc { sweep, .. } if sweep.abs() > PI)) {
return false;
}
let samples: Vec<Vec2> = (0..=SAMPLES + 1).map(|i| self.point_at(t0 + (t1 - t0) * i as f64 / (SAMPLES + 1) as f64)).collect();
// The curve near the pieces...
if samples.iter().any(|p| pieces.iter().map(|s| dist_to(s, *p)).fold(f64::INFINITY, f64::min) > tol) {
return false;
}
// ...and the pieces near the curve (their quarter points against the curve's closest
// point on the span).
pieces.iter().all(|s| {
[0.25, 0.5, 0.75].iter().all(|f| {
let q = s.point_at(*f);
self.dist_on_span(q, t0, t1, &samples) <= tol
})
})
}
/// Distance from `q` to the span `t0..t1` (closest sample, then Newton steps).
fn dist_on_span(&self, q: Vec2, t0: f64, t1: f64, samples: &[Vec2]) -> f64 {
let n = samples.len().saturating_sub(1).max(1);
let (k, mut best) = samples.iter().enumerate().map(|(i, p)| (i, p.dist(q))).fold((0, f64::INFINITY), |a, b| if b.1 < a.1 { b } else { a });
let mut t = t0 + (t1 - t0) * k as f64 / n as f64;
for _ in 0..12 {
let (p, d) = (self.point_at(t), self.derivative(t));
let dd = d.dot(d);
if !(dd > 1e-300) {
break;
}
let nt = (t - (p - q).dot(d) / dd).clamp(t0, t1);
best = best.min(self.point_at(nt).dist(q));
if (nt - t).abs() < 1e-15 {
break;
}
t = nt;
}
best
}
}
impl Loop2 {
/// The loop with every free-form segment fitted by lines and arcs (see
/// [`Seg2::fit_arcs`]). `None` when a segment cannot be fitted.
pub fn fit_arcs(&self, tol: f64) -> Option<Loop2> {
let mut segs = Vec::with_capacity(self.segs.len());
for s in &self.segs {
segs.extend(s.fit_arcs(tol)?);
if segs.len() > MAX_FIT_PIECES * 8 {
return None;
}
}
Some(Loop2 { segs })
}
/// Only lines and arcs?
pub fn is_line_arc(&self) -> bool {
self.segs.iter().all(Seg2::is_line_or_arc)
}
}
impl Region2 {
/// Only lines and arcs?
pub fn is_line_arc(&self) -> bool {
self.outer.is_line_arc() && self.holes.iter().all(Loop2::is_line_arc)
}
}
/// Distance from `p` to a line or arc segment (other segments: to their closest point).
fn dist_to(s: &Seg2, p: Vec2) -> f64 {
match *s {
Seg2::Line { a, b } => {
let d = b - a;
let l2 = d.len2();
let t = if l2 > 1e-300 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
(a + d * t).dist(p)
}
Seg2::Arc { center, radius, start, sweep } => {
let ang = (p - center).angle();
let delta = if sweep >= 0.0 { (ang - start).rem_euclid(TAU) } else { (start - ang).rem_euclid(TAU) };
if delta <= sweep.abs() { (p.dist(center) - radius).abs() } else { p.dist(s.start()).min(p.dist(s.end())) }
}
_ => s.point_at(s.closest_param(p)).dist(p),
}
}
/// The two arcs (or lines) from `p0` leaving along unit `d0` to `p1` arriving along unit `d1`,
/// meeting tangentially: the tangent lines from both ends are cut at one length `d`, and the
/// arcs join at the middle of the line between the two cut points (which is `2 d` long).
fn biarc(p0: Vec2, d0: Vec2, p1: Vec2, d1: Vec2) -> Option<Vec<Seg2>> {
let v = p1 - p0;
let vv = v.dot(v);
if vv < 1e-24 {
return None;
}
let len = vv.sqrt();
let chord = v / len;
if d0.cross(chord).abs() < 1e-12 && d1.cross(chord).abs() < 1e-12 && d0.dot(chord) > 0.0 && d1.dot(chord) > 0.0 {
return Some(vec![Seg2::Line { a: p0, b: p1 }]);
}
// |v − d (d0 + d1)| = 2 d, the positive root.
let t = d0 + d1;
let vt = v.dot(t);
let a = 2.0 * (1.0 - d0.dot(d1));
let d = if a < 1e-12 {
if vt <= 1e-12 * len {
return None;
}
vv / (2.0 * vt)
} else {
(-vt + (vt * vt + a * vv).max(0.0).sqrt()) / a
};
if !(d.is_finite() && d > 0.0) {
return None;
}
let j = ((p0 + d0 * d) + (p1 - d1 * d)) * 0.5;
let mut out = Vec::with_capacity(2);
if j.dist(p0) > 1e-12 {
out.push(arc_from(p0, d0, j)?);
}
if j.dist(p1) > 1e-12 {
out.push(arc_from(p1, -d1, j)?.reversed());
}
(!out.is_empty()).then_some(out)
}
/// The arc leaving `p` along unit `d` that ends at `q` (a line when `q` lies straight ahead).
fn arc_from(p: Vec2, d: Vec2, q: Vec2) -> Option<Seg2> {
let c = q - p;
let cc = c.dot(c);
let n = d.perp();
let h = n.dot(c);
// Sagitta of the arc over the chord: a line when the bulge is negligible.
if h.abs() * cc.sqrt() < 1e-12 * cc || (cc / (2.0 * h)).abs() > 1e7 {
return (d.dot(c) > 0.0).then_some(Seg2::Line { a: p, b: q });
}
let r = cc / (2.0 * h);
let center = p + n * r;
let start = (p - center).angle();
let mut sweep = (q - center).angle() - start;
if r > 0.0 {
while sweep <= 0.0 {
sweep += TAU;
}
} else {
while sweep >= 0.0 {
sweep -= TAU;
}
}
(sweep.is_finite() && sweep.abs() <= TAU).then_some(Seg2::Arc { center, radius: r.abs(), start, sweep })
}
/// `s` moved to start exactly at `p` (an arc keeps its centre; `p` is within a rounding error).
fn with_start(s: Seg2, p: Vec2) -> Seg2 {
match s {
Seg2::Line { b, .. } => Seg2::Line { a: p, b },
other => other,
}
}
/// `s` moved to end exactly at `p`.
fn with_end(s: Seg2, p: Vec2) -> Seg2 {
match s {
Seg2::Line { a, .. } => Seg2::Line { a, b: p },
other => other,
}
}
#[cfg(test)]
mod tests {
use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2};
use super::*;
/// Largest distance of 401 points of `seg` from `pieces`: 30 times as many as the fit
/// itself looks at in a span.
fn max_dev(seg: &Seg2, pieces: &[Seg2]) -> f64 {
(0..=400)
.map(|i| seg.point_at(i as f64 / 400.0))
.map(|p| pieces.iter().map(|s| dist_to(s, p)).fold(f64::INFINITY, f64::min))
.fold(0.0, f64::max)
}
fn chained(seg: &Seg2, pieces: &[Seg2]) {
assert!(pieces.first().unwrap().start().dist(seg.start()) < 1e-9);
assert!(pieces.last().unwrap().end().dist(seg.end()) < 1e-9);
for w in pieces.windows(2) {
assert!(w[0].end().dist(w[1].start()) < 1e-9, "{w:?}");
// Tangent-continuous inside the segment (biarc joints and span joints).
assert!(w[0].end_tangent().dot(w[1].start_tangent()) > 1.0 - 1e-6, "{w:?}");
}
assert!(pieces.iter().all(Seg2::is_line_or_arc));
}
fn ellipse(c: Vec2, a: f64, b: f64) -> Loop2 {
let corners = [c + Vec2::new(a, 0.0), c + Vec2::new(0.0, b), c - Vec2::new(a, 0.0), c - Vec2::new(0.0, b)];
Loop2 {
segs: (0..4)
.map(|i| Seg2::Conic { a: corners[i], apex: corners[i] + corners[(i + 1) % 4] - c, b: corners[(i + 1) % 4], w: FRAC_1_SQRT_2 })
.collect(),
}
}
#[test]
fn a_circular_arc_given_as_a_conic_comes_back_as_arcs_of_its_circle() {
// A quarter circle of radius 10 about (5, −3): the biarc through its ends and end
// tangents lies on the circle itself.
let c = Vec2::new(5.0, -3.0);
let q = Seg2::Conic { a: c + Vec2::new(10.0, 0.0), apex: c + Vec2::new(10.0, 10.0), b: c + Vec2::new(0.0, 10.0), w: FRAC_1_SQRT_2 };
let arcs = q.fit_arcs(1e-6).unwrap();
assert_eq!(arcs.len(), 2, "{arcs:?}");
let mut total = 0.0;
for s in &arcs {
let Seg2::Arc { center, radius, sweep, .. } = *s else { panic!("{s:?}") };
assert!(center.dist(c) < 1e-9 && (radius - 10.0).abs() < 1e-9, "{s:?}");
total += sweep;
}
assert!((total - FRAC_PI_2).abs() < 1e-9, "{total}");
chained(&q, &arcs);
}
#[test]
fn quarter_ellipse_fits_within_the_tolerance_with_few_arcs() {
let q = ellipse(Vec2::new(200.0, 150.0), 30.0, 12.0).segs[0];
for tol in [1e-2, 1e-3, 1e-4] {
let arcs = q.fit_arcs(tol).unwrap();
chained(&q, &arcs);
assert!(max_dev(&q, &arcs) <= tol * 1.01, "tol {tol}: {}", max_dev(&q, &arcs));
// Far fewer pieces than a chord polyline of the same tolerance.
let chords = q.polyline(tol).len() - 1;
assert!(arcs.len() * 2 < chords, "tol {tol}: {} arcs vs {chords} chords", arcs.len());
}
// The chords of 0.001 mm: √(2·√(30² + 12²) / 0.008) = 89.9, so 90.
assert_eq!(q.polyline(1e-3).len() - 1, 90);
assert!(q.fit_arcs(1e-3).unwrap().len() <= 32);
}
#[test]
fn glyph_like_quadratics_cubics_and_s_curves_fit() {
let segs = [
Seg2::Conic { a: Vec2::new(0.0, 0.0), apex: Vec2::new(1.0, 2.0), b: Vec2::new(3.0, 0.5), w: 1.0 },
Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(1.0, 3.0), p2: Vec2::new(2.0, -3.0), p3: Vec2::new(3.0, 0.0) },
// A cusp-like cubic and one with a coincident control point.
Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(4.0, 2.0), p2: Vec2::new(-1.0, 2.0), p3: Vec2::new(3.0, 0.0) },
Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(0.0, 0.0), p2: Vec2::new(2.0, 1.0), p3: Vec2::new(3.0, 0.0) },
Seg2::Conic { a: Vec2::new(0.0, 0.0), apex: Vec2::new(5.0, 5.0), b: Vec2::new(10.0, 0.0), w: 3.0 },
crate::bezier(&[Vec2::new(0.0, 0.0), Vec2::new(1.0, 2.0), Vec2::new(2.0, -1.0), Vec2::new(3.0, 2.0), Vec2::new(4.0, 0.0)]).unwrap(),
];
for s in segs {
let arcs = s.fit_arcs(1e-3).unwrap();
assert!(max_dev(&s, &arcs) <= 1.01e-3, "{s:?}: {}", max_dev(&s, &arcs));
assert!(arcs.first().unwrap().start().dist(s.start()) < 1e-9 && arcs.last().unwrap().end().dist(s.end()) < 1e-9);
for w in arcs.windows(2) {
assert!(w[0].end().dist(w[1].start()) < 1e-9);
}
}
// A straight "curve" is one line; lines and arcs come back as they are.
let flat = Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(1.0, 0.0), p2: Vec2::new(2.0, 0.0), p3: Vec2::new(3.0, 0.0) };
assert_eq!(flat.fit_arcs(1e-3).unwrap(), vec![Seg2::Line { a: Vec2::ZERO, b: Vec2::new(3.0, 0.0) }]);
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 1.0 };
assert_eq!(arc.fit_arcs(1e-3).unwrap(), vec![arc]);
// Hostile input.
let bad = Seg2::Conic { a: Vec2::new(f64::NAN, 0.0), apex: Vec2::ZERO, b: Vec2::new(1.0, 0.0), w: 1.0 };
assert!(bad.fit_arcs(1e-3).is_none());
assert!(segs[0].fit_arcs(0.0).is_none() && segs[0].fit_arcs(f64::NAN).is_none());
}
#[test]
fn fitted_ellipse_loop_keeps_its_area() {
let lp = ellipse(Vec2::ZERO, 15.0, 8.0);
let fitted = lp.fit_arcs(1e-3).unwrap();
assert!(fitted.is_closed(1e-9) && fitted.is_line_arc() && !lp.is_line_arc());
// π·a·b, within the tolerance times the perimeter (74 mm).
let exact = PI * 15.0 * 8.0;
assert!((lp.signed_area() - exact).abs() < 1e-9, "{}", lp.signed_area());
assert!((fitted.signed_area() - exact).abs() < 1e-3 * 74.0, "{} vs {exact}", fitted.signed_area());
assert!(fitted.segs.len() * 2 < lp.polyline(1e-3).len(), "{} pieces", fitted.segs.len());
// As a hole of a 40 × 20 plate.
let plate = Loop2::polygon(&[Vec2::new(-20.0, -10.0), Vec2::new(20.0, -10.0), Vec2::new(20.0, 10.0), Vec2::new(-20.0, 10.0)]);
let (free, arcs) = (Region2 { outer: plate.clone(), holes: vec![lp.reversed()] }, Region2 { outer: plate, holes: vec![fitted.reversed()] });
assert!(arcs.is_line_arc() && !free.is_line_arc());
assert!((free.area() - (800.0 - exact)).abs() < 1e-9 && (arcs.area() - (800.0 - exact)).abs() < 1e-3 * 74.0, "{}", arcs.area());
}
}
+3
View File
@@ -8,6 +8,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod arcfit;
pub mod brep;
pub mod bspline;
pub mod env;
@@ -15,9 +16,11 @@ pub mod fill;
mod mesh;
mod plane;
mod profile;
pub mod tess;
pub mod tspline;
mod vec;
pub use arcfit::MAX_FIT_PIECES;
pub use mesh::{Aabb3, InsideIndex, Mesh, MeshMeasure};
pub use plane::Plane;
pub use profile::{Loop2, MAX_BEZIER_DEGREE, Region2, Seg2, bezier};
+495
View File
@@ -0,0 +1,495 @@
//! Display tessellation plans: the parameter-space triangulation of every face of a body,
//! with the surfaces to evaluate it on, kept apart from the evaluation itself.
//!
//! The kernel decides *where* the vertices go (in each face's (u, v) parameters, with the
//! shared edge nodes fixed in 3D so neighbouring faces meet exactly) and *how* they are
//! connected. Evaluating the surfaces at those parameters is the bulk of the work and has no
//! branching on topology, so it can run anywhere: [`TessPlan::evaluate`] does it on the CPU,
//! and the GPU evaluator runs the same maths in a compute shader. Both evaluate the
//! [`EvalSurface`] forms defined here, so the two cannot drift apart.
//!
//! References: Piegl & Tiller, *The NURBS Book* (2nd ed.), A2.1 (span), A2.3 (basis
//! functions and derivatives), §4.5 (derivatives of a rational surface).
use crate::bspline::MAX_SPLINE_DEGREE;
use crate::{Mesh, Vec3};
/// Most knots in one evaluable knot vector (bounds the shader's span search).
pub const MAX_EVAL_KNOTS: usize = 1 << 12;
/// Most control points in one evaluable surface.
pub const MAX_EVAL_POINTS: usize = 1 << 18;
/// Basis slots for the highest evaluable degree.
const W: usize = MAX_SPLINE_DEGREE + 1;
/// A rational B-spline curve: homogeneous control points `[x·w, y·w, z·w, w]`. Outside its
/// domain it continues as the polynomial of the end span (a line extends for ever).
#[derive(Clone, Debug, PartialEq)]
pub struct EvalCurve {
pub degree: usize,
pub knots: Vec<f64>,
pub ctrl: Vec<[f64; 4]>,
}
/// A surface in a form both evaluators understand.
#[derive(Clone, Debug, PartialEq)]
pub enum EvalSurface {
/// `origin + u·du + v·dv`.
Plane { origin: Vec3, du: Vec3, dv: Vec3 },
/// A rational tensor B-spline; control point `(i, j)` is `ctrl[j·count[0] + i]`.
Nurbs { degree: [usize; 2], knots: [Vec<f64>; 2], count: [usize; 2], ctrl: Vec<[f64; 4]> },
/// `profile(u)` turned by the angle `v` (right-handed) about the axis through `origin`
/// along the unit `axis`, then mapped by the affine `transform` (row-major 3×4); with
/// `swap` the parameters are exchanged first (the surface is evaluated at `(v, u)`).
Revolved { profile: EvalCurve, origin: Vec3, axis: Vec3, transform: [[f64; 4]; 3], swap: bool },
}
/// One face's share of a [`TessPlan`].
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FacePlan {
/// The B-rep face (index in the body's face order).
pub face: u32,
/// Index into [`TessPlan::surfaces`]; `None` when every position and normal is given.
pub surface: Option<u32>,
/// Parameters of every vertex.
pub uv: Vec<[f64; 2]>,
/// Positions of the first `fixed.len()` vertices: the shared edge nodes, used exactly as
/// they are so the neighbouring face gets bit-identical boundary points.
pub fixed: Vec<Vec3>,
/// Normals of every vertex, only for faces without an evaluable surface.
pub normals: Vec<Vec3>,
/// The face is reversed against its surface: normals point the other way.
pub flip: bool,
/// Counter-clockwise seen from outside (orientation already applied).
pub triangles: Vec<[u32; 3]>,
}
/// A body's display tessellation, ready to evaluate.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TessPlan {
pub surfaces: Vec<EvalSurface>,
pub faces: Vec<FacePlan>,
/// B-rep edges as polylines (the shared edge nodes), in the body's edge order.
pub edges: Vec<Vec<Vec3>>,
/// Per edge: the faces it bounds.
pub edge_faces: Vec<Vec<u32>>,
}
impl TessPlan {
/// Evaluate on the CPU into an indexed mesh (vertices are shared within a face, not
/// across faces, so creases keep their own normals).
pub fn evaluate(&self) -> Mesh {
let mut out = Mesh::default();
for f in &self.faces {
let base = out.positions.len();
let surface = f.surface.and_then(|i| self.surfaces.get(i as usize));
for (k, uv) in f.uv.iter().enumerate() {
let (p, n) = match surface {
Some(s) => s.eval(uv[0], uv[1]),
None => (Vec3::ZERO, f.normals.get(k).copied().unwrap_or(Vec3::Z)),
};
let p = f.fixed.get(k).copied().unwrap_or(p);
out.positions.push(p);
out.normals.push(if f.flip { -n } else { n });
}
let n = f.uv.len();
for t in &f.triangles {
if t.iter().all(|&i| (i as usize) < n) {
let at = |i: u32| u32::try_from(base + i as usize).unwrap_or(u32::MAX);
out.triangles.push([at(t[0]), at(t[1]), at(t[2])]);
out.tri_face.push(f.face);
}
}
}
out.edges = self.edges.clone();
out.edge_faces = self.edge_faces.clone();
out
}
/// Vertices across all faces.
pub fn vertex_count(&self) -> usize {
self.faces.iter().map(|f| f.uv.len()).sum()
}
}
impl EvalCurve {
/// Point and first derivative at `t`; `None` if the curve is malformed or its weight
/// vanishes there.
pub fn eval(&self, t: f64) -> Option<(Vec3, Vec3)> {
let p = self.degree;
let n = self.ctrl.len();
if p == 0 || p > MAX_SPLINE_DEGREE || n <= p || self.knots.len() != n + p + 1 || !t.is_finite() {
return None;
}
let span = span_of(&self.knots, p, n, t);
let ders = basis_ders(&self.knots, span, p, t)?;
let (mut a, mut da) = ([0.0; 4], [0.0; 4]);
for (k, (n0, n1)) in ders[0].iter().zip(&ders[1]).take(p + 1).enumerate() {
let c = self.ctrl.get(span + k - p)?;
for d in 0..4 {
a[d] += n0 * c[d];
da[d] += n1 * c[d];
}
}
rational(a, da)
}
}
impl EvalSurface {
/// Point and unit normal (`S_u × S_v`, oriented as the surface) at `(u, v)`. A degenerate
/// point (a cone's apex, a sphere's pole) takes its normal from a step inside; anything
/// that still fails gives the origin and +Z rather than a NaN.
pub fn eval(&self, u: f64, v: f64) -> (Vec3, Vec3) {
let Some((p, du, dv)) = self.derivs(u, v) else { return (Vec3::ZERO, Vec3::Z) };
let n = self.oriented(du.cross(dv));
if let Some(n) = n.normalized().filter(|_| n.len2() > 1e-20 * (du.len2() * dv.len2()).max(1e-300)) {
return (p, n);
}
// Step into the parameter domain around the degenerate point.
let h = 1e-6 * (1.0 + u.abs().max(v.abs()));
for (su, sv) in [(1.0, 1.0), (-1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)] {
if let Some((_, a, b)) = self.derivs(u + su * h, v + sv * h)
&& let Some(n) = self.oriented(a.cross(b)).normalized()
{
return (p, n);
}
}
(p, Vec3::Z)
}
/// Mirror transforms reverse the cross product of the mapped derivatives.
fn oriented(&self, n: Vec3) -> Vec3 {
match self {
EvalSurface::Revolved { transform: m, .. } if det3(m) < 0.0 => -n,
_ => n,
}
}
/// Point and first partial derivatives.
pub fn derivs(&self, u: f64, v: f64) -> Option<(Vec3, Vec3, Vec3)> {
if !(u.is_finite() && v.is_finite()) {
return None;
}
match self {
EvalSurface::Plane { origin, du, dv } => Some((*origin + *du * u + *dv * v, *du, *dv)),
EvalSurface::Nurbs { degree, knots, count, ctrl } => nurbs_derivs(*degree, knots, *count, ctrl, u, v),
EvalSurface::Revolved { profile, origin, axis, transform, swap } => {
let (s, a) = if *swap { (v, u) } else { (u, v) };
let (c, dc) = profile.eval(s)?;
let (sin, cos) = a.sin_cos();
let rot = |x: Vec3| x * cos + axis.cross(x) * sin + *axis * (axis.dot(x) * (1.0 - cos));
let r = c - *origin;
let p = *origin + rot(r);
let ds = rot(dc);
// d/dθ of the rotated point: axis × (rotated radius).
let da = axis.cross(rot(r));
let (p, ds, da) = (affine(transform, p), linear(transform, ds), linear(transform, da));
Some(if *swap { (p, da, ds) } else { (p, ds, da) })
}
}
}
}
fn affine(m: &[[f64; 4]; 3], p: Vec3) -> Vec3 {
linear(m, p) + Vec3::new(m[0][3], m[1][3], m[2][3])
}
fn linear(m: &[[f64; 4]; 3], v: Vec3) -> Vec3 {
let row = |r: &[f64; 4]| r[0] * v.x + r[1] * v.y + r[2] * v.z;
Vec3::new(row(&m[0]), row(&m[1]), row(&m[2]))
}
fn det3(m: &[[f64; 4]; 3]) -> f64 {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
}
/// The point and derivative of a rational function from its homogeneous value and derivative.
fn rational(a: [f64; 4], da: [f64; 4]) -> Option<(Vec3, Vec3)> {
let w = a[3];
if !(w.abs() > 1e-300) {
return None;
}
let p = Vec3::new(a[0] / w, a[1] / w, a[2] / w);
let d = (Vec3::new(da[0], da[1], da[2]) - p * da[3]) * (1.0 / w);
(p.is_finite() && d.is_finite()).then_some((p, d))
}
fn nurbs_derivs(degree: [usize; 2], knots: &[Vec<f64>; 2], count: [usize; 2], ctrl: &[[f64; 4]], u: f64, v: f64) -> Option<(Vec3, Vec3, Vec3)> {
let [pu, pv] = degree;
let [nu, nv] = count;
let ok = |p: usize, n: usize, k: &[f64]| (1..=MAX_SPLINE_DEGREE).contains(&p) && n > p && k.len() == n + p + 1;
if !ok(pu, nu, &knots[0]) || !ok(pv, nv, &knots[1]) || nu.checked_mul(nv)? != ctrl.len() {
return None;
}
let (su, sv) = (span_of(&knots[0], pu, nu, u), span_of(&knots[1], pv, nv, v));
let (bu, bv) = (basis_ders(&knots[0], su, pu, u)?, basis_ders(&knots[1], sv, pv, v)?);
let (mut s, mut s_u, mut s_v) = ([0.0; 4], [0.0; 4], [0.0; 4]);
for (b, (nv0, nv1)) in bv[0].iter().zip(&bv[1]).take(pv + 1).enumerate() {
for (a, (nu0, nu1)) in bu[0].iter().zip(&bu[1]).take(pu + 1).enumerate() {
let c = ctrl.get((sv + b - pv) * nu + su + a - pu)?;
for d in 0..4 {
s[d] += nu0 * nv0 * c[d];
s_u[d] += nu1 * nv0 * c[d];
s_v[d] += nu0 * nv1 * c[d];
}
}
}
let (p, du) = rational(s, s_u)?;
let (_, dv) = rational(s, s_v)?;
Some((p, du, dv))
}
/// The knot span used at `t`: the last non-empty span starting at or before `t`, clamped to
/// the valid spans `p ≤ k < n` so parameters just outside the domain extend the end spans.
fn span_of(knots: &[f64], p: usize, n: usize, t: f64) -> usize {
let mut k = p;
for i in p..n {
if let (Some(&a), Some(&b)) = (knots.get(i), knots.get(i + 1))
&& a < b
{
if a <= t {
k = i;
} else {
break;
}
}
}
k
}
/// Non-zero basis functions of degree `p` on span `span` and their first derivatives
/// (NURBS Book A2.3 with n = 1). `ders[0][j]` is `N_{span−p+j}`, `ders[1][j]` its derivative.
fn basis_ders(knots: &[f64], span: usize, p: usize, t: f64) -> Option<[[f64; W]; 2]> {
if p == 0 || p >= W {
return None;
}
let kn = |i: usize| knots.get(i).copied();
// ndu[j][r]: basis values (upper triangle incl. diagonal), knot differences (lower).
let mut ndu = [[0.0f64; W]; W];
let (mut left, mut right) = ([0.0f64; W], [0.0f64; W]);
ndu[0][0] = 1.0;
for j in 1..=p {
left[j] = t - kn((span + 1).checked_sub(j)?)?;
right[j] = kn(span + j)? - t;
let mut saved = 0.0;
for r in 0..j {
ndu[j][r] = right[r + 1] + left[j - r];
let temp = if ndu[j][r] != 0.0 { ndu[r][j - 1] / ndu[j][r] } else { 0.0 };
ndu[r][j] = saved + right[r + 1] * temp;
saved = left[j - r] * temp;
}
ndu[j][j] = saved;
}
let mut ders = [[0.0f64; W]; 2];
for j in 0..=p {
ders[0][j] = ndu[j][p];
}
// First derivative: p·(N_{r,p−1}/(u_{r+p}−u_r) − N_{r+1,p−1}/(u_{r+p+1}−u_{r+1})).
for r in 0..=p {
let mut d = 0.0;
if r >= 1 {
let den = ndu[p][r - 1];
if den != 0.0 {
d += ndu[r - 1][p - 1] / den;
}
}
if r < p {
let den = ndu[p][r];
if den != 0.0 {
d -= ndu[r][p - 1] / den;
}
}
ders[1][r] = d * p as f64;
}
Some(ders)
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: Vec3, b: Vec3, tol: f64) -> bool {
a.dist(b) <= tol
}
fn line(a: Vec3, b: Vec3) -> EvalCurve {
EvalCurve { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], ctrl: vec![[a.x, a.y, a.z, 1.0], [b.x, b.y, b.z, 1.0]] }
}
const IDENTITY: [[f64; 4]; 3] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
#[test]
fn plane_point_and_normal() {
let s = EvalSurface::Plane { origin: Vec3::new(1.0, 2.0, 3.0), du: Vec3::X, dv: Vec3::Y };
let (p, n) = s.eval(2.0, 3.0);
assert!(close(p, Vec3::new(3.0, 5.0, 3.0), 1e-12));
assert!(close(n, Vec3::Z, 1e-12));
}
#[test]
fn revolved_line_is_a_cylinder() {
// A vertical line at radius 2 turned about Z.
let s = EvalSurface::Revolved {
profile: line(Vec3::new(2.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 5.0)),
origin: Vec3::ZERO,
axis: Vec3::Z,
transform: IDENTITY,
swap: false,
};
for &(u, v) in &[(0.0, 0.0), (0.5, 1.0), (1.0, 4.0), (0.25, 6.0)] {
let (p, n) = s.eval(u, v);
assert!(close(p, Vec3::new(2.0 * v.cos(), 2.0 * v.sin(), 5.0 * u), 1e-12), "{p:?}");
// S_u = +Z, S_v = tangential: the normal points inward (−radial) for this profile.
assert!(close(n, Vec3::new(-v.cos(), -v.sin(), 0.0), 1e-12), "{n:?}");
}
}
#[test]
fn swapped_parameters_reverse_the_normal() {
let mk = |swap| EvalSurface::Revolved {
profile: line(Vec3::new(2.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 5.0)),
origin: Vec3::ZERO,
axis: Vec3::Z,
transform: IDENTITY,
swap,
};
let (p0, n0) = mk(false).eval(0.3, 1.1);
let (p1, n1) = mk(true).eval(1.1, 0.3);
assert!(close(p0, p1, 1e-12));
assert!(close(n0, -n1, 1e-12));
}
#[test]
fn mirror_transform_keeps_the_normal_outward() {
let mirror = [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
let s = EvalSurface::Revolved {
profile: line(Vec3::new(2.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 5.0)),
origin: Vec3::ZERO,
axis: Vec3::Z,
transform: mirror,
swap: false,
};
let (p, n) = s.eval(0.5, 0.0);
assert!(close(p, Vec3::new(-2.0, 0.0, 2.5), 1e-12));
// The unmirrored normal at θ = 0 is −X; mirrored it is +X, still pointing to the axis.
assert!(close(n, Vec3::X, 1e-12), "{n:?}");
}
#[test]
fn rational_quarter_circle_profile_is_exact() {
// A quarter circle of radius 3 in the XZ plane, turned about Z: a sphere zone.
let w = std::f64::consts::FRAC_1_SQRT_2;
let profile = EvalCurve {
degree: 2,
knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
ctrl: vec![[3.0, 0.0, 0.0, 1.0], [3.0 * w, 0.0, 3.0 * w, w], [0.0, 0.0, 3.0, 1.0]],
};
let s = EvalSurface::Revolved { profile, origin: Vec3::ZERO, axis: Vec3::Z, transform: IDENTITY, swap: false };
for i in 0..=10 {
for j in 0..=6 {
let (u, v) = (i as f64 / 10.0, j as f64);
let (p, n) = s.eval(u, v);
assert!((p.len() - 3.0).abs() < 1e-12, "{p:?}");
// At the pole (u = 1) the normal comes from a step inside: within ~1e-6 rad.
assert!(n.cross(p).len() < 3e-5, "normal {n:?} not radial at {p:?}");
}
}
}
#[test]
fn bilinear_nurbs_patch_and_derivatives() {
// Degree (1, 1) over [0, 2] × [0, 1] with corners lifted: a hyperbolic paraboloid.
let ctrl = vec![[0.0, 0.0, 0.0, 1.0], [2.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [2.0, 1.0, 1.0, 1.0]];
let s = EvalSurface::Nurbs { degree: [1, 1], knots: [vec![0.0, 0.0, 2.0, 2.0], vec![0.0, 0.0, 1.0, 1.0]], count: [2, 2], ctrl };
let Some((p, du, dv)) = s.derivs(1.0, 0.5) else { panic!("evaluates") };
assert!(close(p, Vec3::new(1.0, 0.5, 0.25), 1e-12), "{p:?}");
assert!(close(du, Vec3::new(1.0, 0.0, 0.25), 1e-12), "{du:?}");
assert!(close(dv, Vec3::new(0.0, 1.0, 0.5), 1e-12), "{dv:?}");
}
#[test]
fn cubic_nurbs_matches_the_geom_surface() {
// Cross-check the evaluator against `bspline::NurbsSurface::eval` on a weighted patch.
let knots = [vec![0.0, 0.0, 0.0, 0.0, 0.4, 1.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 0.0, 1.0, 2.0, 2.0, 2.0]];
let (nu, nv) = (5, 4);
let mut pts = Vec::new();
let mut wts = Vec::new();
for j in 0..nv {
for i in 0..nu {
pts.push(Vec3::new(i as f64, j as f64, ((i * 7 + j * 3) % 5) as f64 * 0.3));
wts.push(1.0 + ((i + 2 * j) % 3) as f64 * 0.25);
}
}
let Ok(reference) = crate::bspline::NurbsSurface::new([3, 2], knots.clone(), &pts, &wts) else { panic!("valid surface") };
let ctrl = reference.homogeneous().iter().map(|h| [h.x, h.y, h.z, h.w]).collect();
let s = EvalSurface::Nurbs { degree: [3, 2], knots, count: [nu, nv], ctrl };
for i in 0..=8 {
for j in 0..=8 {
let (u, v) = (i as f64 / 8.0, j as f64 / 4.0);
let Ok(want) = reference.eval(u, v) else { panic!("in domain") };
let Some((p, du, dv)) = s.derivs(u, v) else { panic!("evaluates") };
assert!(close(p, want, 1e-12), "({u}, {v}): {p:?} vs {want:?}");
// Derivatives against central differences (one-sided at the domain ends).
let h = 1e-6;
let fd = |a: (f64, f64), b: (f64, f64)| {
let (Some(pa), Some(pb)) = (s.derivs(a.0, a.1), s.derivs(b.0, b.1)) else { panic!("evaluates") };
(pb.0 - pa.0) * (1.0 / (b.0 - a.0 + b.1 - a.1))
};
let fu = fd((u.max(h) - h, v), ((u + h).min(1.0), v));
let fv = fd((u, v.max(h) - h), (u, (v + h).min(2.0)));
assert!(close(du, fu, 1e-4 * (1.0 + du.len())), "du at ({u}, {v}): {du:?} vs {fu:?}");
assert!(close(dv, fv, 1e-4 * (1.0 + dv.len())), "dv at ({u}, {v}): {dv:?} vs {fv:?}");
}
}
}
#[test]
fn hostile_surfaces_do_not_panic() {
let bad = [
EvalSurface::Nurbs { degree: [9, 1], knots: [vec![0.0; 3], vec![0.0; 3]], count: [1, 1], ctrl: vec![] },
EvalSurface::Nurbs {
degree: [1, 1],
knots: [vec![0.0, 0.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]],
count: [2, 2],
ctrl: vec![[0.0; 4]; 4],
},
EvalSurface::Revolved {
profile: EvalCurve { degree: 0, knots: vec![], ctrl: vec![] },
origin: Vec3::ZERO,
axis: Vec3::Z,
transform: IDENTITY,
swap: false,
},
];
for s in &bad {
for &(u, v) in &[(0.5, 0.5), (f64::NAN, 0.0), (1e300, -1e300)] {
let (p, n) = s.eval(u, v);
assert!(p.is_finite() && n.is_finite());
}
}
}
#[test]
fn evaluate_uses_fixed_points_and_drops_bad_indices() {
let plan = TessPlan {
surfaces: vec![EvalSurface::Plane { origin: Vec3::ZERO, du: Vec3::X, dv: Vec3::Y }],
faces: vec![FacePlan {
face: 3,
surface: Some(0),
uv: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
fixed: vec![Vec3::new(0.0, 0.0, 1e-9)],
normals: vec![],
flip: true,
triangles: vec![[0, 1, 2], [0, 1, 7]],
}],
edges: vec![],
edge_faces: vec![],
};
let m = plan.evaluate();
assert_eq!(m.positions.len(), 3);
assert_eq!(m.positions.first().copied(), Some(Vec3::new(0.0, 0.0, 1e-9)));
assert_eq!(m.triangles, vec![[0, 1, 2]]);
assert_eq!(m.tri_face, vec![3]);
assert!(m.normals.iter().all(|n| close(*n, -Vec3::Z, 1e-12)));
}
}
+21 -7
View File
@@ -222,6 +222,11 @@ pub fn export_mesh(b: &solidcraft_kernel::Body) -> Result<Mesh> {
Ok(b.tessellate((b.size() * 2e-4).max(1e-3))?)
}
/// Distance within which an exported mesh's vertices are welded (STL and 3MF alike).
fn weld_eps(b: &solidcraft_kernel::Body) -> f64 {
(b.size() * 1e-6).max(1e-7)
}
/// Binary STL of several meshes (one solid).
pub fn stl_binary(meshes: &[Mesh], name: &str) -> Vec<u8> {
let n: usize = meshes.iter().map(|m| m.triangles.len()).sum();
@@ -292,13 +297,22 @@ pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str)
return Err(IoError::Empty);
}
Ok(match format {
Format::StlBinary | Format::StlAscii | Format::Obj => {
Format::StlBinary | Format::StlAscii => {
// Each body welded on its own (as for 3MF), so shared vertices are written identically
// and the file is closed where faces meet.
let meshes: Vec<Mesh> = sel
.iter()
.map(|b| {
let m = export_mesh(&b.body)?;
let (positions, triangles) = weld(&m.positions, &m.triangles, weld_eps(&b.body));
Ok(Mesh { positions, triangles, ..Mesh::default() })
})
.collect::<Result<_>>()?;
if format == Format::StlBinary { stl_binary(&meshes, name) } else { stl_ascii(&meshes, name).into_bytes() }
}
Format::Obj => {
let meshes: Vec<(String, Mesh)> = sel.iter().map(|b| Ok((b.name.clone(), export_mesh(&b.body)?))).collect::<Result<_>>()?;
match format {
Format::StlBinary => stl_binary(&meshes.into_iter().map(|(_, m)| m).collect::<Vec<_>>(), name),
Format::StlAscii => stl_ascii(&meshes.into_iter().map(|(_, m)| m).collect::<Vec<_>>(), name).into_bytes(),
_ => obj(&meshes).into_bytes(),
}
obj(&meshes).into_bytes()
}
Format::Step => {
let bs: Vec<solidcraft_kernel::ExportBody> =
@@ -317,7 +331,7 @@ pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str)
.iter()
.map(|b| {
let m = export_mesh(&b.body)?;
let eps = (b.body.size() * 1e-6).max(1e-7);
let eps = weld_eps(&b.body);
// Faces with colours of their own tag their triangles.
let paint = b.body.paint();
let mut looks = Vec::new();
+12 -1
View File
@@ -109,7 +109,7 @@ pub fn weld_tagged(positions: &[Vec3], triangles: &[[u32; 3]], tags: &[u32], eps
'search: for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
for &i in grid.get(&[c[0] + dx, c[1] + dy, c[2] + dz]).into_iter().flatten() {
for &i in grid.get(&[c[0].saturating_add(dx), c[1].saturating_add(dy), c[2].saturating_add(dz)]).into_iter().flatten() {
if verts.get(i as usize).is_some_and(|q| q.dist(*p) <= eps) {
found = Some(i);
break 'search;
@@ -798,4 +798,15 @@ mod tests {
assert!(objs.len() < 4_000, "the shared budget must stop the later items: {} built", objs.len());
assert!(warnings.iter().any(|w| w.contains("too many triangles")), "{warnings:?}");
}
/// A body far from the origin (a pattern spacing of "1e12 mm" gets there) welds without
/// overflowing the grid's cell indices.
#[test]
fn weld_survives_extreme_coordinates() {
let o = Vec3::new(1e12, 0.0, 0.0);
let positions = [o, o + Vec3::new(0.1, 0.0, 0.0), o + Vec3::new(0.0, 0.1, 0.0), o + Vec3::new(0.0, 0.0, 0.1)];
let triangles = [[0, 2, 1], [0, 1, 3], [1, 2, 3], [2, 0, 3]];
let (v, t) = weld(&positions, &triangles, 1e-7);
assert_eq!((v.len(), t.len()), (4, 4));
}
}
+4
View File
@@ -19,3 +19,7 @@ truck-shapeops = { workspace = true }
truck-meshalgo = { workspace = true }
truck-stepio = { workspace = true }
truck-topology = { workspace = true }
spade = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon = { workspace = true }
+54 -5
View File
@@ -823,6 +823,11 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
// that earlier blends left between selected edges, so corners round off), a single edge,
// or a chain of curved edges.
let mut todo: Vec<Vec3> = edges.to_vec();
// Edges refused since the last blend that could have changed them: (pick, the box around the
// edge, the refusal). A blend only changes the body near the edges it rounds, so an edge
// refused away from every later blend would be refused the same way again: it is not tried
// again (a refusal costs a rolling-ball attempt, seconds each on a long curved edge).
let mut refused: Vec<(Vec3, (Vec3, Vec3), KernelError)> = Vec::new();
for _ in 0..(2 * edges.len() + 4) {
if todo.is_empty() {
return Ok(cur);
@@ -835,13 +840,21 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
&& let Some(Ok((res, rec))) = crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round)
&& let Ok(b) = guard(what, || Ok(res)).and_then(|res| record::rebuilt(res, &[&solid], &rec))
{
let near = edges_box(&solid, &ids, r + tol);
refused.retain(|(_, b, _)| !boxes_meet(*b, near));
cur = record::after(b, &cur, &counts);
todo.retain(|p| !covered.iter().any(|q| q.dist(*p) < 1e-12));
continue;
}
let mut first_err: Option<KernelError> = None;
let mut done: Option<(Body, Vec<usize>)> = None;
let mut done: Option<(Body, Vec<usize>, (Vec3, Vec3))> = None;
for (j, p) in todo.iter().enumerate() {
if let Some((_, _, e)) = refused.iter().find(|(q, _, _)| q.dist(*p) < 1e-12) {
if first_err.is_none() || (matches!(e, KernelError::TooLarge(_)) && !matches!(first_err, Some(KernelError::TooLarge(_)))) {
first_err = Some(e.clone());
}
continue;
}
let Some((idx, dist)) = cur.nearest_edge(*p, tol)? else { return Err(KernelError::Invalid("the body has no edges".into())) };
if dist > size * 0.05 + 1e-3 {
return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z])));
@@ -861,6 +874,8 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
})
.map(|(i, _)| i)
.collect();
#[cfg(test)]
ROLLS.with(|n| n.set(n.get() + 1));
guard(what, || crate::curveblend::curve_blend(&solid, ids, size, r, shape == Shape::Round))
.map(|o| (o, if on_chain.is_empty() { vec![j] } else { on_chain }))
};
@@ -888,22 +903,26 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
},
},
};
// What this attempt could change: the edge and its smooth chain, with room for the blend.
let reach = edges_box(&solid, chain.as_deref().unwrap_or(&[edge.id()]), r + tol);
match attempt.and_then(|((o, rec), js)| record::rebuilt(o, &[&solid], &rec).map(|b| (b, js))) {
Ok(x) => {
done = Some(x);
Ok((b, js)) => {
done = Some((b, js, reach));
break;
}
// Reported: the first refusal, unless a later one is a size a smaller radius may
// fit (the others may be refused only until that edge is blended).
Err(e) => {
if first_err.is_none() || (matches!(e, KernelError::TooLarge(_)) && !matches!(first_err, Some(KernelError::TooLarge(_)))) {
first_err = Some(e);
first_err = Some(e.clone());
}
refused.push((*p, reach, e));
}
}
}
match done {
Some((b, js)) => {
Some((b, js, near)) => {
refused.retain(|(_, b2, _)| !boxes_meet(*b2, near));
cur = record::after(b, &cur, &counts);
let mut k = 0;
todo.retain(|_| {
@@ -918,6 +937,36 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
if todo.is_empty() { Ok(cur) } else { Err(unsupported("blending these edges together")) }
}
#[cfg(test)]
thread_local! {
/// Rolling-ball attempts made by [`blend`] on this thread (tests count them).
pub(crate) static ROLLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The box around these edges of `solid` (their ends and middles), grown by `margin`.
fn edges_box(solid: &Solid, ids: &[mt::EdgeID], margin: f64) -> (Vec3, Vec3) {
use mt::{BoundedCurve, ParametricCurve};
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut hi = -lo;
for e in Body::unique_edges(solid).iter().filter(|e| ids.contains(&e.id())) {
let c = e.curve();
let (t0, t1) = c.range_tuple();
for k in 0..=8 {
let q = from_p3(c.subs(t0 + (t1 - t0) * f64::from(k) / 8.0));
lo = lo.min(q);
hi = hi.max(q);
}
}
let m = Vec3::new(margin, margin, margin);
(lo - m, hi + m)
}
/// Whether two boxes overlap (an empty box meets every box: nothing is known about it).
fn boxes_meet(a: (Vec3, Vec3), b: (Vec3, Vec3)) -> bool {
let empty = |x: (Vec3, Vec3)| !(x.0.x <= x.1.x && x.0.y <= x.1.y && x.0.z <= x.1.z);
empty(a) || empty(b) || (a.0.x <= b.1.x && b.0.x <= a.1.x && a.0.y <= b.1.y && b.0.y <= a.1.y && a.0.z <= b.1.z && b.0.z <= a.1.z)
}
/// Neighbouring edges along one boundary loop of a planar face that between them hold at least
/// two of the points' edges, joined by arcs that meet them smoothly (left by earlier blends):
/// the run's edge ids and the points it covers.
+520 -166
View File
@@ -5,14 +5,15 @@
//! touch one more planar face square to the edge (the configuration of `blend`). Every section
//! square to the edge is a circular arc tangent to both faces, its radius running linearly from
//! one end to the other; the arcs' control points move linearly, so the blend is exactly a
//! rational B-spline of degree 2 across and 1 along.
//! rational B-spline of degree 2 across and 1 along. The radius may run down to zero at one end:
//! there the blend closes to the edge's end point, a three-sided face, and the end face is kept.
use std::collections::HashMap;
use solidcraft_geom::Vec3;
use truck_modeling::{self as mt, builder};
use crate::blend::{plane_normal, rebuild_face, unsupported, vtx};
use crate::blend::{plane_normal, rebuild_face, too_large, unsupported, vtx};
use crate::body::{Body, Solid, p3};
use crate::{KernelError, Result, guard};
@@ -59,23 +60,25 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64
fillet_variable_points(body, edge_at, start, &[(0.0, r0), (1.0, r1)], false)
}
/// The radius along the edge as a B-spline of the edge parameter (0 at the edge's front):
/// degree, knots and control radii.
struct RadiusLaw {
deg: usize,
knots: Vec<f64>,
radii: Vec<f64>,
/// A fillet radius along an edge: a clamped B-spline of the position along it (0 at one end, 1
/// at the other), given by its degree, knots and control radii. The radius is a smooth curve
/// shaped by its control radii, each pulling it toward itself near its Greville position.
#[derive(Clone, Debug, PartialEq)]
pub struct RadiusSpline {
pub degree: usize,
pub knots: Vec<f64>,
pub radii: Vec<f64>,
}
impl RadiusLaw {
impl RadiusSpline {
/// Through (position, radius) points sorted by position from 0 to 1: straight between them,
/// or `smooth` (cubic pieces with Catmull-Rom slopes, C¹ through every point).
fn through(pts: &[(f64, f64)], smooth: bool) -> RadiusLaw {
pub fn through(pts: &[(f64, f64)], smooth: bool) -> RadiusSpline {
if !smooth || pts.len() < 3 {
let mut knots = vec![0.0];
knots.extend(pts.iter().map(|p| p.0));
knots.push(1.0);
return RadiusLaw { deg: 1, knots, radii: pts.iter().map(|p| p.1).collect() };
return RadiusSpline { degree: 1, knots, radii: pts.iter().map(|p| p.1).collect() };
}
let n = pts.len();
let at = |i: usize| pts.get(i).copied().unwrap_or((0.0, 0.0));
@@ -91,37 +94,65 @@ impl RadiusLaw {
radii.extend([r0 + slope(i) * h / 3.0, r1 - slope(i + 1) * h / 3.0, r1]);
knots.extend(if i + 2 == n { vec![1.0; 4] } else { vec![t1; 3] });
}
RadiusLaw { deg: 3, knots, radii }
RadiusSpline { degree: 3, knots, radii }
}
/// Greville abscissae: where each control radius sits along the edge.
fn greville(&self) -> Vec<f64> {
(0..self.radii.len()).map(|j| self.knots.iter().skip(j + 1).take(self.deg).sum::<f64>() / self.deg as f64).collect()
pub fn greville(&self) -> Vec<f64> {
(0..self.radii.len()).map(|j| self.knots.iter().skip(j + 1).take(self.degree).sum::<f64>() / self.degree as f64).collect()
}
/// The radius at `t` (sampled for checks).
fn eval(&self, t: f64) -> f64 {
/// The radius at position `t`.
pub fn eval(&self, t: f64) -> f64 {
let kv = mt::KnotVec::from(self.knots.clone());
let b = kv.try_bspline_basis_functions(self.deg, t.clamp(0.0, 1.0)).unwrap_or_default();
let b = kv.try_bspline_basis_functions(self.degree, t.clamp(0.0, 1.0)).unwrap_or_default();
b.iter().zip(&self.radii).map(|(x, r)| x * r).sum()
}
/// The same radius with its positions taken from the other end.
pub fn reversed(&self) -> RadiusSpline {
RadiusSpline {
degree: self.degree,
knots: self.knots.iter().rev().map(|k| 1.0 - k).collect(),
radii: self.radii.iter().rev().copied().collect(),
}
}
/// Refused unless it is a clamped B-spline from 0 to 1 of degree 1 to 3 with 2 to 100 control
/// radii, positive along the edge and zero at most at one end.
fn check(&self) -> Result<()> {
let (n, deg) = (self.radii.len(), self.degree);
if !(1..=3).contains(&deg) || !(2..=100).contains(&n) || n <= deg || self.knots.len() != n + deg + 1 {
return Err(KernelError::Invalid("a radius spline has degree 1…3, 2…100 control radii and as many knots as both and one more".into()));
}
let clamped = self.knots.iter().take(deg + 1).all(|k| *k == 0.0) && self.knots.iter().skip(n).all(|k| *k == 1.0);
if !clamped || !self.knots.iter().all(|k| k.is_finite()) || self.knots.windows(2).any(|w| w[1] < w[0]) {
return Err(KernelError::Invalid("radius spline knots rise from 0 to 1, repeated degree + 1 times at each end".into()));
}
if self.radii.iter().any(|r| !(r.is_finite() && (*r == 0.0 || *r > 1e-6) && *r < 1e6)) {
return Err(KernelError::Invalid("fillet radii must be positive (zero only at an end)".into()));
}
let zeros: Vec<usize> = (0..n).filter(|i| self.radii.get(*i) == Some(&0.0)).collect();
if !matches!(zeros[..], [] | [0]) && zeros[..] != [n - 1] {
return Err(KernelError::Invalid("the radius may be zero at one end of the edge only".into()));
}
// Positive inside the edge (a zero end is given exactly, as a control radius).
if (1..200).any(|k| self.eval(k as f64 / 200.0) <= 1e-6) {
return Err(KernelError::Invalid("the radius must stay positive along the edge".into()));
}
Ok(())
}
}
/// Fillet one straight edge with a radius that varies along it: (position, radius) points with
/// positions 0 (the end nearest `start`) to 1 (the other end) and any between, the radius
/// straight between them or `smooth`.
/// straight between them or `smooth`. One end's radius may be zero.
pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &[(f64, f64)], smooth: bool) -> Result<Body> {
body.require_brep("fillet")?;
if points.len() < 2 || points.len() > 100 {
return Err(KernelError::Invalid("a variable radius needs 2…100 points".into()));
}
for (t, r) in points {
if !(r.is_finite() && *r > 1e-6 && *r < 1e6) {
return Err(KernelError::Invalid("fillet radii must be positive".into()));
}
if !(t.is_finite() && (0.0..=1.0).contains(t)) {
return Err(KernelError::Invalid("radius positions run from 0 to 1 along the edge".into()));
}
if points.iter().any(|(t, _)| !(t.is_finite() && (0.0..=1.0).contains(t))) {
return Err(KernelError::Invalid("radius positions run from 0 to 1 along the edge".into()));
}
let mut pts = points.to_vec();
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
@@ -129,171 +160,375 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &
if pts.first().map(|p| p.0) != Some(0.0) || pts.last().map(|p| p.0) != Some(1.0) || pts.len() < 2 {
return Err(KernelError::Invalid("give the radius at both ends of the edge".into()));
}
fillet_variable_spline(body, edge_at, start, &RadiusSpline::through(&pts, smooth))
}
/// Fillet one straight edge with the radius `spline` along it, its positions from the end
/// nearest `start`. One end's radius may be zero.
pub fn fillet_variable_spline(body: &Body, edge_at: Vec3, start: Vec3, spline: &RadiusSpline) -> Result<Body> {
body.require_brep("fillet")?;
spline.check()?;
let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?;
let size = body.size();
guard("variable fillet", || {
let solid: &Solid = &body.solid;
let s = EdgeSetup::new(body, &edge)?;
// Positions from the end nearest `start` (the edge keeps its own direction).
let law = if s.p1.dist(start) < s.p0.dist(start) { spline.reversed() } else { spline.clone() };
build(body, &s, &edge, &law)
})
}
/// A straight edge between two planar faces as the variable blend sees it: the shell it is in
/// and that shell's faces, the frame at the edge, and its ends (front first).
struct EdgeSetup<'a> {
si: usize,
faces: Vec<&'a mt::Face>,
fr: EdgeFrame,
edge: mt::EdgeID,
v0: mt::Vertex,
v1: mt::Vertex,
p0: Vec3,
p1: Vec3,
d: Vec3,
tol: f64,
}
impl<'a> EdgeSetup<'a> {
fn new(body: &'a Body, edge: &mt::Edge) -> Result<EdgeSetup<'a>> {
if !matches!(edge.curve(), mt::Curve::Line(_)) {
return Err(unsupported("only straight edges take a variable radius"));
}
let shells = solid.boundaries();
let (si, shell) = shells
let (si, shell) = body
.solid
.boundaries()
.iter()
.enumerate()
.find(|(_, sh)| sh.edge_iter().any(|e| e.id() == edge.id()))
.ok_or_else(|| KernelError::Failed("edge not found".into()))?;
let faces: Vec<&mt::Face> = shell.face_iter().collect();
let fr = edge_frame(&faces, &edge)?;
let fr = edge_frame(&faces, edge)?;
let (v0, v1) = (edge.absolute_front().clone(), edge.absolute_back().clone());
// Positions from the end nearest `start` (the edge keeps its own direction).
if vtx(&v1).dist(start) < vtx(&v0).dist(start) {
pts = pts.iter().rev().map(|(t, r)| (1.0 - t, *r)).collect();
}
let law = RadiusLaw::through(&pts, smooth);
let (ra, rb) = (law.eval(0.0), law.eval(1.0));
// The largest radius along the edge decides whether the blend fits.
let rmax = (0..=200).map(|k| law.eval(k as f64 / 200.0)).fold(0.0, f64::max);
if (0..=200).any(|k| law.eval(k as f64 / 200.0) <= 1e-6) {
return Err(KernelError::Invalid("the radius must stay positive along the edge".into()));
}
let (p0, p1) = (vtx(&v0), vtx(&v1));
let d = (p1 - p0).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?;
let tol = (size * 1e-7).max(1e-9);
let back = |r: f64| r / (fr.phi / 2.0).tan();
// Each end: the end face and its edges along the two faces.
struct End {
v: mt::Vertex,
g: usize,
e1: mt::Edge,
e2: mt::Edge,
Ok(EdgeSetup { si, faces, fr, edge: edge.id(), v0, v1, p0, p1, d, tol: (body.size() * 1e-7).max(1e-9) })
}
/// How far a blend of radius `r` reaches into each face from the edge.
fn back(&self, r: f64) -> f64 {
r / (self.fr.phi / 2.0).tan()
}
/// Whether the band a blend of radius `r` takes from the faces at position `u` (strictly
/// between the ends) stays inside both, clear of their other edges and holes.
fn band_fits(&self, u: f64, r: f64) -> bool {
let p = self.p0 + (self.p1 - self.p0) * u;
let s = self.back(r);
[(self.fr.i1, self.fr.t1), (self.fr.i2, self.fr.t2)]
.into_iter()
.all(|(i, t)| self.faces.get(i).is_some_and(|f| crate::blend::extension_fits(f, p, p + t * s, self.tol)))
}
/// The largest radius up to `cap` whose band fits at position `u`.
fn room(&self, u: f64, cap: f64) -> f64 {
if self.band_fits(u, cap) {
return cap;
}
let mut ends = Vec::new();
for (v, p, r) in [(&v0, p0, ra), (&v1, p1, rb)] {
let at_v: Vec<usize> = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.vertex_iter().any(|x| &x == v))).collect();
let others: Vec<usize> = at_v.iter().copied().filter(|i| *i != fr.i1 && *i != fr.i2).collect();
let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) };
let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?;
let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
if ng.dot(d).abs() < 1.0 - 1e-9 {
let (mut lo, mut hi) = (0.0, cap);
while hi - lo > self.tol {
let mid = (lo + hi) / 2.0;
if self.band_fits(u, mid) { lo = mid } else { hi = mid }
}
lo
}
/// The room at the end `v` (`out` pointing away from the edge there), as a radius: zero
/// where a face closes to a point (its next side runs back along the edge), the reach of the
/// sides along the faces where they are square to the edge, None where a side leans outward.
fn end_room(&self, v: &mt::Vertex, out: Vec3) -> Option<f64> {
let mut room = f64::INFINITY;
for (i, t) in [(self.fr.i1, self.fr.t1), (self.fr.i2, self.fr.t2)] {
let side = self.faces.get(i)?.edge_iter().find(|e| e.id() != self.edge && (e.front() == v || e.back() == v))?;
let dir = leaving(&side, v, self.tol)?;
let lean = dir.dot(out);
if lean < -1e-9 {
return Some(0.0);
}
if lean > 1e-9 || !matches!(side.curve(), mt::Curve::Line(_)) {
return None;
}
let other = if side.front() == v { side.back() } else { side.front() };
room = room.min((vtx(other) - vtx(v)).dot(t) * (self.fr.phi / 2.0).tan());
}
Some(room)
}
}
/// The direction `edge` leaves its end `v` in (along its first piece within `tol`).
fn leaving(edge: &mt::Edge, v: &mt::Vertex, tol: f64) -> Option<Vec3> {
use mt::{BoundedCurve, ParameterDivision1D};
let curve = edge.curve();
let pts = curve.parameter_division(curve.range_tuple(), tol.max(1e-6)).1;
let p = vtx(v);
let near = if crate::body::from_p3(*pts.first()?).dist(p) <= crate::body::from_p3(*pts.last()?).dist(p) {
pts.get(1)?
} else {
pts.get(pts.len().checked_sub(2)?)?
};
(crate::body::from_p3(*near) - p).normalized()
}
/// Build the blend of the edge of `s` with the radius `law` (its positions from the edge's front).
fn build(body: &Body, s: &EdgeSetup, edge: &mt::Edge, law: &RadiusSpline) -> Result<Body> {
let (faces, fr, tol) = (&s.faces, &s.fr, s.tol);
let (v0, v1, p0, p1, d) = (&s.v0, &s.v1, s.p0, s.p1, s.d);
// The end radii are the first and last control radii (exact: a zero end closes the blend).
let (ra, rb) = (law.radii.first().copied().unwrap_or(0.0), law.radii.last().copied().unwrap_or(0.0));
let back = |r: f64| s.back(r);
let arc_mid = |p: Vec3, sb: f64, r: f64| {
let c = p + fr.t1 * sb + fr.n1 * (r * fr.side);
c + (p - c).normalized().unwrap_or(-fr.n1) * r
};
// Each end: the blend's corners on the two faces and, with a radius, the arc between them,
// the end face and its edges along the two faces. At a zero radius the blend closes to the
// end vertex, and the faces around it stay as they are.
struct Arc {
v: mt::Vertex,
g: usize,
e1: mt::Edge,
e2: mt::Edge,
edge: mt::Edge,
}
struct End {
a: mt::Vertex,
b: mt::Vertex,
arc: Option<Arc>,
}
let mut ends = Vec::new();
for (v, p, r) in [(v0, p0, ra), (v1, p1, rb)] {
if r == 0.0 {
ends.push(End { a: v.clone(), b: v.clone(), arc: None });
continue;
}
let at_v: Vec<usize> = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.vertex_iter().any(|x| &x == v))).collect();
let others: Vec<usize> = at_v.iter().copied().filter(|i| *i != fr.i1 && *i != fr.i2).collect();
let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) };
let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?;
let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
if ng.dot(d).abs() < 1.0 - 1e-9 {
return Err(unsupported("the faces at the edge ends must be perpendicular to the edge"));
}
let shared = |fi: usize| -> Result<mt::Edge> {
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
f.edge_iter()
.find(|e| e.id() != edge.id() && (e.front() == v || e.back() == v) && gf.edge_iter().any(|x| x.id() == e.id()))
.ok_or_else(|| unsupported("unexpected corner topology"))
};
let (e1, e2) = (shared(fr.i1)?, shared(fr.i2)?);
for (e, t) in [(&e1, fr.t1), (&e2, fr.t2)] {
if !matches!(e.curve(), mt::Curve::Line(_)) {
return Err(unsupported("the edges at the corners must be straight"));
}
let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
let along = (other - p).dot(t);
if (other - p - t * along).len() > tol * 100.0 + 1e-7 {
return Err(unsupported("the faces at the edge ends must be perpendicular to the edge"));
}
let shared = |fi: usize| -> Result<mt::Edge> {
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
f.edge_iter()
.find(|e| e.id() != edge.id() && (e.front() == v || e.back() == v) && gf.edge_iter().any(|x| x.id() == e.id()))
.ok_or_else(|| unsupported("unexpected corner topology"))
};
let (e1, e2) = (shared(fr.i1)?, shared(fr.i2)?);
for (e, t) in [(&e1, fr.t1), (&e2, fr.t2)] {
if !matches!(e.curve(), mt::Curve::Line(_)) {
return Err(unsupported("the edges at the corners must be straight"));
}
let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
let along = (other - p).dot(t);
if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= back(r.max(rmax)) + tol {
return Err(unsupported("the blend is larger than the neighbouring faces"));
}
}
ends.push(End { v: v.clone(), g, e1, e2 });
}
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
if end0.g == end1.g {
return Err(unsupported("both ends touch the same face"));
}
let (s0, s1) = (back(ra), back(rb));
let (pa0, pb0, pa1, pb1) = (p0 + fr.t1 * s0, p0 + fr.t2 * s0, p1 + fr.t1 * s1, p1 + fr.t2 * s1);
let (a0, b0, a1, b1) = (builder::vertex(p3(pa0)), builder::vertex(p3(pb0)), builder::vertex(p3(pa1)), builder::vertex(p3(pb1)));
let arc_mid = |p: Vec3, s: f64, r: f64| {
let c = p + fr.t1 * s + fr.n1 * (r * fr.side);
c + (p - c).normalized().unwrap_or(-fr.n1) * r
};
let c0 = builder::circle_arc(&a0, &b0, p3(arc_mid(p0, s0, ra)));
let c1 = builder::circle_arc(&a1, &b1, p3(arc_mid(p1, s1, rb)));
// Contact curves: the edge moved along each face by the setback of the radius there.
let g = law.greville();
let contact = |t: Vec3, a: &mt::Vertex, b: &mt::Vertex| -> Result<mt::Edge> {
if law.deg == 1 && law.radii.len() == 2 {
return Ok(builder::line(a, b));
}
let ctrl: Vec<mt::Point3> = g.iter().zip(&law.radii).map(|(gj, rj)| p3(p0 + (p1 - p0) * *gj + t * back(*rj))).collect();
let c = mt::BSplineCurve::try_new(mt::KnotVec::from(law.knots.clone()), ctrl).map_err(|e| KernelError::Failed(format!("fillet: {e}")))?;
mt::Edge::try_new(a, b, mt::Curve::BSplineCurve(c)).map_err(|e| KernelError::Failed(format!("fillet: {e}")))
};
let la = contact(fr.t1, &a0, &a1)?;
let lb = contact(fr.t2, &b0, &b1)?;
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
for (end, a, b) in [(end0, &a0, &b0), (end1, &a1, &b1)] {
for (e, nv) in [(&end.e1, a), (&end.e2, b)] {
let (f, bk) = (e.absolute_front(), e.absolute_back());
let ne = if f == &end.v { builder::line(nv, bk) } else { builder::line(f, nv) };
subst.insert(e.id(), ne);
if along <= back(r) + tol {
return Err(too_large("the blend is larger than the neighbouring faces"));
}
}
// The side edges at the ends are pieces of themselves; the contacts and end arcs are new.
let mut rec = crate::record::Rebuild::default();
rec.pieces(&subst);
let mut new_faces: Vec<mt::Face> = Vec::with_capacity(faces.len() + 1);
for (i, f) in faces.iter().enumerate() {
let nf = if i == fr.i1 {
let mut m = subst.clone();
m.insert(edge.id(), la.clone());
rebuild_face(f, &m, &[])?
} else if i == fr.i2 {
let mut m = subst.clone();
m.insert(edge.id(), lb.clone());
rebuild_face(f, &m, &[])?
} else if i == end0.g {
rebuild_face(f, &subst, std::slice::from_ref(&c0))?
} else if i == end1.g {
rebuild_face(f, &subst, std::slice::from_ref(&c1))?
} else {
(*f).clone()
};
rec.face(&nf, f);
new_faces.push(nf);
let sb = back(r);
let (a, b) = (builder::vertex(p3(p + fr.t1 * sb)), builder::vertex(p3(p + fr.t2 * sb)));
let arc = builder::circle_arc(&a, &b, p3(arc_mid(p, sb, r)));
ends.push(End { a, b, arc: Some(Arc { v: v.clone(), g, e1, e2, edge: arc }) });
}
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
if let (Some(x), Some(y)) = (&end0.arc, &end1.arc)
&& x.g == y.g
{
return Err(unsupported("both ends touch the same face"));
}
// Along the edge the band the blend takes from each face stays inside it.
const STATIONS: usize = 256;
if (1..STATIONS).any(|k| !s.band_fits(k as f64 / STATIONS as f64, law.eval(k as f64 / STATIONS as f64))) {
return Err(too_large("the blend is larger than the neighbouring faces"));
}
// Contact curves: the edge moved along each face by the setback of the radius there.
let g = law.greville();
let contact = |t: Vec3, a: &mt::Vertex, b: &mt::Vertex| -> Result<mt::Edge> {
if law.degree == 1 && law.radii.len() == 2 {
return Ok(builder::line(a, b));
}
// The blend surface: rational quadratic arcs (tangent points and the edge point, middle
// weight sin(φ/2)) at each end, joined linearly.
let w = (fr.phi / 2.0).sin();
let h = |p: Vec3, wt: f64| mt::Vector4::new(p.x * wt, p.y * wt, p.z * wt, wt);
let row = |t: Vec3, wt: f64, off: bool| -> Vec<mt::Vector4> {
g.iter().zip(&law.radii).map(|(gj, rj)| h(p0 + (p1 - p0) * *gj + if off { t * back(*rj) } else { Vec3::ZERO }, wt)).collect()
};
let ctrl = vec![row(fr.t1, 1.0, true), row(fr.t1, w, false), row(fr.t2, 1.0, true)];
let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::from(law.knots.clone())), ctrl);
let mut surf = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bsp));
// Outward: away from the arc centre on a convex edge, toward it on a concave one.
let (c, mid) = (p0 + fr.t1 * s0 + fr.n1 * (ra * fr.side), arc_mid(p0, s0, ra));
let outward = (mid - c) * (-fr.side);
{
use mt::{ParametricSurface3D, SearchParameter};
if let Some((u, v)) = surf.search_parameter(p3(mid), None, 100) {
let nn = surf.normal(u, v);
if Vec3::new(nn.x, nn.y, nn.z).dot(outward) < 0.0 {
surf = mt::Invertible::inverse(&surf);
}
}
let ctrl: Vec<mt::Point3> = g.iter().zip(&law.radii).map(|(gj, rj)| p3(p0 + (p1 - p0) * *gj + t * back(*rj))).collect();
let c = mt::BSplineCurve::try_new(mt::KnotVec::from(law.knots.clone()), ctrl).map_err(|e| KernelError::Failed(format!("fillet: {e}")))?;
mt::Edge::try_new(a, b, mt::Curve::BSplineCurve(c)).map_err(|e| KernelError::Failed(format!("fillet: {e}")))
};
let la = contact(fr.t1, &end0.a, &end1.a)?;
let lb = contact(fr.t2, &end0.b, &end1.b)?;
let arcs = || [end0, end1].into_iter().filter_map(|end| end.arc.as_ref().map(|arc| (end, arc)));
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
for (end, arc) in arcs() {
for (e, nv) in [(&arc.e1, &end.a), (&arc.e2, &end.b)] {
let (f, bk) = (e.absolute_front(), e.absolute_back());
let ne = if f == &arc.v { builder::line(nv, bk) } else { builder::line(f, nv) };
subst.insert(e.id(), ne);
}
// The blend face uses each new edge opposite to its neighbour.
let f1_forward =
faces.get(fr.i1).is_some_and(|f| f.boundaries().iter().any(|w| w.edge_iter().any(|e| e.id() == edge.id() && e.front() == &v0)));
let wire: mt::Wire = if f1_forward {
vec![c0.clone(), lb.clone(), c1.inverse(), la.inverse()].into()
}
// The side edges at the ends are pieces of themselves; the contacts and end arcs are new.
let mut rec = crate::record::Rebuild::default();
rec.pieces(&subst);
let mut new_faces: Vec<mt::Face> = Vec::with_capacity(faces.len() + 1);
for (i, f) in faces.iter().enumerate() {
let nf = if i == fr.i1 {
let mut m = subst.clone();
m.insert(edge.id(), la.clone());
rebuild_face(f, &m, &[])?
} else if i == fr.i2 {
let mut m = subst.clone();
m.insert(edge.id(), lb.clone());
rebuild_face(f, &m, &[])?
} else if let Some((_, arc)) = arcs().find(|(_, arc)| arc.g == i) {
rebuild_face(f, &subst, std::slice::from_ref(&arc.edge))?
} else {
vec![la.clone(), c1.clone(), lb.inverse(), c0.inverse()].into()
(*f).clone()
};
let blend = mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?;
rec.blend(&blend, [edge.id()]);
new_faces.push(blend);
let mut shells = solid.boundaries().clone();
if let Some(sh) = shells.get_mut(si) {
*sh = new_faces.into();
rec.face(&nf, f);
new_faces.push(nf);
}
// The blend surface: rational quadratic arcs (tangent points and the edge point, middle
// weight sin(φ/2)) at each end, joined along the radius spline.
let w = (fr.phi / 2.0).sin();
let h = |p: Vec3, wt: f64| mt::Vector4::new(p.x * wt, p.y * wt, p.z * wt, wt);
let row = |t: Vec3, wt: f64, off: bool| -> Vec<mt::Vector4> {
g.iter().zip(&law.radii).map(|(gj, rj)| h(p0 + (p1 - p0) * *gj + if off { t * back(*rj) } else { Vec3::ZERO }, wt)).collect()
};
let ctrl = vec![row(fr.t1, 1.0, true), row(fr.t1, w, false), row(fr.t2, 1.0, true)];
let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::from(law.knots.clone())), ctrl);
let mut surf = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bsp));
// Outward: away from the arc centre on a convex edge, toward it on a concave one (at an
// end with a radius).
let (p, r) = if end0.arc.is_none() { (p1, rb) } else { (p0, ra) };
let (c, mid) = (p + fr.t1 * back(r) + fr.n1 * (r * fr.side), arc_mid(p, back(r), r));
let outward = (mid - c) * (-fr.side);
{
use mt::{ParametricSurface3D, SearchParameter};
if let Some((u, v)) = surf.search_parameter(p3(mid), None, 100) {
let nn = surf.normal(u, v);
if Vec3::new(nn.x, nn.y, nn.z).dot(outward) < 0.0 {
surf = mt::Invertible::inverse(&surf);
}
}
let out = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("variable fillet produced an invalid solid: {e}")))?;
crate::record::rebuilt(out, &[solid], &rec)
}
// The blend face uses each new edge opposite to its neighbour; a closed end has no arc.
let arc_of = |end: &End| end.arc.as_ref().map(|a| a.edge.clone());
let f1_forward = faces.get(fr.i1).is_some_and(|f| f.boundaries().iter().any(|w| w.edge_iter().any(|e| e.id() == edge.id() && e.front() == v0)));
let wire: mt::Wire = if f1_forward {
[arc_of(end0), Some(lb.clone()), arc_of(end1).map(|a| a.inverse()), Some(la.inverse())].into_iter().flatten().collect()
} else {
[Some(la.clone()), arc_of(end1), Some(lb.inverse()), arc_of(end0).map(|a| a.inverse())].into_iter().flatten().collect()
};
let blend = mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?;
rec.blend(&blend, [edge.id()]);
new_faces.push(blend);
let solid: &Solid = &body.solid;
let mut shells = solid.boundaries().clone();
if let Some(sh) = shells.get_mut(s.si) {
*sh = new_faces.into();
}
let out = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("variable fillet produced an invalid solid: {e}")))?;
crate::record::rebuilt(out, &[solid], &rec)
}
/// How much of the room along an edge a taper takes: its band stays within this fraction of each
/// face's width, so the faces keep a strip beside the round, narrowing to nothing only where a
/// face closes to a point.
const TAPER_SHARE: f64 = 0.9;
/// Spans of the cubic radius spline a taper builds.
const TAPER_SPANS: usize = 8;
/// The radius a taper gives one edge: its positions from `start`.
#[derive(Clone, Debug, PartialEq)]
pub struct Taper {
pub start: Vec3,
pub spline: RadiusSpline,
}
/// The radius spline that tapers `radius` on the straight edge at `edge_at` to the room along
/// it: `radius` where that fits, following the room down where it doesn't, to zero at an end
/// where a face closes to a point. Its control radii are the target min(radius, share × room)
/// at their Greville positions (Schoenberg's variation-diminishing spline), which stays under the
/// target wherever the room is concave along the edge (a convex face); the blend checks the rest.
pub fn taper(body: &Body, edge_at: Vec3, radius: f64) -> Result<Taper> {
body.require_brep("fillet")?;
if !(radius.is_finite() && radius > 1e-6 && radius < 1e6) {
return Err(KernelError::Invalid("fillet radii must be positive".into()));
}
let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?;
guard("taper", || {
let s = EdgeSetup::new(body, &edge)?;
let leans = || unsupported("a taper needs each end of the edge square to it or closing to a point");
let room0 = s.end_room(&s.v0, -s.d).ok_or_else(leans)?;
let room1 = s.end_room(&s.v1, s.d).ok_or_else(leans)?;
if room0 == 0.0 && room1 == 0.0 {
return Err(too_large("the faces close to a point at both ends of the edge"));
}
let mut knots = vec![0.0; 4];
knots.extend((1..TAPER_SPANS).map(|k| k as f64 / TAPER_SPANS as f64));
knots.extend([1.0; 4]);
let mut law = RadiusSpline { degree: 3, knots, radii: vec![0.0; TAPER_SPANS + 3] };
let g = law.greville();
let last = g.len() - 1;
law.radii = g
.iter()
.enumerate()
.map(|(j, u)| match j {
0 => (TAPER_SHARE * room0).min(radius),
j if j == last => (TAPER_SHARE * room1).min(radius),
_ => TAPER_SHARE * s.room(*u, radius / TAPER_SHARE),
})
.collect();
if law.radii.iter().take(last).skip(1).any(|r| *r <= 1e-6) {
return Err(too_large("the room for the blend runs out inside the edge"));
}
law.check()?;
Ok(Taper { start: s.p0, spline: law })
})
}
/// A constant fillet of `edges` at `radius`; where that is too large, each edge it doesn't fit
/// on alone gets a [`taper`], and the rest the constant radius. The result and the taper of each
/// edge (in `edges`' order; None where the radius is constant).
pub fn fillet_tapered(body: &Body, edges: &[Vec3], radius: f64) -> Result<(Body, Vec<Option<Taper>>)> {
let why = match crate::fillet(body, edges, radius) {
Err(KernelError::TooLarge(why)) => why,
other => return other.map(|b| (b, vec![None; edges.len()])),
};
let mut tapers = vec![None; edges.len()];
let mut constant = Vec::new();
for (p, slot) in edges.iter().zip(tapers.iter_mut()) {
match crate::fillet(body, &[*p], radius) {
Ok(_) => constant.push(*p),
Err(KernelError::TooLarge(_)) => *slot = Some(taper(body, *p, radius)?),
Err(e) => return Err(e),
}
}
// Each edge fits alone but not together: nothing to taper.
if tapers.iter().all(Option::is_none) {
return Err(KernelError::TooLarge(why));
}
// Each step's result carries its history over `[body]`.
let counts = crate::record::counts(&[body]);
let mut cur = crate::boolean_record::operand(body, 0, &counts);
for (p, t) in edges.iter().zip(&tapers) {
if let Some(t) = t {
cur = crate::record::after(fillet_variable_spline(&cur, *p, t.start, &t.spline)?, &cur, &counts);
}
}
if !constant.is_empty() {
cur = crate::record::after(crate::fillet(&cur, &constant, radius)?, &cur, &counts);
}
Ok((cur, tapers))
}
/// Chord-length fillet: each edge (straight, between planar faces) rounded so that the blend
/// is `chord` wide across; edges with the same face angle share a radius.
pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result<Body> {
@@ -371,7 +606,7 @@ mod tests {
assert!(rel(v, 6000.0 - removed) < 1e-4, "{v} vs {}", 6000.0 - removed);
assert_eq!(f.unmeshed_faces(), 0);
let s = fillet_variable_points(&b, at, start, &pts, true).unwrap();
let law = RadiusLaw::through(&pts, true);
let law = RadiusSpline::through(&pts, true);
let n = 20000;
let integral: f64 = (0..n).map(|i| law.eval((i as f64 + 0.5) / n as f64).powi(2)).sum::<f64>() / n as f64 * 20.0;
assert!((law.eval(0.5) - 4.0).abs() < 1e-12 && (law.eval(0.0) - 2.0).abs() < 1e-12);
@@ -387,6 +622,125 @@ mod tests {
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (1.0, -1.0)], false).is_err());
}
/// The radius running down to zero at one end: the blend closes to the corner, a three-sided
/// face, and the end face there is untouched. Removed (1 − π/4) L r0² / 3 when linear.
#[test]
fn variable_radius_down_to_zero_at_one_end() {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
let (at, start, end) = (Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0), Vec3::new(10.0, 20.0, 30.0));
let k = 1.0 - std::f64::consts::FRAC_PI_4;
for (r0, r1) in [(4.0, 0.0), (0.0, 4.0)] {
let f = fillet_variable(&b, at, start, r0, r1).unwrap();
let v = measure(&f).unwrap().volume;
assert!(rel(v, 6000.0 - k * 20.0 * 16.0 / 3.0) < 1e-4, "{r0} → {r1}: {v}");
assert_eq!(f.unmeshed_faces(), 0);
let faces: Vec<&mt::Face> = f.solid.boundaries().iter().flat_map(|sh| sh.face_iter()).collect();
assert_eq!(faces.len(), 7);
// The blend is three-sided; the closed end's corner is still a box corner.
assert!(faces.iter().any(|fc| fc.edge_iter().count() == 3));
let corner = if r1 == 0.0 { end } else { start };
assert!(faces.iter().flat_map(|fc| fc.vertex_iter()).any(|x| vtx(&x).dist(corner) < 1e-9));
}
// Through a middle point, smooth, closing at the far end.
let pts = [(0.0, 3.0), (0.5, 4.0), (1.0, 0.0)];
let s = fillet_variable_points(&b, at, start, &pts, true).unwrap();
let law = RadiusSpline::through(&pts, true);
let n = 20000;
let integral: f64 = (0..n).map(|i| law.eval((i as f64 + 0.5) / n as f64).powi(2)).sum::<f64>() / n as f64 * 20.0;
let v = measure(&s).unwrap().volume;
assert!(rel(v, 6000.0 - k * integral) < 1e-4, "{v} vs {}", 6000.0 - k * integral);
// Zero at both ends, zero in the middle, a smooth radius dipping below zero, or a radius
// too small to build: refused.
assert!(fillet_variable(&b, at, start, 0.0, 0.0).is_err());
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (0.5, 0.0), (1.0, 2.0)], false).is_err());
let dip = [(0.0, 0.0), (0.2, 0.1), (0.4, 6.0), (1.0, 6.0)];
assert!((1..200).any(|i| RadiusSpline::through(&dip, true).eval(i as f64 / 200.0) <= 1e-6));
assert!(fillet_variable_points(&b, at, start, &dip, true).is_err());
assert!(fillet_variable(&b, at, start, 4.0, 1e-9).is_err());
}
/// A prism of the polygon `pts` (in xy) from z = 0 to `h`.
fn prism(pts: &[(f64, f64)], h: f64) -> Body {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let outer = Loop2::polygon(&pts.iter().map(|(x, y)| Vec2::new(*x, *y)).collect::<Vec<_>>());
crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, h).unwrap().pop().unwrap()
}
/// The material a right-angled blend with the radius `law` removes along an edge of length L:
/// ∫ (1 − π/4) r² over the edge.
fn removed(law: &RadiusSpline, len: f64) -> f64 {
let n = 20000;
(1.0 - std::f64::consts::FRAC_PI_4) * (0..n).map(|i| law.eval((i as f64 + 0.5) / n as f64).powi(2)).sum::<f64>() / n as f64 * len
}
/// The volume of a fine tessellation: at a blend closed to a point `measure` takes a
/// coarser mesh of the blend, a few mm³ off on these parts.
fn fine_volume(b: &Body) -> f64 {
b.tessellate_with(5e-4, true).unwrap().measure().volume
}
/// A taper on the top front edge of a wedge, whose top face narrows to a point, and of a
/// trapezoid, whose top face narrows to 2 mm: the radius asked for where it fits, below
/// 0.9 of the room everywhere, down to zero at the wedge's tip and to 1.8 mm at the
/// trapezoid's narrow end; the volume integrates the spline.
#[test]
fn taper_follows_the_room_along_the_edge() {
let (at, start) = (Vec3::new(20.0, 0.0, 15.0), Vec3::new(0.0, 0.0, 15.0));
let wedge = prism(&[(0.0, 0.0), (40.0, 0.0), (0.0, 20.0)], 15.0);
assert!(matches!(crate::fillet(&wedge, &[at], 16.0), Err(KernelError::TooLarge(_))));
let t = taper(&wedge, at, 16.0).unwrap();
let law = if t.start.dist(start) < 1e-9 { t.spline.clone() } else { t.spline.reversed() };
// The room: the top face is 20 (1 − u) wide, the front 15 high.
for i in 0..=100 {
let u = i as f64 / 100.0;
let r = law.eval(u);
assert!(r <= 0.9 * (20.0 * (1.0 - u)).min(15.0) + 1e-9 && r <= 16.0, "{u}: {r}");
}
assert_eq!(law.eval(1.0), 0.0);
assert!((law.eval(0.0) - 13.5).abs() < 1e-9);
let (f, tapers) = fillet_tapered(&wedge, &[at], 16.0).unwrap();
assert_eq!(tapers, vec![Some(t)]);
let v = fine_volume(&f);
assert!(rel(v, 6000.0 - removed(&law, 40.0)) < 1e-4, "{v} vs {}", 6000.0 - removed(&law, 40.0));
assert_eq!(f.unmeshed_faces(), 0);
let faces: Vec<&mt::Face> = f.solid.boundaries().iter().flat_map(|sh| sh.face_iter()).collect();
assert!(faces.iter().any(|fc| fc.edge_iter().count() == 3));
let trap = prism(&[(0.0, 0.0), (40.0, 0.0), (40.0, 2.0), (0.0, 20.0)], 15.0);
assert!(matches!(crate::fillet(&trap, &[at], 3.0), Err(KernelError::TooLarge(_))));
let (f, tapers) = fillet_tapered(&trap, &[at], 3.0).unwrap();
let t = tapers.into_iter().flatten().next().unwrap();
let law = if t.start.dist(start) < 1e-9 { t.spline } else { t.spline.reversed() };
assert!((law.eval(0.0) - 3.0).abs() < 1e-9 && (law.eval(1.0) - 1.8).abs() < 1e-9);
let v = fine_volume(&f);
assert!(rel(v, 6600.0 - removed(&law, 40.0)) < 1e-4, "{v} vs {}", 6600.0 - removed(&law, 40.0));
// A radius that fits stays constant.
let (g, tapers) = fillet_tapered(&trap, &[at], 1.0).unwrap();
assert_eq!(tapers, vec![None]);
assert!(rel(measure(&g).unwrap().volume, measure(&crate::fillet(&trap, &[at], 1.0).unwrap()).unwrap().volume) < 1e-12);
}
/// A radius spline given directly: a cubic with its control radii pulling the radius up in
/// the middle; the same from the other end; refused when it isn't clamped or dips to zero.
#[test]
fn variable_radius_from_a_spline() {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
let (at, start) = (Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0));
let law = RadiusSpline { degree: 3, knots: vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0], radii: vec![2.0, 2.0, 6.0, 3.0, 0.0] };
let f = fillet_variable_spline(&b, at, start, &law).unwrap();
let v = fine_volume(&f);
assert!(rel(v, 6000.0 - removed(&law, 20.0)) < 1e-4, "{v} vs {}", 6000.0 - removed(&law, 20.0));
let r = fillet_variable_spline(&b, at, Vec3::new(10.0, 20.0, 30.0), &law.reversed()).unwrap();
assert!(rel(fine_volume(&r), v) < 1e-9);
let unclamped = RadiusSpline { knots: vec![0.0, 0.0, 0.0, 0.1, 0.5, 1.0, 1.0, 1.0, 1.0], ..law.clone() };
assert!(fillet_variable_spline(&b, at, start, &unclamped).is_err());
let dips = RadiusSpline { radii: vec![2.0, 0.0, 0.0, 3.0, 2.0], ..law.clone() };
assert!(fillet_variable_spline(&b, at, start, &dips).is_err());
// A middle radius beyond the faces: too large.
let big = RadiusSpline { radii: vec![2.0, 2.0, 40.0, 3.0, 2.0], ..law };
assert!(matches!(fillet_variable_spline(&b, at, start, &big), Err(KernelError::TooLarge(_))));
}
#[test]
fn chord_length_on_a_right_angle() {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
+121 -12
View File
@@ -20,6 +20,13 @@ pub(crate) type Solid = mt::Solid;
#[derive(Clone, Debug)]
pub struct Body {
pub(crate) solid: std::sync::Arc<Solid>,
/// Bounding-box diagonal, measured by [`Body::new`] while it read the vertices anyway; 0
/// means "not measured, walk the geometry". Every kernel tolerance is a fraction of it.
pub(crate) size: f64,
/// The last volume measured for this solid and the tolerance it was measured at, shared by
/// clones because they share the solid. A boolean measures its operands, and a later boolean
/// takes the result as an operand again, so the same body is measured twice otherwise.
pub(crate) volume: Option<std::sync::Arc<std::sync::Mutex<Option<(u64, f64)>>>>,
/// Missing-face check from the orientation mesh, reusable at exactly its tolerance.
pub(crate) coarse_missing: Option<(f64, usize)>,
/// Set for mesh bodies (`solid` is then empty).
@@ -441,6 +448,25 @@ fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
(pts.first().copied().unwrap_or_default(), len)
}
/// Deflection the orientation mesh uses: coarse enough for a large solid, fine enough that a
/// small one still tessellates. `unmeshed_faces` counts at the same value, so the count the
/// orientation mesh produced is reused instead of meshing the body again.
fn orientation_tolerance(span: f64) -> f64 {
(span * 0.01).clamp(1e-3, 1.0)
}
/// Bounding-box diagonal of a solid's vertices (1 when it has no finite extent).
fn span_of(solid: &Solid) -> f64 {
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut hi = -lo;
for v in solid.vertex_iter() {
let p = from_p3(v.point());
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
}
if lo.x.is_finite() { (hi - lo).len() } else { 1.0 }
}
impl Body {
/// The very same body (shared data), not just an equal-looking one. Its history (how an
/// operation made it) is not part of it: an operation that leaves a body as it is (a cut by
@@ -466,17 +492,8 @@ impl Body {
let mut solid = rephase_revolved(solid);
// Orient outward: a closed solid must have positive volume (meshed finely enough that
// every face comes out: a face missing from a coarse mesh can flip the sign).
let span = {
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut hi = -lo;
for v in solid.vertex_iter() {
let p = from_p3(v.point());
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
}
if lo.x.is_finite() { (hi - lo).len() } else { 1.0 }
};
let tol = (span * 0.01).clamp(1e-3, 1.0);
let span = span_of(&solid);
let tol = orientation_tolerance(span);
let orient = |tol: f64| {
guard("orient", || {
let shells = mesh_shells(&solid, tol);
@@ -502,6 +519,8 @@ impl Body {
}
Ok(Body {
solid: std::sync::Arc::new(solid),
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: (v >= 0.0).then_some((tol, missing)),
mesh: None,
color: None,
@@ -513,6 +532,35 @@ impl Body {
})
}
/// Like [`Body::new`], for a solid made by placing an already-oriented body (see
/// [`crate::ops::transform_matrix`]): a placement keeps the faces pointing the way they did
/// unless it mirrors them, so its orientation is read from the matrix rather than meshed
/// again. Only for closed solids; `coarse` is the input's cached missing-face count, which a
/// rigid placement preserves exactly.
pub(crate) fn new_placed(solid: Solid, flip: bool, coarse: Option<(f64, usize)>) -> Result<Body> {
if solid.boundaries().is_empty() {
return Err(KernelError::Failed("empty result".into()));
}
let mut solid = rephase_revolved(solid);
let span = span_of(&solid);
if flip {
solid.not();
}
Ok(Body {
solid: std::sync::Arc::new(solid),
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: coarse,
mesh: None,
color: None,
paint: None,
splits: None,
face_sources: None,
regularization_protected: false,
history: None,
})
}
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
self.regularization_protected |= input.regularization_protected;
self
@@ -843,8 +891,13 @@ impl Body {
self
}
/// Characteristic size (bounding box diagonal) for tolerances.
/// Characteristic size (bounding box diagonal) for tolerances. [`Body::new`] measured it
/// while it read the vertices anyway, so the answer is cached; a body built by hand carries
/// 0 and is walked here once, and mesh bodies always measure their triangles.
pub fn size(&self) -> f64 {
if self.mesh.is_none() && self.size > 0.0 {
return self.size;
}
let mut b = solidcraft_geom::Aabb3::EMPTY;
if let Some(m) = &self.mesh {
for p in &m.positions {
@@ -858,6 +911,37 @@ impl Body {
if d.is_finite() && d > 0.0 { d } else { 1.0 }
}
/// The body's volume from a mesh at `tol`, measured once per (solid, tolerance): clones of
/// a body share the measurement, so an operand a later boolean takes again is not meshed
/// twice for the same number. `None` when the body cannot be meshed.
pub fn volume_at(&self, tol: f64) -> Option<f64> {
let key = if tol.is_finite() && tol > 0.0 { tol } else { 0.05 };
let key = key.to_bits();
if let Some(cell) = &self.volume
&& let Ok(cached) = cell.lock()
&& let Some((cached_tol, volume)) = *cached
&& cached_tol == key
{
return Some(volume);
}
let volume = self.tessellate(f64::from_bits(key)).ok()?.measure().volume;
if let Some(cell) = &self.volume
&& let Ok(mut slot) = cell.lock()
{
*slot = Some((key, volume));
}
Some(volume)
}
/// Rough heap footprint, for the evaluation cache's byte budget: the B-rep topology is what
/// makes a kept timeline snapshot expensive, so it dominates the estimate.
pub fn retained_bytes(&self) -> usize {
if let Some(m) = &self.mesh {
return m.positions.len() * 24 + m.triangles.len() * 12;
}
self.solid.face_iter().count() * 2048 + self.solid.edge_iter().count() * 512 + self.solid.vertex_iter().count() * 96
}
/// Distinct edges in a stable order (the order of `edges()` and `tessellate().edges`).
pub(crate) fn unique_edges(solid: &Solid) -> Vec<mt::Edge> {
let mut seen = std::collections::HashSet::new();
@@ -963,6 +1047,31 @@ impl Body {
})
}
/// The display tessellation as a plan for the CPU or GPU evaluator: every face's
/// triangulation in its own parameters, with each edge sampled once (chord tolerance
/// `tol`, mm) so faces meet exactly.
pub fn display_plan(&self, tol: f64) -> Result<solidcraft_geom::tess::TessPlan> {
self.plan_within(tol, None)
}
/// Like [`Body::display_plan`], and no facet turns more than `angle` radians (see
/// [`Body::display_mesh_angular`]).
pub fn display_plan_angular(&self, tol: f64, angle: f64) -> Result<solidcraft_geom::tess::TessPlan> {
self.plan_within(tol, Some(angle))
}
fn plan_within(&self, tol: f64, angle: Option<f64>) -> Result<solidcraft_geom::tess::TessPlan> {
if self.is_mesh() {
return Err(KernelError::Invalid("a mesh body has no surfaces to tessellate".into()));
}
let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.05 };
let tol = tol.max(self.size() * 1e-6);
// The same angle and floor as the reference mesher's angular display mesh.
let turn =
angle.filter(|a| a.is_finite()).map(|a| crate::tess::size::Turn { angle: a.max(MIN_DISPLAY_ANGLE), floor: tol * DISPLAY_ANGLE_FLOOR });
guard("tessellate", || crate::tess::plan(&self.solid, tol, turn).map(|(plan, _)| plan))
}
/// Faces with no triangles in a coarse tessellation (the mesher failed or gave up).
pub fn unmeshed_faces(&self) -> usize {
if self.is_mesh() {
+29
View File
@@ -146,6 +146,35 @@ pub(crate) fn heal_body(body: &Body, solid: Solid, size: f64, keep: &[Vec<Vec3>]
heal_build(solid, size, keep, faces, edges, counts)
}
/// A prism boolean's result `body` with the coplanar pieces its slabs leave merged (nothing
/// else healed: its edges are exact as they are), its history carried through, and what else it carries kept: each face's
/// construction source where every piece merged into it had that same one, its Split Face
/// lines and its regularization protection.
pub(crate) fn heal_prism_result(body: &Body, size: f64, keep: &[Vec<Vec3>], counts: &[(usize, usize)]) -> Result<Body> {
let (faces, edges): (Option<Vec<FaceProv>>, Option<Vec<Vec<EdgeOrigin>>>) =
body.history().map(|h| (h.faces.clone(), h.edges.iter().map(|e| e.origins.clone()).collect())).unzip();
let (healed, map) = crate::heal::merge_faces_traced((*body.solid).clone(), size, keep);
let sources = match (body.face_sources(), &map) {
(Some(sources), Some(m)) => Some(
m.faces
.iter()
.map(|under| {
let mut each = under.iter().map(|i| sources.get(*i).cloned().flatten());
let first = each.next().flatten()?;
each.all(|s| s.as_ref() == Some(&first)).then_some(first)
})
.collect::<Vec<_>>(),
),
_ => None,
};
let carried = faces.zip(edges).zip(map).and_then(|((f, e), m)| m.carry(&f, &e));
let (faces, edges) = carried.unzip();
let mut out = record::build(healed, counts, faces, edges.as_ref())?;
out = out.with_face_sources(sources.map(std::sync::Arc::new)).inherit_regularization_protection(body);
out.splits = body.splits.clone();
Ok(out)
}
/// `body` healed as the local modifiers take it (coplanar neighbours as one face, as after a
/// join; faces split on purpose kept apart), with its history over `[body]`.
pub(crate) fn healed(body: &Body) -> Result<Body> {
+59 -23
View File
@@ -31,11 +31,41 @@ fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, S
if !lp.is_closed(1e-6) {
return Err(KernelError::Invalid("profile loop is not closed".into()));
}
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
chain_sources(plane, &lp.segs, true)
}
/// An open wire in 3D through a chain of sketch segments, each starting where the one before
/// ends (a closed chain makes a closed wire).
pub(crate) fn chain_wire(plane: &Plane, segs: &[Seg2]) -> Result<mt::Wire> {
let n = segs.len();
if n == 0 || n > MAX_SEGS {
return Err(KernelError::Invalid(format!("curve chain with {n} segments")));
}
if !segs.iter().all(seg_ok) {
return Err(KernelError::Invalid("non-finite curve".into()));
}
for (s, t) in segs.iter().zip(segs.iter().skip(1)) {
if s.end().dist(t.start()) > 1e-6 {
return Err(KernelError::Invalid("the curves are not connected end to end".into()));
}
}
let closed = segs.first().zip(segs.last()).is_some_and(|(f, l)| l.end().dist(f.start()) <= 1e-6);
chain_sources(plane, segs, closed).map(|(wire, _)| wire)
}
/// The edges of a chain of segments (closed: the last one ends on the first's start vertex),
/// with each edge's segment and that segment's position in the chain (a big arc makes two
/// edges).
fn chain_sources(plane: &Plane, segs: &[Seg2], closed: bool) -> Result<(mt::Wire, Vec<(mt::Edge, Seg2, usize)>)> {
let n = segs.len();
let mut verts: Vec<mt::Vertex> = segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
if !closed && let Some(last) = segs.last() {
verts.push(builder::vertex(p3(plane.to_world(last.end()))));
}
let mut edges = Vec::with_capacity(n);
let mut sources = Vec::with_capacity(n);
for (i, s) in lp.segs.iter().enumerate() {
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { continue };
for (i, s) in segs.iter().enumerate() {
let (Some(a), Some(b)) = (verts.get(i), verts.get(if closed { (i + 1) % n } else { i + 1 })) else { continue };
let e: mt::Edge = match *s {
Seg2::Line { .. } => builder::line(a, b),
Seg2::Arc { sweep, .. } => {
@@ -128,32 +158,38 @@ fn region_ok(r: &Region2) -> Result<()> {
if r.area().abs() < 1e-9 {
return Err(KernelError::Invalid("profile has no area".into()));
}
for s in std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs) {
let ok = match *s {
Seg2::Line { a, b } => a.is_finite() && b.is_finite(),
Seg2::Arc { center, radius, start, sweep } => {
center.is_finite() && radius.is_finite() && radius > 1e-9 && start.is_finite() && sweep.is_finite()
}
Seg2::Cubic { p0, p1, p2, p3 } => p0.is_finite() && p1.is_finite() && p2.is_finite() && p3.is_finite(),
Seg2::Conic { a, apex, b, w } => a.is_finite() && apex.is_finite() && b.is_finite() && w.is_finite() && w > 1e-9,
Seg2::Bezier { n, p } => (4..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) && p.iter().all(|q| q.is_finite()),
};
if !ok {
return Err(KernelError::Invalid("non-finite profile".into()));
}
if !std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).all(seg_ok) {
return Err(KernelError::Invalid("non-finite profile".into()));
}
Ok(())
}
/// A segment with finite, well-formed numbers.
fn seg_ok(s: &Seg2) -> bool {
match *s {
Seg2::Line { a, b } => a.is_finite() && b.is_finite(),
Seg2::Arc { center, radius, start, sweep } => {
center.is_finite() && radius.is_finite() && radius > 1e-9 && start.is_finite() && sweep.is_finite()
}
Seg2::Cubic { p0, p1, p2, p3 } => p0.is_finite() && p1.is_finite() && p2.is_finite() && p3.is_finite(),
Seg2::Conic { a, apex, b, w } => a.is_finite() && apex.is_finite() && b.is_finite() && w.is_finite() && w > 1e-9,
Seg2::Bezier { n, p } => (4..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) && p.iter().all(|q| q.is_finite()),
}
}
/// A finite, orthonormal sketch frame.
pub(crate) fn frame_ok(plane: &Plane) -> bool {
plane.origin.is_finite()
&& plane.x.is_finite()
&& plane.y.is_finite()
&& (plane.x.len2() - 1.).abs() <= 1e-8
&& (plane.y.len2() - 1.).abs() <= 1e-8
&& plane.x.dot(plane.y).abs() <= 1e-8
}
/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`).
pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
if !plane.origin.is_finite()
|| !plane.x.is_finite()
|| !plane.y.is_finite()
|| (plane.x.len2() - 1.).abs() > 1e-8
|| (plane.y.len2() - 1.).abs() > 1e-8
|| plane.x.dot(plane.y).abs() > 1e-8
{
if !frame_ok(plane) {
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
}
let (start, end) = (finite(start, "start")?, finite(end, "end")?);
+323
View File
@@ -0,0 +1,323 @@
//! Has a face room to move?
//!
//! Coincident faces are pushed apart before a boolean (see [`crate::coplanar`]). That leaves
//! the result as it was only where the space a face moves through holds nothing of the other
//! body (a cut), or nothing but its material (a join). A [`Clearance`] decides that on the
//! other body's faces: every triangle of its mesh is clipped against that space, so a wall, a
//! rib or a hole anywhere in it is found. Sample points miss what lies between them.
use std::collections::HashMap;
use solidcraft_geom::{Aabb3, Mesh, Vec3};
use truck_modeling as mt;
use crate::body::Body;
use crate::offset::{Surf, surf_of};
/// How a body's material sits in a region of space.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Occupancy {
/// The region holds none of it.
Empty,
/// The region lies wholly in it.
Full,
/// A face reaches into the region, or may: no answer.
Mixed,
}
/// A convex region: the points `p` with `n · p <= d` for every side `(n, d)`, `n` a unit vector
/// pointing out of it.
struct Convex(Vec<(Vec3, f64)>);
impl Convex {
/// The prism a triangle sweeps when it moves along the unit vector `along` (not in its
/// plane) from the distance `lo` to `hi`, its sides moved out by `margin` (in, when
/// negative). `None` for a triangle without area or an empty interval.
fn swept(tri: [Vec3; 3], along: Vec3, margin: f64, lo: f64, hi: f64) -> Option<Convex> {
let [a, b, c] = tri;
let centre = (a + b + c) / 3.0;
if hi <= lo || !(margin + lo + hi).is_finite() || (b - a).cross(c - a).dot(along).abs() < 1e-300 {
return None;
}
let mut sides = Vec::with_capacity(5);
for (p, q) in [(a, b), (b, c), (c, a)] {
let mut out = (q - p).cross(along).normalized()?;
if out.dot(centre - p) > 0.0 {
out = -out;
}
sides.push((out, out.dot(p) + margin));
}
let base = along.dot(a);
sides.push((along, base + hi));
sides.push((-along, -(base + lo)));
Some(Convex(sides))
}
fn contains(&self, p: Vec3) -> bool {
self.0.iter().all(|(n, d)| n.dot(p) <= *d)
}
/// Does the triangle reach into the region? Touching its boundary counts.
fn meets(&self, tri: [Vec3; 3]) -> bool {
// A side with the whole triangle beyond it settles most triangles at once.
if self.0.iter().any(|(n, d)| tri.iter().all(|p| n.dot(*p) > *d)) {
return false;
}
// What the sides leave of the triangle, one side after the other.
let mut left: Vec<Vec3> = tri.to_vec();
let mut next: Vec<Vec3> = Vec::with_capacity(tri.len() + self.0.len());
for (n, d) in &self.0 {
let Some(&last) = left.last() else { return false };
next.clear();
let (mut p, mut sp) = (last, n.dot(last) - d);
for &q in &left {
let sq = n.dot(q) - d;
if (sp < 0.0 && sq > 0.0) || (sp > 0.0 && sq < 0.0) {
next.push(p + (q - p) * (sp / (sp - sq)));
}
if sq <= 0.0 {
next.push(q);
}
(p, sp) = (q, sq);
}
std::mem::swap(&mut left, &mut next);
}
!left.is_empty()
}
}
/// How far a face can stand off its triangles, seen from a region along one direction.
#[derive(Clone, Copy)]
enum Reach {
/// Not at all: a planar face, a facet of a mesh body.
Exact,
/// Sideways only: a wall of straight lines along the direction (a hole drilled along it).
Sideways,
/// By the chord error, any way.
Around,
/// By the chord error, to one side: a face of a cylinder, a cone or a sphere lies beyond its
/// triangles seen from the material when it is `convex`, seen from outside when it is not.
Beyond { convex: bool },
/// It ends before the region begins or begins past its end (a fillet running into the
/// plane the region starts from).
Never,
}
/// A body's faces, for telling how its material sits in the space a face moves through.
pub(crate) struct Clearance<'a> {
body: &'a Body,
mesh: Mesh,
/// How far a curved face may stand off its triangles.
sag: f64,
tol: f64,
}
/// Pairs of triangles one question may compare; beyond it there is no answer.
const MAX_PAIRS: usize = 2_000_000;
impl<'a> Clearance<'a> {
/// `chord`: the chord error of the mesh the body is asked on.
pub(crate) fn new(body: &'a Body, chord: f64) -> Option<Clearance<'a>> {
let mesh = body.tessellate_with(chord, true).ok()?;
(!mesh.triangles.is_empty()).then(|| Clearance { body, mesh, sag: 2.0 * chord, tol: (body.size() * 1e-7).max(1e-9) })
}
/// How the body's material sits in the space that `faces` move through when they go along
/// the unit vector `along` from the distance `lo` to `hi`. `faces`: the triangles of
/// planar faces square to `along`, each with how far its face reaches past it sideways
/// (negative: the triangle's sides are drawn in, so that faces lying against them don't
/// count). `Mixed` as soon as a face of the body reaches into that space.
pub(crate) fn swept(&self, faces: &[([Vec3; 3], f64)], along: Vec3, lo: f64, hi: f64) -> Occupancy {
let Some((first, _)) = faces.first() else { return Occupancy::Mixed };
let base = along.dot(first[0]);
// The body's triangles near that space.
let mut space = Aabb3::EMPTY;
let mut pad = self.sag;
for (tri, margin) in faces {
pad = pad.max(self.sag + margin);
tri.iter().for_each(|p| [lo - self.sag, hi + self.sag].iter().for_each(|s| space.add(*p + along * *s)));
}
let (min, max) = (space.min - Vec3::new(pad, pad, pad), space.max + Vec3::new(pad, pad, pad));
let outside = |tri: &[Vec3; 3]| {
let all = |f: &dyn Fn(Vec3) -> bool| tri.iter().all(|p| f(*p));
all(&|p| p.x < min.x)
|| all(&|p| p.x > max.x)
|| all(&|p| p.y < min.y)
|| all(&|p| p.y > max.y)
|| all(&|p| p.z < min.z)
|| all(&|p| p.z > max.z)
};
let mut near: Vec<(u32, [Vec3; 3])> = Vec::new();
for (t, face) in self.mesh.triangles.iter().zip(&self.mesh.tri_face) {
let Some(tri) = self.mesh.tri(t) else { return Occupancy::Mixed };
if !outside(&tri) {
near.push((*face, tri));
}
}
if faces.len().saturating_mul(near.len()) > MAX_PAIRS {
return Occupancy::Mixed;
}
let inside = self.mesh.inside_index();
let mut reach: HashMap<u32, Reach> = HashMap::new();
let mut all = None;
for (tri, margin) in faces {
let prism = |side: f64, end: f64| Convex::swept(*tri, along, margin + side, lo - end, hi + end);
let probe = (tri[0] + tri[1] + tri[2]) / 3.0 + along * ((lo + hi) * 0.5);
// A sliver thinner than its margins covers nothing that its neighbours don't.
let Some(tight) = prism(0.0, 0.0).filter(|p| p.contains(probe)) else { continue };
let (Some(sideways), Some(around)) = (prism(self.sag, 0.0), prism(self.sag, self.sag)) else { return Occupancy::Mixed };
// With no face in it, the prism is all material or all empty.
let here = if inside.contains(probe) { Occupancy::Full } else { Occupancy::Empty };
let reaches = |(face, t): &(u32, [Vec3; 3])| {
around.meets(*t)
&& match *reach.entry(*face).or_insert_with(|| self.reach(*face, along, base + lo, base + hi)) {
Reach::Exact => tight.meets(*t),
Reach::Sideways => sideways.meets(*t),
// Its triangles lie between the prism and the face.
Reach::Beyond { convex } if convex == (here == Occupancy::Full) => tight.meets(*t),
Reach::Around | Reach::Beyond { .. } => true,
Reach::Never => false,
}
};
if near.iter().any(reaches) {
return Occupancy::Mixed;
}
if all.is_some_and(|seen| seen != here) {
return Occupancy::Mixed;
}
all = Some(here);
}
all.unwrap_or(Occupancy::Mixed)
}
/// Has the body nothing between the heights `lo` and `hi` above the plane `n · p = d` (`n`
/// a unit vector), anywhere?
pub(crate) fn clear_of_slab(&self, n: Vec3, d: f64, lo: f64, hi: f64) -> bool {
let mut reach: HashMap<u32, Reach> = HashMap::new();
self.mesh.triangles.iter().zip(&self.mesh.tri_face).all(|(t, face)| {
let Some(tri) = self.mesh.tri(t) else { return false };
let pad = match *reach.entry(*face).or_insert_with(|| self.reach(*face, n, d + lo, d + hi)) {
Reach::Never => return true,
Reach::Around | Reach::Beyond { .. } => self.sag,
Reach::Exact | Reach::Sideways => 0.0,
};
let heights = tri.map(|p| n.dot(p) - d);
heights.iter().all(|s| s + pad < lo) || heights.iter().all(|s| s - pad > hi)
})
}
/// How the face's triangles stand for it in a region between the heights `lo` and `hi`
/// along `along` (measured as `along · p`).
fn reach(&self, face: u32, along: Vec3, lo: f64, hi: f64) -> Reach {
if self.body.is_mesh() {
return Reach::Exact;
}
crate::guard("face reach", || {
let Some(f) = self.body.solid.face_iter().nth(face as usize) else { return Ok(Reach::Around) };
let surface = f.oriented_surface();
if matches!(surface, mt::Surface::Plane(_)) {
return Ok(Reach::Exact);
}
let round = surf_of(f, self.tol * 100.0);
let straight = matches!(round, Some(Surf::Cylinder { a, .. }) if a.dot(along).abs() > 1.0 - 1e-12)
|| crate::prism::wall_along(&surface, along, self.tol * 10.0, &mut 262_144);
Ok(if straight {
Reach::Sideways
} else if round.and_then(|s| span(&s, along)).is_some_and(|(s0, s1)| s1 <= lo || s0 >= hi) {
Reach::Never
} else if let Some(Surf::Cylinder { convex, .. } | Surf::Cone { convex, .. } | Surf::Sphere { convex, .. }) = round {
Reach::Beyond { convex }
} else {
Reach::Around
})
})
.unwrap_or(Reach::Around)
}
}
/// The whole extent of a round surface along `along`, where it has one.
fn span(s: &Surf, along: Vec3) -> Option<(f64, f64)> {
let about = |c: Vec3, r: f64| Some((along.dot(c) - r.abs(), along.dot(c) + r.abs()));
match *s {
Surf::Cylinder { o, a, r, .. } if a.dot(along).abs() < 1e-9 => about(o, r),
Surf::Sphere { c, r, .. } => about(c, r),
Surf::Torus { o, a, h, r, .. } if a.dot(along).abs() > 1.0 - 1e-12 => about(o + a * h, r),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{BoolOp, boolean, box_solid, cylinder, fillet};
fn clearance(body: &Body) -> Clearance<'_> {
Clearance::new(body, body.size() * 2e-4).unwrap()
}
/// The top face of a 200 x 100 x 20 block between x = `x0` and `x1`, y = 10 and 90, as two
/// triangles with their sides drawn in by 1e-4.
fn top(x0: f64, x1: f64) -> Vec<([Vec3; 3], f64)> {
let p = |x: f64, y: f64| Vec3::new(x, y, 20.0);
vec![([p(x0, 10.0), p(x1, 10.0), p(x1, 90.0)], -1e-4), ([p(x0, 10.0), p(x1, 90.0), p(x0, 90.0)], -1e-4)]
}
/// The space over a block's top face is empty and the space under it is material, up to
/// the smallest thing that stands in it, wherever that is.
#[test]
fn the_space_a_face_moves_through_is_empty_or_material_until_something_stands_in_it() {
let block = box_solid(Vec3::ZERO, Vec3::new(200.0, 100.0, 20.0)).unwrap();
let c = clearance(&block);
assert_eq!(c.swept(&top(10.0, 190.0), Vec3::Z, 1e-4, 3.0), Occupancy::Empty);
assert_eq!(c.swept(&top(10.0, 190.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Full);
// 25 down is through the bottom face (the block is 20 thick); 210 is past its side x = 200.
assert_eq!(c.swept(&top(10.0, 190.0), -Vec3::Z, 1e-4, 25.0), Occupancy::Mixed);
assert_eq!(c.swept(&top(10.0, 210.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
assert_eq!(c.swept(&[], Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
assert!(c.clear_of_slab(Vec3::Z, 20.0, 1e-4, 3.0) && !c.clear_of_slab(Vec3::Z, 20.0, -3.0, -1e-4));
// A pin 0.4 x 0.4 on the top face, at x = 137.3 to 137.7: in the way of what covers it only.
let pin = box_solid(Vec3::new(137.3, 61.1, 20.0), Vec3::new(137.7, 61.5, 26.0)).unwrap();
let pinned = boolean(&block, &pin, BoolOp::Union).unwrap().unwrap();
let c = clearance(&pinned);
assert_eq!(c.swept(&top(10.0, 190.0), Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
assert_eq!(c.swept(&top(10.0, 137.0), Vec3::Z, 1e-4, 3.0), Occupancy::Empty);
assert!(!c.clear_of_slab(Vec3::Z, 20.0, 1e-4, 3.0) && c.clear_of_slab(Vec3::Z, 20.0, 6.0 + 1e-4, 9.0));
// A 4 mm hole through the block at x = 60: no material all the way under what covers it.
let hole = cylinder(Vec3::new(60.0, 50.0, -5.0), Vec3::Z, 2.0, 30.0).unwrap();
let holed = boolean(&block, &hole, BoolOp::Cut).unwrap().unwrap();
let c = clearance(&holed);
assert_eq!(c.swept(&top(10.0, 190.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
assert_eq!(c.swept(&top(70.0, 190.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Full);
}
/// Round faces that end in the plane a face lies in leave the space beyond it free: a
/// hole's wall, a fillet running into the top face. A round face that rises into that space
/// by less than its chords show does not.
#[test]
fn round_faces_ending_at_the_plane_leave_the_space_beyond_it_free() {
let block = box_solid(Vec3::ZERO, Vec3::new(200.0, 100.0, 20.0)).unwrap();
let hole = cylinder(Vec3::new(60.0, 50.0, -5.0), Vec3::Z, 8.0, 30.0).unwrap();
let holed = boolean(&block, &hole, BoolOp::Cut).unwrap().unwrap();
// The fillet (r = 4) along the edge y = 0 of the top face ends on the line y = 4, z = 20.
let rounded = fillet(&holed, &[Vec3::new(100.0, 0.0, 20.0)], 4.0).unwrap();
let c = clearance(&rounded);
// The top face from x = `x0` to 190 and y = `y0` to 90.
let rect = |x0: f64, y0: f64| {
let p = |x: f64, y: f64| Vec3::new(x, y, 20.0);
vec![([p(x0, y0), p(190.0, y0), p(190.0, 90.0)], -1e-4), ([p(x0, y0), p(190.0, 90.0), p(x0, 90.0)], -1e-4)]
};
assert_eq!(c.swept(&rect(10.0, -10.0), Vec3::Z, 1e-4, 3.0), Occupancy::Empty);
assert!(c.clear_of_slab(Vec3::Z, 20.0, 1e-4, 3.0));
// Under the top face the fillet is in the way from its tangent line y = 4 down (and no
// further: its chords lie inside it), the hole from x = 68 down.
assert_eq!(c.swept(&rect(80.0, 4.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Full);
assert_eq!(c.swept(&rect(80.0, 3.9), -Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
assert_eq!(c.swept(&rect(60.0, 5.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
// A bar lying along x (axis at z = 12): its top ends 0.05 um short of where the space
// begins (z = 20.0001), then reaches 0.9 um into it.
for (radius, want) in [(8.00005, Occupancy::Empty), (8.001, Occupancy::Mixed)] {
let bar = cylinder(Vec3::new(20.0, 50.0, 12.0), Vec3::X, radius, 160.0).unwrap();
assert_eq!(clearance(&bar).swept(&top(30.0, 150.0), Vec3::Z, 1e-4, 3.0), want, "radius {radius}");
}
}
}
+207 -5
View File
@@ -10,6 +10,17 @@
//! - union of faces facing the same way: both faces move outward by different amounts, the
//! union is taken, and the slab beyond the plane is cut off (now a transversal cut);
//! - union of faces back to back: the tool's face moves into the target, which overlaps it.
//!
//! Each of these leaves the result unchanged only where the space a face moves through holds
//! what the boolean leaves there anyway: none of the target for a cut or an intersection, the
//! target's material for a union, nothing of either body beyond a plane whose slab is cut off
//! again. The faces of a coincident plane lie on each other, but nothing says that what lies
//! beside them does: a tool's side in the face of a wall has the neighbouring walls beside it,
//! a channel's top in the face a rib stands on has the rib over it. So a face is asked first
//! what it would move through: a join's face moving into the target at points behind it
//! (`backed`), the others on the other body's faces (see [`crate::clearance`]). A face with
//! no room stays where it is and the boolean fails with [`NO_ROOM`], for the strategies that
//! take the bodies as they lie.
use std::collections::HashMap;
@@ -18,6 +29,7 @@ use truck_modeling::{self as mt, builder};
use crate::body::shell_groups::boxes_separate;
use crate::body::{Body, Solid, from_p3, p3, v3};
use crate::clearance::{Clearance, Occupancy};
use crate::history::{Change, EdgeOrigin, FaceOrigin, FaceProv, History};
use crate::{BoolOp, KernelError, Result, guard, record};
@@ -307,8 +319,12 @@ pub(crate) fn coincident_planes(a: &Body, b: &Body) -> Vec<(Vec3, f64, bool)> {
found
}
/// What [`boolean_apart`] fails with when a face has no room to move.
pub(crate) const NO_ROOM: &str = "coincident faces were left in place: pushing them apart would change the result";
/// Retry a failed boolean with coincident planar faces pushed apart. `None` when there are no
/// coincident faces or the faces can't be pushed.
/// coincident faces or the faces can't be pushed; the error [`NO_ROOM`] when a face would
/// move through something that changes the result.
pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9);
@@ -320,12 +336,31 @@ pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
let back: Vec<(Vec3, f64)> = coincident.iter().filter(|(_, _, s)| !*s).map(|(n, d, _)| (*n, *d)).collect();
let (d1, d2) = (size * 0.0137, size * 0.0229);
let counts = record::counts(&[a, b]);
let (skin, chord) = (tol * 10.0, size * 2e-4);
let no_room = || KernelError::Failed(NO_ROOM.into());
let run = || -> Result<Option<Body>> {
// The target's faces, meshed when the first face asks what it would move through.
let faces_of_a = std::cell::OnceCell::new();
let target = || faces_of_a.get_or_init(|| Clearance::new(a, chord)).as_ref().ok_or_else(no_room);
let (mut a2, mut b2) = (a.clone(), b.clone());
// What the pushed bodies are made of: their histories over [a] and [b].
let (mut ha, mut hb) = (crate::boolean_record::kept(a, 0, 1), crate::boolean_record::kept(b, 0, 1));
for (n, d) in &same {
// b's face points along n too.
let free = if op == BoolOp::Union {
// Both faces move out and the slab beyond the plane is cut off the joined body
// again, everywhere: neither body may have anything in it.
let depth = d1.max(d2) * 3.0 * 1.25;
let tool = Clearance::new(b, chord).ok_or_else(no_room)?;
target()?.clear_of_slab(*n, *d, skin, depth) && tool.clear_of_slab(*n, *d, skin, depth)
} else {
// The tool takes whatever of `a` its face moves through (a rib standing on the
// face a cut ends in).
target()?.swept(&in_plane(&b2, *n, *d, skin), *n, skin, d2 * 1.25) == Occupancy::Empty
};
if !free {
return Err(no_room());
}
b2 = push_faces(&b2, *n, *d, d2)?;
hb = crate::boolean_record::through(&b2, 0, hb.as_ref());
if op == BoolOp::Union {
@@ -346,6 +381,11 @@ pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
.find(|depth| backed(a, b, -*n, -*d, *depth))
.ok_or_else(|| KernelError::Failed("pushing a face: part of it lies over a hole or past the edge of the other body".into()))?
} else {
// Back into the tool, the face gives up what the tool took of `a` there (a
// wall standing beside the face the tool's foot lies on).
if target()?.swept(&in_plane(&b2, -*n, -*d, skin), *n, skin, d2 * 1.25) != Occupancy::Empty {
return Err(no_room());
}
-d2
};
b2 = push_faces(&b2, -*n, -*d, shift)?;
@@ -445,6 +485,26 @@ pub(crate) fn faces_touch(a: &Body, b: &Body) -> bool {
})
}
/// The triangles of `tool`'s faces in the plane (`m`, `d`), `m` their outward normal, each with
/// how far its face reaches past it sideways. A face bounded by straight edges is its
/// triangles; they are drawn in by `skin`, so that faces of the other body lying against the
/// face's sides are not in its way. A face with a curved edge reaches past its triangles by
/// the chord error.
fn in_plane(tool: &Body, m: Vec3, d: f64, skin: f64) -> Vec<([Vec3; 3], f64)> {
let chord = (tool.size() * 2e-4).max(skin);
let Ok(mesh) = tool.tessellate_with(chord, true) else { return Vec::new() };
let faces: Vec<&mt::Face> = tool.solid.face_iter().collect();
let margins: HashMap<usize, f64> = planes(tool)
.into_iter()
.filter(|(_, p)| p.n.dot(m) > 1.0 - 1e-9 && (p.d - d).abs() < skin)
.filter_map(|(i, _)| {
let straight = faces.get(i)?.edge_iter().all(|e| matches!(e.curve(), mt::Curve::Line(_)));
Some((i, if straight { -skin } else { 2.0 * chord }))
})
.collect();
mesh.triangles.iter().zip(&mesh.tri_face).filter_map(|(t, face)| Some((mesh.tri(t)?, *margins.get(&(*face as usize))?))).collect()
}
/// Has `a` material behind every part of b's faces in the plane (m, d) (m their outward
/// normal, pointing into `a`), to `depth`? Checked at points inside the faces' triangles.
fn backed(a: &Body, b: &Body, m: Vec3, d: f64, depth: f64) -> bool {
@@ -468,6 +528,128 @@ fn backed(a: &Body, b: &Body, m: Vec3, d: f64, depth: f64) -> bool {
!points.is_empty() && points.iter().all(|p| [0.05, 0.5, 1.0].iter().all(|k| inside.contains(*p + m * (depth * k))))
}
/// The faces of `body` lying in the plane (n, d), facing along n.
fn faces_in(body: &Body, n: Vec3, d: f64, tol: f64) -> Vec<usize> {
planes(body).into_iter().filter(|(_, p)| p.n.dot(n) > 1.0 - 1e-9 && (p.d - d).abs() < tol).map(|(i, _)| i).collect()
}
/// Do two face surfaces lie on one plane or one cylinder?
fn same_surface(x: &Option<crate::FaceSurface>, y: &Option<crate::FaceSurface>, tol: f64) -> bool {
use crate::FaceSurface::{Cylinder, Plane};
match (x, y) {
(Some(Plane { normal: n1, point: p1 }), Some(Plane { normal: n2, point: p2 })) => n1.dot(*n2) > 1.0 - 1e-9 && n1.dot(*p2 - *p1).abs() < tol,
(Some(Cylinder { origin: o1, axis: a1, radius: r1 }), Some(Cylinder { origin: o2, axis: a2, radius: r2 })) => {
let off = *o2 - *o1;
a1.dot(*a2).abs() > 1.0 - 1e-9 && (r1 - r2).abs() < tol && (off - *a1 * off.dot(*a1)).len() < tol
}
_ => false,
}
}
/// The box around faces `faces` of `body` (by their boundaries), grown by `gap`.
fn faces_box(body: &Body, faces: &[usize], gap: f64) -> Option<Aabb3> {
let fs: Vec<&mt::Face> = body.solid.face_iter().collect();
let mut bb = Aabb3::EMPTY;
for f in faces {
let b = face_box(fs.get(*f)?, gap)?;
bb.add(b.min);
bb.add(b.max);
}
(!bb.is_empty()).then(|| Aabb3 { min: bb.min - Vec3::new(gap, gap, gap), max: bb.max + Vec3::new(gap, gap, gap) })
}
/// `body` (with its history `h` over its operand) with its faces in the plane (n, d) split
/// where the faces of `tool` next to the tool's faces in the opposite plane cross them: the
/// outline of the tool's contact imprinted on the body's. Only the faces near the contact are
/// split, and each surface once.
fn imprinted(body: Body, mut h: Option<History>, n: Vec3, d: f64, tool: &Body, tol: f64) -> Result<(Body, Option<History>)> {
let contact = faces_in(tool, -n, -d, tol);
let gap = tol * 10.0;
let Some(region) = faces_box(tool, &contact, gap) else { return Ok((body, h)) };
let near = |b: &Body, faces: Vec<usize>| -> Vec<usize> {
let fs: Vec<&mt::Face> = b.solid.face_iter().collect();
faces.into_iter().filter(|f| fs.get(*f).and_then(|x| face_box(x, gap)).is_some_and(|bx| !boxes_separate(bx, region, gap))).collect()
};
let bounds = crate::history::edge_faces(&tool.solid);
let mut beside: Vec<usize> = bounds
.iter()
.filter(|fs| fs.len() == 2 && fs.iter().any(|f| contact.contains(f)))
.flat_map(|fs| fs.iter().copied())
.filter(|f| !contact.contains(f))
.collect();
beside.sort_unstable();
beside.dedup();
let mut body = body;
// One split per surface: the halves of a round side lie on one cylinder, and splitting
// along it again re-traces the line already there, leaving a sliver (or not) depending
// on how the second trace falls.
let mut used: Vec<Option<crate::FaceSurface>> = Vec::new();
for t in beside {
let surface = crate::face_surface(tool, t);
if used.iter().any(|u| same_surface(u, &surface, tol)) {
continue;
}
used.push(surface);
let faces = near(&body, faces_in(&body, n, d, tol));
if faces.is_empty() {
break;
}
// Faces the tool's face does not cross stay as they are; none crossed is an error.
let Ok((split, _)) = crate::splitface::split_any(&body, &faces, &crate::SplitTool::Face { body: tool, face: t }) else { continue };
h = crate::boolean_record::through(&split, 0, h.as_ref());
body = split;
}
Ok((body, h))
}
/// Union of bodies touching back to back over part of a planar face (a pad half past the edge
/// of the face it stands on, two blocks meeting over some of their faces, #674, #679): each
/// body's contact faces are split along the outline of the other's, so the common area is the
/// same face in both, and those faces are glued away. `None` when that does not give a result
/// `accept`ed.
pub(crate) fn imprint_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) -> Option<Body> {
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
let back: Vec<(Vec3, f64)> = coincident_planes(a, b).into_iter().filter(|(_, _, same)| !*same).map(|(n, d, _)| (n, d)).collect();
if back.is_empty() {
return None;
}
let run = || -> Result<Option<Body>> {
let (mut a2, mut ha) = (a.clone(), crate::boolean_record::kept(a, 0, 1));
let (mut b2, mut hb) = (b.clone(), crate::boolean_record::kept(b, 0, 1));
for (n, d) in &back {
// a's faces face along n in the plane; b's face the other way.
(a2, ha) = imprinted(a2, ha, *n, *d, b, tol)?;
(b2, hb) = imprinted(b2, hb, -*n, -*d, a, tol)?;
}
// The common faces' boundaries with the same vertices on both sides.
for (n, d) in &back {
let corners = |body: &Body, n: Vec3, d: f64| -> Vec<Vec3> {
let fs: Vec<&mt::Face> = body.solid.face_iter().collect();
let mut out: Vec<Vec3> =
faces_in(body, n, d, tol).iter().filter_map(|i| fs.get(*i)).flat_map(|f| f.vertex_iter().map(|v| from_p3(v.point()))).collect();
out.dedup_by(|x, y| x.dist(*y) <= tol);
out
};
let (pa, pb) = (corners(&a2, *n, *d), corners(&b2, -*n, -*d));
let a3 = crate::splitface::split_edges_at(&a2, &pb)?;
ha = crate::boolean_record::through(&a3, 0, ha.as_ref());
let b3 = crate::splitface::split_edges_at(&b2, &pa)?;
hb = crate::boolean_record::through(&b3, 0, hb.as_ref());
(a2, b2) = (a3, b3);
}
let Some(r) = glue(&a2, &b2) else { return Ok(None) };
let r = r?;
let (ha, hb) = (crate::boolean_record::placed(ha, 0, 2), crate::boolean_record::placed(hb, 1, 2));
let h = crate::boolean_record::of_pair(&r, &ha, &hb);
Ok(Some(r.with_checked_history(h, &record::counts(&[a, b]))))
};
match guard("imprint union", run) {
Ok(Some(r)) if accept(&r) => Some(r),
_ => None,
}
}
/// Union of bodies that touch along identical planar faces, back to back (a body joined with
/// its mirror image, or the pieces of a body cut apart by a plane, which may meet in several
/// faces): every such pair of faces goes and the shells are stitched along their edges. `None`
@@ -506,10 +688,20 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
let mut ok = true;
for e in &eb {
let ends = (key(from_p3(e.absolute_front().point())), key(from_p3(e.absolute_back().point())));
let m = key(mid(e));
let m = mid(e);
// The same curve: the same ends and e's middle on f (curves of one shape may be
// parametrised differently, so their parameter middles needn't coincide).
let on = |f: &mt::Edge| {
use mt::SearchNearestParameter;
let c = f.curve();
let (t0, t1) = c.range_tuple();
key(mid(f)) == key(m)
|| c.search_nearest_parameter(p3(m), None, 100)
.is_some_and(|t| (t0.min(t1) - 1e-9..=t0.max(t1) + 1e-9).contains(&t) && from_p3(c.subs(t)).dist(m) <= tol)
};
let hit = ea.iter().find(|f| {
let fe = (key(from_p3(f.absolute_front().point())), key(from_p3(f.absolute_back().point())));
(fe == ends || fe == (ends.1, ends.0)) && key(mid(f)) == m
(fe == ends || fe == (ends.1, ends.0)) && on(f)
});
let Some(f) = hit else {
ok = false;
@@ -586,10 +778,16 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
}
faces.push(nf);
}
let solid = Solid::try_new(vec![faces.into()]).map_err(|e| fail(&e.to_string()))?;
// Bodies of several pieces stay in several pieces (each face's provenance follows it).
let prov: HashMap<mt::FaceID, FaceProv> = faces.iter().map(|f| f.id()).zip(from).collect();
let shell: mt::Shell = faces.into();
let solid = Solid::try_new(crate::polybool::components_in_order(&shell)).map_err(|e| fail(&e.to_string()))?;
let from: Vec<FaceProv> = solid.face_iter().filter_map(|f| prov.get(&f.id()).cloned()).collect();
// Each edge's origins in the solid's edge order.
let origins = crate::body::edge_order(solid.face_iter()).0.iter().map(|e| origins.get(&e.id()).cloned()).collect();
crate::boolean_record::heal_build(solid, size, &[], Some(from), origins, &record::counts(&[a, b]))
let keep: Vec<_> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
crate::boolean_record::heal_build(solid, size, &keep, Some(from), origins, &record::counts(&[a, b]))
.map(|body| body.with_split_lines(Some(std::sync::Arc::new(keep))))
}))
}
@@ -869,3 +1067,7 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) ->
}
None
}
#[cfg(test)]
#[path = "coplanar_tests.rs"]
mod tests;
+77
View File
@@ -0,0 +1,77 @@
//! Booleans of bodies with faces in a common plane: a face is pushed out of the plane only where
//! that leaves the result as it is. Every expected volume is what the two bodies define.
#![allow(clippy::unwrap_used, clippy::panic)]
use crate::*;
use solidcraft_geom::Vec3;
/// An l x w x h block with two bottom edges chamfered by 1 mm: no prism along any axis, so
/// its booleans are not done on sections.
fn block(l: f64, w: f64, h: f64) -> Body {
let b = box_solid(Vec3::ZERO, Vec3::new(l, w, h)).unwrap();
let b = chamfer(&b, &[Vec3::new(l / 2.0, w, 0.0)], 1.0).unwrap();
chamfer(&b, &[Vec3::new(l, w / 2.0, 0.0)], 1.0).unwrap()
}
/// A 200 x 100 x 20 block with a rib across it: 20 wide over the block's 100, from the top
/// face z = 20 up to z = 30.
fn ribbed() -> Body {
let rib = box_solid(Vec3::new(90.0, 0.0, 20.0), Vec3::new(110.0, 100.0, 30.0)).unwrap();
boolean(&block(200.0, 100.0, 20.0), &rib, BoolOp::Union).unwrap().unwrap()
}
/// A channel along that block, longer than its 200: y 30 to 70 and z 10 to 20, so its top
/// lies in the plane of the face the rib stands on.
fn channel() -> Body {
box_solid(Vec3::new(-20.0, 30.0, 10.0), Vec3::new(220.0, 70.0, 20.0)).unwrap()
}
/// What the boolean of `a` and `b` adds to a's volume, the result's lumps, and how it was made.
fn change(a: &Body, b: &Body, op: BoolOp) -> (f64, usize, BooleanDiagnostics) {
let (r, how) = capture_boolean_diagnostics(|| boolean(a, b, op));
let r = r.unwrap().unwrap();
(measure(&r).unwrap().volume - measure(a).unwrap().volume, r.lumps().unwrap().len(), how)
}
fn used(how: &BooleanDiagnostics, strategy: BooleanStrategy) -> bool {
how.events.iter().any(|e| e.strategy == strategy)
}
/// A channel cut along a block, up to the face a rib stands on. The channel's top lies in
/// that face's plane and the rib bridges it: pushed up out of the plane (2.29 % of the
/// body's size, 5.6 mm), the top would cut a notch 20 x 40 into the rib.
#[test]
fn a_cut_up_to_a_face_leaves_what_stands_on_the_face() {
let (added, lumps, how) = change(&ribbed(), &channel(), BoolOp::Cut);
// It takes 200 x 40 x 10 of the block and nothing of the rib.
assert!((added + 80_000.0).abs() < 1e-6 && lumps == 1, "{added}, {lumps} lumps, {how:?}");
assert!(!used(&how, BooleanStrategy::CoplanarSeparation), "{how:?}");
}
/// The same with a round hole through the block beside the channel (r 4 at 30, 15: y 11 to
/// 19, the channel from 30 on). The body no longer has planar faces only, but the hole lies
/// clear of the tool, and the cut stays what the channel is.
#[test]
fn a_round_hole_beside_the_cut_changes_nothing() {
let hole = cylinder(Vec3::new(30.0, 15.0, -1.0), Vec3::Z, 4.0, 22.0).unwrap();
let drilled = boolean(&ribbed(), &hole, BoolOp::Cut).unwrap().unwrap();
let (added, lumps, how) = change(&drilled, &channel(), BoolOp::Cut);
// 200 x 40 x 10 again (the hole is measured on its mesh, before and after).
assert!((added + 80_000.0).abs() < 1e-3 && lumps == 1, "{added}, {lumps} lumps, {how:?}");
assert!(!used(&how, BooleanStrategy::CoplanarSeparation), "{how:?}");
}
/// With nothing in their way the faces are pushed as before: a pocket's top out of the plane
/// of the block's top face, a pad's foot into the block. (This holds without the check too:
/// it keeps the check from refusing what is harmless.)
#[test]
fn faces_with_room_are_pushed_apart() {
let block = block(200.0, 100.0, 20.0);
// A pocket 50 x 60 x 5 from the top face: 15 000 less.
let pocket = box_solid(Vec3::new(75.0, 20.0, 15.0), Vec3::new(125.0, 80.0, 20.0)).unwrap();
let (added, _, how) = change(&block, &pocket, BoolOp::Cut);
assert!((added + 15_000.0).abs() < 1e-6 && used(&how, BooleanStrategy::CoplanarSeparation), "{added}, {how:?}");
// A pad 50 x 60 x 10 on the top face: 30 000 more.
let pad = box_solid(Vec3::new(75.0, 20.0, 20.0), Vec3::new(125.0, 80.0, 30.0)).unwrap();
let (added, lumps, how) = change(&block, &pad, BoolOp::Union);
assert!((added - 30_000.0).abs() < 1e-6 && lumps == 1 && used(&how, BooleanStrategy::CoplanarSeparation), "{added}, {how:?}");
}
+24
View File
@@ -327,3 +327,27 @@ fn rolling_ball_past_the_end_seam_is_too_large_and_reduces() {
}
assert!(worst < 1e-3, "blend surface is {worst} mm off the analytic round at r {r}");
}
#[test]
fn a_refused_edge_is_not_rolled_again_after_blends_far_from_it() {
// The crossing cylinders' seam refuses a round of 3 (too large). A bar joined to them has
// two straight edges at its far end that round on their own. Each blend there leaves the
// seam's surroundings as they were, so the seam is tried once, not once per blend, and the
// refusal is the same.
let (r1, r2, d, h) = (17.0, 9.5, 12.0, 10.0);
let body = boolean(&cylinder(Vec3::ZERO, Vec3::Z, r1, h).unwrap(), &cylinder(Vec3::new(d, 0.0, 0.0), Vec3::Z, r2, h).unwrap(), BoolOp::Union)
.unwrap()
.unwrap();
let bar = crate::box_solid(Vec3::new(-80.0, -5.0, 0.0), Vec3::new(-10.0, 5.0, h)).unwrap();
let body = boolean(&body, &bar, BoolOp::Union).unwrap().unwrap();
let along = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
let seam = Vec3::new(along, (r1 * r1 - along * along).sqrt(), h / 2.0);
let ends = [Vec3::new(-80.0, -5.0, h / 2.0), Vec3::new(-80.0, 5.0, h / 2.0)];
crate::blend::ROLLS.with(|n| n.set(0));
let e = fillet(&body, &[seam, ends[0], ends[1]], 3.0).unwrap_err();
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
let rolls = crate::blend::ROLLS.with(|n| n.get());
assert_eq!(rolls, 1, "the seam was rolled {rolls} times");
// The far edges alone still round.
assert!(fillet(&body, &ends, 3.0).is_ok());
}
+32 -16
View File
@@ -120,10 +120,12 @@ fn merge_coplanar(shell: &mt::Shell, tol: f64, keep: &[Vec<Vec3>]) -> Option<(mt
&& na.dot(*nb) > 1.0 - 1e-9
&& (da - db).abs() < tol
{
// The lesser root wins, so each group's root is its first face whatever order the
// edges (in hash order of their addresses) are met in (#707).
let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
if ra != rb {
if let Some(x) = parent.get_mut(ra) {
*x = rb;
if let Some(x) = parent.get_mut(ra.max(rb)) {
*x = ra.min(rb);
}
merged_any = true;
}
@@ -232,11 +234,13 @@ fn merge_collinear(shell: &mt::Shell, trace: &mut Trace) -> Option<mt::Shell> {
let (pa, pm, pb) = (from_p3(other(a).point()), from_p3(mid.point()), from_p3(other(b).point()));
let (Some(u), Some(w)) = ((pm - pa).normalized(), (pb - pm).normalized()) else { continue };
if u.dot(w) > 1.0 - 1e-10 {
// The lesser root wins: each run's root is its first edge whatever order the
// vertices are met in (#707).
let (ra, rb) = (find(&mut parent, ia), find(&mut parent, ib));
if ra != rb
&& let Some(x) = parent.get_mut(ra)
&& let Some(x) = parent.get_mut(ra.max(rb))
{
*x = rb;
*x = ra.min(rb);
any = true;
}
}
@@ -244,7 +248,9 @@ fn merge_collinear(shell: &mt::Shell, trace: &mut Trace) -> Option<mt::Shell> {
if !any {
return None;
}
let mut runs: HashMap<usize, Vec<usize>> = HashMap::new();
// Ordered by run (and each run's ends by first use below): which end a joined edge starts
// from must not follow hash order, or the same heal gives a different body from run to run.
let mut runs: std::collections::BTreeMap<usize, Vec<usize>> = std::collections::BTreeMap::new();
for i in 0..edges.len() {
let r = find(&mut parent, i);
runs.entry(r).or_default().push(i);
@@ -253,14 +259,17 @@ fn merge_collinear(shell: &mt::Shell, trace: &mut Trace) -> Option<mt::Shell> {
let mut joined: Vec<(mt::Edge, HashMap<mt::VertexID, usize>)> = Vec::new();
let mut run_of: HashMap<mt::EdgeID, usize> = HashMap::new();
for members in runs.values().filter(|m| m.len() > 1) {
let mut ends: HashMap<mt::VertexID, (usize, mt::Vertex)> = HashMap::new();
let mut ends: Vec<(mt::VertexID, usize, mt::Vertex)> = Vec::new();
for i in members {
let Some(e) = edges.get(*i) else { continue };
for v in [e.front(), e.back()] {
ends.entry(v.id()).or_insert((0, v.clone())).0 += 1;
match ends.iter_mut().find(|(id, ..)| *id == v.id()) {
Some(end) => end.1 += 1,
None => ends.push((v.id(), 1, v.clone())),
}
}
}
let mut ends = ends.into_values().filter(|(n, _)| *n == 1).map(|(_, v)| v);
let mut ends = ends.into_iter().filter(|(_, n, _)| *n == 1).map(|(.., v)| v);
let (Some(first), Some(last), None) = (ends.next(), ends.next(), ends.next()) else { continue };
// Walk the run from one end, collecting its vertices in order.
let mut order = vec![first.id()];
@@ -476,7 +485,7 @@ fn planar_surfaces(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
/// Heal a solid; returns the input unchanged when nothing applies or healing fails.
#[cfg(test)]
pub fn heal(solid: Solid, size: f64) -> Solid {
healed(solid, size, &[], false).0
healed(solid, size, &[], false, false).0
}
/// What healing made each face and edge of its result from.
@@ -535,14 +544,21 @@ impl HealMap {
/// along lines they were split along on purpose (`keep`), with what each face and edge of the
/// result was made from (`None` when that could not be followed).
pub(crate) fn heal_traced(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> (Solid, Option<HealMap>) {
healed(solid, size, keep, true)
healed(solid, size, keep, true, false)
}
/// [`heal_traced`] that only merges coplanar neighbouring faces, leaving every edge, curve and
/// surface as it is: for results whose geometry is already exact (a prism boolean's), where
/// joining edges would change more than the faces split in pieces.
pub(crate) fn merge_faces_traced(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> (Solid, Option<HealMap>) {
healed(solid, size, keep, true, true)
}
/// The healed solid, and what it was made from when `traced`.
fn healed(solid: Solid, size: f64, keep: &[Vec<Vec3>], traced: bool) -> (Solid, Option<HealMap>) {
fn healed(solid: Solid, size: f64, keep: &[Vec<Vec3>], traced: bool, faces_only: bool) -> (Solid, Option<HealMap>) {
// Edges a boolean made are intersection curves, which panic where they no longer
// evaluate; the solid then stays as it is.
match crate::guard("heal", || Ok(heal_shells(&solid, size, keep, traced))) {
match crate::guard("heal", || Ok(heal_shells(&solid, size, keep, traced, faces_only))) {
Ok(Some((healed, map))) => (healed, map),
_ => {
let map = if traced { crate::guard("heal map", || Ok(HealMap::identity(&solid))).ok() } else { None };
@@ -588,7 +604,7 @@ impl Trace {
}
}
fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool) -> Option<(Solid, Option<HealMap>)> {
fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool, faces_only: bool) -> Option<(Solid, Option<HealMap>)> {
let tol = (size * 1e-7).max(1e-9);
let mut shells = Vec::new();
let mut changed = false;
@@ -603,11 +619,11 @@ fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool) -> Op
let mut from: Vec<Vec<usize>> = (first..first + count).map(|i| vec![i]).collect();
first += count;
// Faces come out in place, one for one, except where coplanar ones merge.
if let Some(m) = planar_surfaces(&cur, (size * 1e-7).max(1e-9)) {
if !faces_only && let Some(m) = planar_surfaces(&cur, (size * 1e-7).max(1e-9)) {
cur = m;
changed = true;
}
if let Some(m) = exact_edges(&cur, (size * 1e-5).max(1e-7), &mut trace) {
if !faces_only && let Some(m) = exact_edges(&cur, (size * 1e-5).max(1e-7), &mut trace) {
cur = m;
changed = true;
}
@@ -616,7 +632,7 @@ fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool) -> Op
from = groups.iter().map(|g| g.iter().filter_map(|i| from.get(*i)).flatten().copied().collect()).collect();
changed = true;
}
if let Some(m) = merge_collinear(&cur, &mut trace) {
if !faces_only && let Some(m) = merge_collinear(&cur, &mut trace) {
cur = m;
changed = true;
}
+70
View File
@@ -0,0 +1,70 @@
//! Partial planar contact must sew a solid without displacing or inventing material.
use crate::*;
use solidcraft_geom::Vec3;
use std::f64::consts::PI;
#[test]
fn split_contact_arc_crossing_one_edge_twice_preserves_the_body() {
use truck_modeling::Surface;
let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap();
let b = cylinder(Vec3::new(0.0, 7.5, 0.0), -Vec3::X, 5.0, 2.5).unwrap();
let face = a.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_some_and(|n| n.dot(-Vec3::X) > 0.99)).unwrap();
let side = b.solid.face_iter().position(|f| !matches!(f.surface(), Surface::Plane(_))).unwrap();
let r = split_faces(&a, &[face], &SplitTool::Face { body: &b, face: side }).unwrap();
assert_eq!(r.face_count(), a.face_count() + 1);
assert!(r.validity().is_empty(), "{:?}", r.validity());
assert!(r.history().is_some());
let (before, after) = (measure(&a).unwrap(), measure(&r).unwrap());
assert!((before.volume - after.volume).abs() < 1e-7);
assert!((before.area - after.area).abs() < 1e-7);
}
#[test]
fn partial_contact_does_not_join_tangent_or_separated_faces() {
let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap();
for z in [-5.0, -6.0] {
let b = cylinder(Vec3::new(0.0, 7.5, z), -Vec3::X, 5.0, 2.5).unwrap();
assert!(crate::coplanar::imprint_union(&a, &b, &|_| true).is_none());
}
}
#[test]
fn partial_contact_retains_an_authored_split_away_from_the_contact() {
let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap();
let face = a.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_some_and(|n| n.dot(Vec3::X) > 0.99)).unwrap();
let a = split_faces(&a, &[face], &SplitTool::Plane(solidcraft_geom::Plane { origin: Vec3::new(0.0, 0.0, 15.0), ..solidcraft_geom::Plane::XY }))
.unwrap();
let b = cylinder(Vec3::new(0.0, 7.5, 0.0), -Vec3::X, 5.0, 2.5).unwrap();
let r = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
assert_eq!(r.split_lines(), a.split_lines());
assert!(r.regularization_protected);
assert!(r.validity().is_empty());
let remote_faces =
r.faces(0.01).unwrap().iter().filter(|f| f.plane_normal.is_some_and(|n| n.dot(Vec3::X) > 0.99) && (f.centroid.x - 25.0).abs() < 1e-7).count();
assert_eq!(remote_faces, 2);
}
#[test]
fn partial_circular_contact_preserves_material_and_history() {
for z in [-3.0_f64, 0.0, 3.0] {
for angle in [0.0, 0.37] {
let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap();
let b = cylinder(Vec3::new(0.0, 7.5, z), -Vec3::X, 5.0, 2.5).unwrap();
let place = |body: &Body| transform(body, Vec3::new(4.0, -8.0, 2.0), Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0), angle).unwrap();
let (a, b) = (place(&a), place(&b));
for reversed in [false, true] {
let (a, b) = if reversed { (&b, &a) } else { (&a, &b) };
let r = boolean(a, b, BoolOp::Union).unwrap().unwrap();
assert!(r.validity().is_empty(), "z={z} angle={angle}: {:?}", r.validity());
assert_eq!(r.shell_count(), 1);
assert!(r.history().is_some(), "missing history z={z} angle={angle}");
let m = measure(&r).unwrap();
let volume = 25.0 * 47.5 * 37.5 + PI * 25.0 * 2.5;
let contact = 25.0 * (-z / 5.0).acos() + z * (25.0 - z * z).sqrt();
let area = 2.0 * (25.0 * 47.5 + 25.0 * 37.5 + 47.5 * 37.5) + 2.0 * PI * 5.0 * (5.0 + 2.5) - 2.0 * contact;
assert!((m.volume - volume).abs() < 0.1, "z={z} angle={angle}: volume {} expected {volume}", m.volume);
assert!((m.area - area).abs() < 0.2, "z={z} angle={angle}: area {} expected {area}", m.area);
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Bodies touching back to back over part of a face fuse into one solid: two L's of a 2 × 2
//! grid extruded up and down (#679), a pad half past the edge of the face it stands on (#674),
//! several contact regions, contact beside a shared edge, and random rectangles.
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use crate::*;
fn prism(pts: &[[f64; 2]], z0: f64, z1: f64) -> Body {
let outer = Loop2::polygon(&pts.iter().map(|p| Vec2::new(p[0], p[1])).collect::<Vec<_>>());
extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], z0, z1).unwrap().pop().unwrap()
}
fn rect(x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64) -> Body {
box_solid(Vec3::new(x0, y0, z0), Vec3::new(x1, y1, z1)).unwrap()
}
fn fused(a: &Body, b: &Body) -> (Body, BooleanDiagnostics) {
let (r, d) = capture_boolean_diagnostics(|| boolean(a, b, BoolOp::Union));
(r.unwrap().unwrap(), d)
}
fn assert_one_solid(r: &Body, volume: f64, tol: f64, what: &str) {
let m = measure(r).unwrap();
assert!((m.volume - volume).abs() <= tol, "{what}: volume {} vs {volume}", m.volume);
assert_eq!(r.shell_count(), 1, "{what}: one solid");
assert!(r.validity().is_empty(), "{what}: {:?}", r.validity());
}
/// #679: XO/XX extruded up 10, XX/OX down 10, 10 mm cells. They meet back to back over two
/// whole cells, along an edge and at the centre point: 6 cells × 10 mm × 100 mm².
#[test]
fn two_ls_up_and_down_fuse_into_one_solid() {
let up = prism(&[[-10., -10.], [10., -10.], [10., 0.], [0., 0.], [0., 10.], [-10., 10.]], 0., 10.);
let down = prism(&[[-10., 10.], [-10., 0.], [0., 0.], [0., -10.], [10., -10.], [10., 10.]], -10., 0.);
for (a, b) in [(&up, &down), (&down, &up)] {
let (r, d) = fused(a, b);
assert_one_solid(&r, 6000.0, 1e-6, "#679");
assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{d:?}");
// Two tops, two bottoms, the two cells left open at z = 0, six sides.
assert_eq!(r.face_count(), 12);
}
}
/// Two blocks up from z = 0 and one plate down under both: two separate contacts fuse into one
/// solid. A plate under a block, flush with its sides along shared edges.
#[test]
fn several_contact_regions_and_edge_contact() {
let up = rect(-10., -10., -2., -2., 0., 10.);
let up2 = rect(2., 2., 10., 10., 0., 10.);
let ups = boolean(&up, &up2, BoolOp::Union).unwrap().unwrap();
// Down under both up cells, and under one more.
let down = prism(&[[-10., -10.], [0., -10.], [0., 0.], [10., 0.], [10., 10.], [-10., 10.]], -5., 0.);
let (r, _) = fused(&ups, &down);
assert_one_solid(&r, 2.0 * 640.0 + 3.0 * 500.0, 1e-6, "two contacts");
// Down under one up cell and beside the other: one face contact and one edge contact.
// A plate under one block, flush with two of its sides and running on past the third.
let down = rect(-10., -10., -2., 5., -5., 0.);
let (r, _) = fused(&up, &down);
assert_one_solid(&r, 640.0 + 8.0 * 15.0 * 5.0, 1e-6, "contact and flush sides");
}
/// #674 (robustness seed 16): a short cylinder whose end lies on a block's side with half of it
/// past the block's bottom edge. The end is split along the edge, the block's side along the
/// circle, and the half disc they share goes.
#[test]
fn a_pad_half_past_the_edge_of_its_face_fuses() {
let block = rect(0., 0., 25., 47.5, 0., 37.5);
let pad = cylinder(Vec3::new(7.5, 0., 0.), -Vec3::Y, 5.0, 2.5).unwrap();
// Measured as the result is (the round side's volume from its mesh).
let want = measure(&block).unwrap().volume + measure(&pad).unwrap().volume;
assert!((want - (25.0 * 47.5 * 37.5 + std::f64::consts::PI * 25.0 * 2.5)).abs() < 0.1);
for (a, b) in [(&block, &pad), (&pad, &block)] {
let (r, d) = fused(a, b);
assert_one_solid(&r, want, 1e-6 * want, "#674");
assert!(d.events.is_empty(), "an exact join: {d:?}");
let bounds = r.tessellate(0.01).unwrap().bounds();
assert!((bounds.min.y + 2.5).abs() < 1e-9 && (bounds.min.z + 5.0).abs() < 0.01, "{bounds:?}");
}
}
/// Random rectangles extruded up from z = 0 and down from it, overlapping anywhere (inside one
/// another, crossing, sharing a side): one solid of exactly the two prisms' volume.
#[test]
fn random_rectangles_either_side_of_a_plane_fuse_exactly() {
let mut seed = 0x679_u64;
let mut next = |lo: f64, hi: f64| {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
lo + (hi - lo) * ((seed >> 11) as f64 / (1u64 << 53) as f64)
};
let q = |x: f64| (x * 2.0).round() / 2.0;
let mut done = 0;
for case in 0..30 {
let (x0, y0) = (q(next(0., 20.)), q(next(0., 20.)));
let (w0, h0) = (q(next(2., 20.)), q(next(2., 20.)));
// The second overlaps the first over an area.
let (x1, y1) = (q(next(x0 - 15., x0 + w0 - 1.)), q(next(y0 - 15., y0 + h0 - 1.)));
let (w1, h1) = (q(next(1., 25.)).max(x0 + 1. - x1).max(1.), q(next(1., 25.)).max(y0 + 1. - y1).max(1.));
let (t0, t1) = (q(next(1., 10.)), q(next(1., 10.)));
let up = rect(x0, y0, x0 + w0, y0 + h0, 0., t0);
let down = rect(x1, y1, x1 + w1, y1 + h1, -t1, 0.);
let want = w0 * h0 * t0 + w1 * h1 * t1;
let what = format!("case {case}: up {x0},{y0} {w0}x{h0}x{t0}, down {x1},{y1} {w1}x{h1}x{t1}");
let (r, d) = fused(&up, &down);
assert_one_solid(&r, want, 1e-6 * want, &what);
assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{what}: {d:?}");
done += 1;
}
assert_eq!(done, 30);
}
+12 -3
View File
@@ -21,6 +21,7 @@ mod boolean_guard;
mod boolean_record;
mod bore_guard;
mod build;
mod clearance;
mod coplanar;
mod curveblend;
mod delete_face;
@@ -39,6 +40,10 @@ mod iges_out;
mod intersection_curve_tests;
#[cfg(test)]
mod issue_616_tests;
#[cfg(test)]
mod issue_674_tests;
#[cfg(test)]
mod issue_679_tests;
mod lip;
mod loopblend;
mod measure;
@@ -50,6 +55,7 @@ mod native;
mod numbered;
mod offset;
mod ops;
mod par;
mod plane_fit;
mod polybool;
mod polyhedron;
@@ -68,14 +74,17 @@ mod step_out;
mod surface_kind;
mod surfaces;
mod sweep_path;
mod tess;
#[cfg(test)]
mod thread_tests;
mod topo;
mod tspline;
#[cfg(test)]
mod untouched_faces_tests;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_max, fillet_max_each};
pub use blend_corner::fillet_radii;
pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points};
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper};
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
pub use build::{PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
@@ -93,7 +102,7 @@ pub use offset::with_same_surface;
pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix, union_apart};
pub use polybool::{faceted_boolean, planar_boolean};
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use prism::{extrude_planar_regions, planar_regions_boolean};
pub use prism::{PLANAR_FIT_TOL, extrude_planar_regions, planar_regions_boolean};
pub use prism_measure::certified_prism_equivalent;
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map};
@@ -101,7 +110,7 @@ pub use step::step_export;
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_orientation_errors, step_validate};
pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products};
pub use surface_kind::{FaceSurface, face_surface};
pub use surfaces::{copy_faces, extend, patch_edges, patch_region, stitch, thicken, trim};
pub use surfaces::{copy_faces, extend, extrude_sheet, patch_edges, patch_region, stitch, thicken, trim};
pub use sweep_path::sweep_path;
pub use topo::{CylinderFace, cylinder_face_at};
pub use topo::{TopoCounts, merged_topology, seam_flags};
+30 -3
View File
@@ -18,6 +18,14 @@ fn invalid(message: &str) -> KernelError {
KernelError::Invalid(format!("mesh conversion: {message}"))
}
/// A mesh over the triangle budget: how big it is, the limit, and that only constant-section
/// parts convert, so a smaller freeform mesh would still be refused (#346).
fn over_budget(triangles: usize) -> KernelError {
invalid(&format!(
"the mesh has {triangles} triangles, more than the {MAX_TRIANGLES} conversion can take; only closed constant-section parts (one profile extruded straight) can be converted, so a freeform mesh is refused at any size"
))
}
/// Reconstruct a constant-section mesh as analytic planes and, where certified
/// within `tolerance` mm, circular cylinders. The source mesh is not modified.
/// Unsupported shapes are refused instead of silently returning a faceted solid.
@@ -30,7 +38,7 @@ pub fn convert_prismatic_mesh(body: &Body, tolerance: f64) -> Result<Body> {
let welded = weld(input, tolerance)?;
let mesh = welded.mesh.as_ref().ok_or_else(|| invalid("empty mesh"))?;
if mesh.triangles.len() > MAX_TRIANGLES {
return Err(invalid("mesh exceeds the 50000-triangle conversion budget"));
return Err(over_budget(mesh.triangles.len()));
}
let mut edges: BTreeMap<(u32, u32), (usize, i32)> = BTreeMap::new();
let mut axes: BTreeMap<(i64, i64, i64), (Vec3, f64)> = BTreeMap::new();
@@ -69,8 +77,11 @@ pub fn convert_prismatic_mesh(body: &Body, tolerance: f64) -> Result<Body> {
}
fn weld(mesh: &crate::meshbody::TriMesh, tolerance: f64) -> Result<Body> {
if mesh.triangles.len() > MAX_TRIANGLES || mesh.positions.len() > MAX_TRIANGLES * 3 {
return Err(invalid("mesh exceeds the conversion budget"));
if mesh.triangles.len() > MAX_TRIANGLES {
return Err(over_budget(mesh.triangles.len()));
}
if mesh.positions.len() > MAX_TRIANGLES * 3 {
return Err(invalid(&format!("the mesh has {} vertices, more than the {} conversion can take", mesh.positions.len(), MAX_TRIANGLES * 3)));
}
let bounds = mesh.positions.iter().fold(solidcraft_geom::Aabb3::EMPTY, |mut b, p| {
b.add(*p);
@@ -464,6 +475,22 @@ mod tests {
}
}
#[test]
fn a_mesh_over_the_budget_says_how_big_it_is_and_what_converts() {
// #346: a freeform mesh just over the limit (a strip of 50001 triangles) is refused with
// its size, the limit, and that only constant-section parts convert at any size.
let n = MAX_TRIANGLES + 1;
let positions: Vec<Vec3> = (0..n + 2).map(|k| Vec3::new((k / 2) as f64, (k % 2) as f64, 0.)).collect();
let triangles: Vec<[u32; 3]> = (0..n as u32).map(|i| [i, i + 1, i + 2]).collect();
let body = crate::mesh_body(&positions, &triangles).unwrap();
let err = convert_prismatic_mesh(&body, 0.05).unwrap_err().to_string();
assert!(err.contains("50001 triangles") && err.contains("50000"), "{err}");
assert!(err.contains("constant-section") && err.contains("freeform"), "{err}");
let mesh = crate::meshbody::TriMesh { positions: vec![Vec3::ZERO; MAX_TRIANGLES * 3 + 1], triangles: vec![] };
let err = weld(&mesh, 0.05).unwrap_err().to_string();
assert!(err.contains("150001 vertices") && err.contains("150000"), "{err}");
}
#[test]
fn fitting_cannot_open_a_hole_through_a_nearby_wall() {
let outer = Loop2::polygon(&[Vec2::new(-1., -1.), Vec2::new(1., -1.), Vec2::new(1., 1.), Vec2::new(-1., 1.)]);
+4
View File
@@ -103,6 +103,8 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
}
Ok(Body {
solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())),
size: 0.0,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: None,
mesh: Some(Arc::new(out)),
color: None,
@@ -197,6 +199,8 @@ impl Body {
}
Some(Body {
solid: self.solid.clone(),
size: 0.0,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: None,
mesh: Some(Arc::new(out)),
color: self.color,
+2
View File
@@ -84,6 +84,8 @@ pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
let solid = adapter::decode(saved.solid)?;
let mut body = Body {
solid: Arc::new(solid),
size: 0.0,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: None,
mesh: None,
color: None,
+139 -33
View File
@@ -35,7 +35,9 @@ pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool {
const JITTER: [[f64; 3]; 4] = [[0.0, 0.0, 0.0], [0.1373, 0.2719, 0.3911], [-0.5127, 0.0833, 0.2291], [0.6911, -0.3337, -0.6127]];
fn volume(b: &Body) -> f64 {
b.tessellate(b.size() * 2e-3).map(|m| m.measure().volume).unwrap_or(f64::NAN)
// Measured once per (solid, tolerance) and shared by clones, so an operand a later
// boolean takes again is not meshed twice for the same number.
b.volume_at(b.size() * 2e-3).unwrap_or(f64::NAN)
}
/// Halton low-discrepancy value (base `b`) for index `i`.
@@ -393,6 +395,43 @@ fn meeting_size(a: &Body, b: &Body) -> f64 {
if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
}
/// Whether a result volume is plausible for `op`: within the bounds the operands' volumes
/// leave, plus the slack the meshed volumes are measured to.
fn plausible(v: f64, op: BoolOp, va: f64, vb: f64, slack: f64) -> bool {
v > 0.0
&& match op {
BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack,
BoolOp::Cut => v >= va - vb - slack && v <= va + slack,
BoolOp::Intersect => v <= va.min(vb) + slack,
}
}
/// The prism route's result: `Some` when both operands are prisms along one direction (see
/// [`crate::prism::prism_boolean`]), with a union's slab seams healed as the general B-rep
/// boolean does.
fn prism_result(a: &Body, b: &Body, op: BoolOp, size: f64, counts: &[(usize, usize)]) -> Option<Result<Option<Body>>> {
// Slab-by-slab prism booleans sew a closed solid, but leave cap and wall pieces along slab
// boundaries: a cut along Z through a box whose side wall already has a port in it splits
// that wall into strips at the port's top and bottom, though the cut never touches it. Merge
// those coplanar faces for every operation, just as the general B-rep boolean does, retaining
// intentional Split Face lines. Authored boundaries (painted faces, regularization
// protection) keep their pieces.
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
let authored = |x: &Body| x.regularization_protected || x.paint().is_some_and(|p| !p.faces.is_empty());
crate::prism::prism_boolean(a, b, op).map(|result| {
result.map(|body| {
body.map(|body| {
if op == BoolOp::Union {
guard("heal prism boolean", || recorded::heal_body(&body, (*body.solid).clone(), size, &keep, counts)).unwrap_or(body)
} else if authored(a) || authored(b) {
body
} else {
guard("heal prism boolean", || recorded::heal_prism_result(&body, size, &keep, counts)).unwrap_or(body)
}
})
})
})
}
fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
b.require_brep("a boolean")?;
@@ -405,6 +444,33 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
// floor) is otherwise spanned by triangles cutting through it, which reads as missing
// material.
let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5);
// A prism pair (a plate and the tool through it) is decided exactly in 2D, with no mesh of
// either operand. The sampled shortcuts below need a refined mesh of both, and that mesh is
// most of what a boolean costs: a pattern or a multi-profile sketch rebuilds the whole
// target's mesh once per tool. Only a cut or an intersection meets its tool often enough to
// be worth asking first - parts that stay apart are settled far more cheaply by the shell
// join the sampled shortcut does. A pair the route cannot answer still takes the path below,
// unchanged.
let early_prism = matches!(op, BoolOp::Cut | BoolOp::Intersect);
let prism = early_prism.then(|| prism_result(a, b, op, size, &counts)).flatten();
if let Some(Ok(Some(body))) = &prism {
let (va0, vb0) = (volume(a), volume(b));
let (v0, slack0) = (volume(body), 2e-3 * (va0 + vb0) + 1e-9);
// A tool that misses its target, or covers it whole, leaves it as it is, and the
// sampled shortcut below answers that by reusing the operand itself: the very same body.
// The 2D route rebuilds it instead - the same shape on new faces - which would turn an
// untouched body in a timeline into a different solid. Those pairs go back to the path
// below, which decides them, and the identity they keep, exactly as it always has.
let unchanged = 1e-4 * (va0 + vb0);
let rebuilt_from_an_operand = match op {
BoolOp::Cut => v0 >= va0 - unchanged,
BoolOp::Intersect => (v0 - va0).abs() <= unchanged || (v0 - vb0).abs() <= unchanged,
BoolOp::Union => false,
};
if !rebuilt_from_an_operand && plausible(v0, op, va0, vb0, slack0) {
return Ok(Some(body.clone()));
}
}
let meshes = (a.tessellate_with(tol_m, true).ok(), b.tessellate_with(tol_m, true).ok());
let (va, vb) = match &meshes {
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
@@ -429,12 +495,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
let check = |r: &Body| -> (bool, f64) {
let Ok(mr) = r.tessellate_with(tol_m, true) else { return (false, f64::NAN) };
let v = mr.measure().volume;
let bounds = v > 0.0
&& match op {
BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack,
BoolOp::Cut => v >= va - vb - slack && v <= va + slack,
BoolOp::Intersect => v <= va.min(vb) + slack,
};
let bounds = plausible(v, op, va, vb, slack);
if !bounds {
return (false, v);
}
@@ -461,36 +522,26 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
}
let mut last = String::new();
// Coincident faces make the intersection fail after many retries; push them apart first.
// Prisms along one direction (plates with holes): exact, in 2D (so only the volume
// bounds are checked).
if let Some(Ok(r)) = crate::prism::prism_boolean(a, b, op) {
// Slab-by-slab prism joins sew a closed solid, but leave cap and wall
// pieces along slab boundaries. Merge those coplanar faces just as the
// general B-rep boolean does, retaining intentional Split Face lines.
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
let r = if op == BoolOp::Union {
r.map(|body| guard("heal prism boolean", || recorded::heal_body(&body, (*body.solid).clone(), size, &keep, &counts)).unwrap_or(body))
} else {
r
};
match &r {
Some(body) => {
// Prisms along one direction (plates with holes): exact, in 2D (so only the volume bounds
// are checked). For a cut or an intersection this already ran above; the other operations
// ask here, after the sampled shortcut, which is where that shortcut is cheapest.
let prism = prism.or_else(|| (!early_prism).then(|| prism_result(a, b, op, size, &counts)).flatten());
if let Some(result) = prism {
match result {
Ok(Some(body)) => {
// Measured as the operands were: a coarser mesh of a plate with many holes
// overstates its volume by more than the slack.
let v = match &meshes {
(Some(_), Some(_)) => body.tessellate_with(tol_m, true).map(|m| m.measure().volume).unwrap_or(f64::NAN),
_ => volume(body),
_ => volume(&body),
};
let bounds = match op {
BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack,
BoolOp::Cut => v >= va - vb - slack && v <= va + slack,
BoolOp::Intersect => v <= va.min(vb) + slack,
};
if v > 0.0 && bounds {
return Ok(r);
if plausible(v, op, va, vb, slack) {
return Ok(Some(body));
}
}
None => return Ok(None),
Ok(None) => return Ok(None),
// A failed prism attempt keeps trying the other strategies, as it did before.
Err(_) => {}
}
}
// Bodies touching along one whole face (a part joined with its mirror image).
@@ -507,6 +558,8 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
// Coincident planar faces: push them apart (exactly). Tried once, before the general
// boolean; why it failed is reported if everything else fails too.
let mut apart_failed = String::new();
// The exact polygon boolean's answer, when it was asked for early.
let mut planar = None;
if !APART.with(|c| c.get()) {
APART.with(|c| c.set(true));
let (r, diagnostics) = capture_boolean_diagnostics(|| crate::coplanar::boolean_apart(a, b, op));
@@ -526,6 +579,22 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
Some(Err(e)) => apart_failed = format!("; {e}"),
Some(Ok(None)) | None => {}
}
// A face with no room to move would take or add what it moves through. The exact
// polygon boolean takes the bodies as they lie (bodies with only planar faces, or a
// planar tool clear of the other body's curved faces): it is asked now, before the
// general boolean has its tries at faces lying in each other.
if apart_failed.contains(crate::coplanar::NO_ROOM) {
// What its own booleans report counts only when its result is taken.
let (exact, diagnostics) = capture_boolean_diagnostics(|| crate::polybool::planar_boolean(a, b, op));
match exact {
Ok(Some(body)) if check(&body).0 => {
merge(diagnostics);
record(BooleanFallback::new(BooleanStrategy::PlanarFallback));
return Ok(Some(body));
}
other => planar = Some(other),
}
}
}
// A face standing wholly inside a coincident face of the other body, back to back, and no
// other contact (#396): exact, by topology alone.
@@ -549,6 +618,14 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
return Ok(Some(body));
}
}
// Faces back to back over part of their area: imprinted on each other and glued (exact; held
// to the retry guard's checks all the same).
if op == BoolOp::Union && crate::coplanar::faces_touch(a, b) {
let accept = |body: &Body| check(body).0 && !unfused(body) && crate::boolean_guard::check_retry(a, b, op, Some(body)).is_ok();
if let Some(body) = crate::coplanar::imprint_union(a, b, &accept) {
return Ok(Some(body));
}
}
let mut empty_votes = 0;
let mut empty_diagnostics = crate::BooleanDiagnostics::default();
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
@@ -604,7 +681,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
last.push_str(&apart_failed);
// Coincident faces defeat the B-rep boolean; bodies with only planar faces have an exact
// polygon fallback.
match crate::polybool::planar_boolean(a, b, op) {
match planar.unwrap_or_else(|| crate::polybool::planar_boolean(a, b, op)) {
Ok(Some(body)) => {
let (ok, v) = check(&body);
if ok {
@@ -841,7 +918,20 @@ pub(crate) fn join_pieces(pieces: Vec<Body>) -> Result<Option<Body>> {
}
}
/// Affine transform by a column-major 4×4 matrix (rotations, translations, reflections).
/// Determinant of the linear part of a column-major 4x4 matrix: negative means the placement
/// mirrors, which turns a solid inside out.
fn det3(m: &[[f64; 4]; 4]) -> f64 {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
}
/// Whether the linear part is a rigid motion (rotation or reflection: no scale or shear).
fn rigid_placement(m: &[[f64; 4]; 4]) -> bool {
let r = [Vec3::new(m[0][0], m[0][1], m[0][2]), Vec3::new(m[1][0], m[1][1], m[1][2]), Vec3::new(m[2][0], m[2][1], m[2][2])];
(0..3).all(|i| (r[i].dot(r[i]) - 1.0).abs() < 1e-9) && r[0].dot(r[1]).abs() < 1e-9 && r[0].dot(r[2]).abs() < 1e-9 && r[1].dot(r[2]).abs() < 1e-9
}
/// Affine transform by a column-major 4x4 matrix (rotations, translations, reflections).
pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
if m.iter().flatten().any(|x| !x.is_finite()) {
return Err(KernelError::Invalid("non-finite transform".into()));
@@ -865,7 +955,19 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
for (copy, face) in solid.face_iter().zip(body.solid.face_iter()) {
rec.whole(copy, face);
}
let placed = record::rebuilt(solid, &[&body.solid], &rec)?;
let (faces, edges) = rec.history(&solid, &[&body.solid]).unzip();
let counts = record::solid_counts(&[&body.solid]);
// A rigid placement leaves every face's mesh, and so the missing-face count, as it was;
// a mirrored one turns the solid inside out. Either way the orientation is read from the
// matrix instead of meshing the whole copy again.
let placed = record::build_placed(
solid,
&counts,
faces,
edges.as_ref(),
det3(&m) < 0.0,
rigid_placement(&m).then_some(body.coarse_missing).flatten(),
)?;
Ok(placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources).inherit_regularization_protection(body))
})
}
@@ -1017,6 +1119,10 @@ pub(crate) fn half_box(body: &Body, plane: &solidcraft_geom::Plane, positive: bo
crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))
}
#[cfg(test)]
#[path = "prism_fast_path_tests.rs"]
mod prism_fast_path_tests;
#[cfg(test)]
#[path = "placement_tests.rs"]
mod placement_tests;
+42
View File
@@ -0,0 +1,42 @@
//! Ordered parallel map over independent items, on the process-wide worker pool (desktop
//! only; the browser build runs serially). Each item runs under [`crate::guard`], so a panic in
//! one item is that item's error and never reaches the pool.
use crate::{Result, guard};
/// `f` over `items`, results in item order: on the pool when `parallel`, else on the calling
/// thread. Waking the pool's workers costs tens of microseconds, so callers ask for it only
/// when the items carry more work than that (decided from the work, not the item count).
pub(crate) fn map<T: Sync, R: Send>(what: &'static str, items: &[T], parallel: bool, f: impl Fn(&T) -> Result<R> + Sync) -> Vec<Result<R>> {
let one = |item: &T| guard(what, || f(item));
// The browser build has no worker pool: everything runs on the calling thread.
#[cfg(target_arch = "wasm32")]
let _ = parallel;
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
if parallel && items.len() > 1 && rayon::current_num_threads() > 1 {
return items.par_iter().map(one).collect();
}
}
items.iter().map(one).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::KernelError;
#[test]
fn keeps_order_and_errors() {
let items: Vec<u32> = (0..1000).collect();
let out = map("test", &items, true, |x| if *x == 500 { Err(KernelError::Invalid("five hundred".into())) } else { Ok(x * 2) });
assert_eq!(out.len(), 1000);
for (i, r) in out.iter().enumerate() {
match r {
Ok(v) => assert_eq!(*v, 2 * i as u32),
Err(_) => assert_eq!(i, 500),
}
}
}
}
+117 -20
View File
@@ -3,7 +3,7 @@
//! as a wall flush with the side of its base. The polygons are rebuilt into a B-rep: coplanar
//! pieces are merged into faces, T-junctions are split so neighbouring faces share edges.
use std::collections::{BTreeSet, HashMap};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use solidcraft_geom::{Mesh, Vec3};
use truck_modeling::{self as mt, builder};
@@ -415,7 +415,7 @@ fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option
let tol = (a.size() * 1e-3).max(1e-3);
let infos = a.faces(tol).ok()?;
let mesh = a.tessellate(tol).ok()?;
let mut curved: HashMap<u32, solidcraft_geom::Aabb3> = HashMap::new();
let mut curved: BTreeMap<u32, solidcraft_geom::Aabb3> = BTreeMap::new();
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
if infos.get(*fi as usize)?.plane_normal.is_some() {
continue;
@@ -589,13 +589,13 @@ struct Sources {
/// faces (lesser face first).
struct OperandEdges {
ends: Vec<(Vec3, Vec3)>,
between: HashMap<(usize, usize), Vec<usize>>,
between: BTreeMap<(usize, usize), Vec<usize>>,
}
impl OperandEdges {
fn of(b: &Body) -> OperandEdges {
let (edges, bounds) = edge_order(b.solid.face_iter());
let mut between: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
let mut between: BTreeMap<(usize, usize), Vec<usize>> = BTreeMap::new();
for (i, fs) in bounds.iter().enumerate() {
if let [f, g] = fs.as_slice() {
between.entry((*f.min(g), *f.max(g))).or_default().push(i);
@@ -699,6 +699,25 @@ fn csg(pa: Vec<Poly>, pb: Vec<Poly>, op: BoolOp, eps: f64) -> Result<Vec<Poly>>
Ok(polys)
}
/// `shell`'s connected components, each face in the shell's order and the components by their
/// first face. truck's own come out in the hash order of face addresses, so the same rebuild
/// gave a body with its faces in a different order from run to run (#707).
pub(crate) fn components_in_order(shell: &mt::Shell) -> Vec<mt::Shell> {
let pos: HashMap<mt::FaceID, usize> = shell.face_iter().enumerate().map(|(i, f)| (f.id(), i)).collect();
let key = |f: &mt::Face| pos.get(&f.id()).copied().unwrap_or(usize::MAX);
let mut comps: Vec<Vec<mt::Face>> = shell
.connected_components()
.into_iter()
.map(|c| {
let mut faces: Vec<mt::Face> = c.into_iter().collect();
faces.sort_by_key(|f| key(f));
faces
})
.collect();
comps.sort_by_key(|c| c.first().map(key).unwrap_or(usize::MAX));
comps.into_iter().map(mt::Shell::from).collect()
}
/// Position key for vertex merging.
fn key(p: Vec3, q: f64) -> (i64, i64, i64) {
((p.x / q).round() as i64, (p.y / q).round() as i64, (p.z / q).round() as i64)
@@ -711,7 +730,7 @@ fn key(p: Vec3, q: f64) -> (i64, i64, i64) {
fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
let q = size * 1e-7;
// Global vertex table.
let mut index: HashMap<(i64, i64, i64), usize> = HashMap::new();
let mut index: BTreeMap<(i64, i64, i64), usize> = BTreeMap::new();
let mut verts: Vec<Vec3> = Vec::new();
// Points within `q` share a vertex, also across the rounding grid's cell boundaries.
let mut id = |p: Vec3, verts: &mut Vec<Vec3>| -> usize {
@@ -755,15 +774,15 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
let mean = edge_len.iter().sum::<f64>() / edge_len.len().max(1) as f64;
let cell = mean.max(size * 1e-4).max(1e-9);
let ckey = |p: Vec3| ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64, (p.z / cell).floor() as i64);
let mut grid: HashMap<(i64, i64, i64), Vec<usize>> = HashMap::new();
let mut grid: BTreeMap<(i64, i64, i64), Vec<usize>> = BTreeMap::new();
for (k, v) in verts.iter().enumerate() {
grid.entry(ckey(*v)).or_default().push(k);
}
let mut directed: Vec<(usize, usize, usize)> = Vec::new(); // (plane group, from, to)
// The polygons (by loop) each directed edge comes from.
let mut owners: HashMap<(usize, usize, usize), Vec<usize>> = HashMap::new();
let mut owners: BTreeMap<(usize, usize, usize), Vec<usize>> = BTreeMap::new();
let mut groups: Vec<PlaneEq> = Vec::new();
let mut group_of: HashMap<(i64, i64, i64, i64), usize> = HashMap::new();
let mut group_of: BTreeMap<(i64, i64, i64, i64), usize> = BTreeMap::new();
for (li, (pl, l)) in loops.iter().enumerate() {
let gk = ((pl.n.x * 1e8).round() as i64, (pl.n.y * 1e8).round() as i64, (pl.n.z * 1e8).round() as i64, (pl.w / (tol * 10.0)).round() as i64);
let g = *group_of.entry(gk).or_insert_with(|| {
@@ -814,7 +833,7 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
}
}
// Interior edges cancel against their reverse within the same plane group.
let mut count: HashMap<(usize, usize, usize), i64> = HashMap::new();
let mut count: BTreeMap<(usize, usize, usize), i64> = BTreeMap::new();
for (g, a, b) in &directed {
*count.entry((*g, *a, *b)).or_insert(0) += 1;
}
@@ -838,12 +857,12 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
}
}
let roots: Vec<usize> = (0..loops.len()).map(|li| find(&mut parent, li)).collect();
let mut joined: HashMap<usize, BTreeSet<FaceOrigin>> = HashMap::new();
let mut joined: BTreeMap<usize, BTreeSet<FaceOrigin>> = BTreeMap::new();
for (root, src) in roots.iter().zip(&srcs) {
joined.entry(*root).or_default().insert(*src);
}
// What lies on each side of each boundary edge (from, to): the faces of its polygons.
let mut side: HashMap<(usize, usize), BTreeSet<FaceOrigin>> = HashMap::new();
let mut side: BTreeMap<(usize, usize), BTreeSet<FaceOrigin>> = BTreeMap::new();
for (g, a, b) in &boundary {
let set = side.entry((*a, *b)).or_default();
for li in owners.get(&(*g, *a, *b)).into_iter().flatten() {
@@ -857,13 +876,11 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
joined.get(roots.get(li)?).cloned()
};
guard("planar boolean rebuild", || {
let tv: Vec<mt::Vertex> = verts.iter().map(|v| builder::vertex(p3(*v))).collect();
let mut edges: HashMap<(usize, usize), mt::Edge> = HashMap::new();
let mut faces: Vec<mt::Face> = Vec::new();
let mut face_from: HashMap<mt::FaceID, Option<BTreeSet<FaceOrigin>>> = HashMap::new();
for (g, plane) in groups.iter().enumerate() {
let mut verts = verts.clone();
// Every plane group's boundary loops (vertex indices).
let mut all_loops: Vec<Vec<Vec<usize>>> = Vec::new();
for (g, _) in groups.iter().enumerate() {
let mut segs: Vec<(usize, usize)> = boundary.iter().filter(|e| e.0 == g).map(|e| (e.1, e.2)).collect();
let mut wires: Vec<mt::Wire> = Vec::new();
let mut loops_g: Vec<Vec<usize>> = Vec::new();
while let Some((s, t)) = segs.pop() {
let mut lp = vec![s, t];
@@ -895,7 +912,19 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
}
}
}
for lp in &loops_g {
all_loops.push(loops_g);
}
// The original vertex of each vertex index (pinched vertices get copies, below).
let mut orig: Vec<usize> = (0..verts.len()).collect();
split_pinched_vertices(&mut all_loops, &mut verts, &mut orig);
let tv: Vec<mt::Vertex> = verts.iter().map(|v| builder::vertex(p3(*v))).collect();
let mut edges: BTreeMap<(usize, usize), mt::Edge> = BTreeMap::new();
let mut faces: Vec<mt::Face> = Vec::new();
let mut face_from: HashMap<mt::FaceID, Option<BTreeSet<FaceOrigin>>> = HashMap::new();
for (g, plane) in groups.iter().enumerate() {
let Some(loops_g) = all_loops.get(g) else { continue };
let mut wires: Vec<mt::Wire> = Vec::new();
for lp in loops_g {
let n = lp.len();
let mut es = Vec::with_capacity(n);
for i in 0..n {
@@ -964,7 +993,7 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
ws.push(wires.get(*h).cloned().ok_or_else(|| KernelError::Failed("wire".into()))?);
}
}
let from = loop_faces(g, ol);
let from = loop_faces(g, &ol.iter().map(|k| orig.get(*k).copied().unwrap_or(*k)).collect::<Vec<_>>());
// A loop a hair off flat (fine facets of a curved face) takes the group's plane.
let face = match builder::try_attach_plane(&ws) {
Ok(f) => f,
@@ -980,7 +1009,7 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
}
}
let shell: mt::Shell = faces.into();
let parts = shell.connected_components();
let parts = components_in_order(&shell);
let solid = Solid::try_new(parts).map_err(|e| KernelError::Failed(format!("planar boolean: {e}")))?;
let regions = solid.face_iter().map(|f| face_from.get(&f.id()).cloned().flatten().map(|o| (o, false))).collect::<Option<Vec<_>>>();
let tol = size * 1e-6;
@@ -988,6 +1017,7 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
.iter()
.map(|((a, b), e)| {
let ends = (*verts.get(*a)?, *verts.get(*b)?);
let (a, b) = (orig.get(*a)?, orig.get(*b)?);
let mut origins = BTreeSet::new();
for p in side.get(&(*a, *b))? {
for q in side.get(&(*b, *a))? {
@@ -1001,6 +1031,73 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
})
}
/// A vertex where the solid's surface pinches, as where two blocks meet at one corner only
/// (#679): the faces around it form several fans that share no edge there. Each fan past the
/// first gets its own copy of the vertex (same point), so every vertex of the shell is a
/// manifold one.
fn split_pinched_vertices(loops: &mut [Vec<Vec<usize>>], verts: &mut Vec<Vec3>, orig: &mut Vec<usize>) {
// Every corner: (group, loop, position), by vertex.
let mut corners: BTreeMap<usize, Vec<(usize, usize, usize)>> = BTreeMap::new();
for (g, ls) in loops.iter().enumerate() {
for (li, l) in ls.iter().enumerate() {
for (i, v) in l.iter().enumerate() {
corners.entry(*v).or_default().push((g, li, i));
}
}
}
let mut renames: Vec<((usize, usize, usize), usize)> = Vec::new();
let mut ids: Vec<usize> = corners.keys().copied().collect();
ids.sort_unstable();
for v in ids {
let Some(cs) = corners.get(&v) else { continue };
if cs.len() < 2 {
continue;
}
// The two neighbours of each corner; corners sharing an edge (v, x) are in one fan.
let nbrs: Vec<[usize; 2]> = cs
.iter()
.map(|(g, li, i)| {
let l = loops.get(*g).and_then(|ls| ls.get(*li)).map(Vec::as_slice).unwrap_or(&[]);
let n = l.len().max(1);
[l.get((i + n - 1) % n).copied().unwrap_or(v), l.get((i + 1) % n).copied().unwrap_or(v)]
})
.collect();
let mut parent: Vec<usize> = (0..cs.len()).collect();
for i in 0..cs.len() {
for j in i + 1..cs.len() {
let (Some(a), Some(b)) = (nbrs.get(i), nbrs.get(j)) else { continue };
if a.iter().any(|x| b.contains(x)) {
let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
if let Some(slot) = parent.get_mut(ri) {
*slot = rj;
}
}
}
}
let mut fan_id: BTreeMap<usize, usize> = BTreeMap::new();
for (k, c) in cs.iter().enumerate() {
let root = find(&mut parent, k);
let next = fan_id.len();
let fan = *fan_id.entry(root).or_insert(next);
if fan > 0 {
// A copy per extra fan.
let copy = verts.len() + fan - 1;
renames.push((*c, copy));
}
}
let p = verts.get(v).copied().unwrap_or_default();
for _ in 1..fan_id.len() {
verts.push(p);
orig.push(v);
}
}
for ((g, li, i), new) in renames {
if let Some(slot) = loops.get_mut(g).and_then(|ls| ls.get_mut(li)).and_then(|l| l.get_mut(i)) {
*slot = new;
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod diagnostic_tests {
+246 -21
View File
@@ -6,6 +6,8 @@
//! Stepped extrusions (case floors and walls) use sections between every cap level, so a
//! circular boss can meet the rim without shifting either operand off its exact plane.
use std::borrow::Cow;
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use truck_modeling as mt;
@@ -19,13 +21,23 @@ const MAX_PLANAR_SEGMENTS: usize = 8192;
const MAX_PLANAR_EVENTS: usize = 131_072;
const MAX_PLANAR_PAIRS: usize = 16_000_000;
/// Boolean of planar regions bounded by straight lines and circular arcs. Boundaries stay
/// analytic; disjoint results and retained islands are returned as separate regions.
/// Free-form boundary segments (conics, Béziers) take part in planar booleans as lines and
/// arcs within this distance (mm); loops that come out whole get their exact segments back.
pub const PLANAR_FIT_TOL: f64 = 1e-3;
/// Boolean of planar regions bounded by straight lines, circular arcs and free-form segments.
/// Lines and arcs stay analytic. Free-form loops are arranged as tangent arcs within
/// [`PLANAR_FIT_TOL`]: a loop the operation leaves whole comes back exact, one it cuts keeps
/// the arcs. Disjoint results and retained islands are returned as separate regions.
pub fn planar_regions_boolean(a: &[Region2], b: &[Region2], op: BoolOp, tolerance: f64) -> Result<Vec<Region2>> {
crate::guard("planar region boolean", || {
if !tolerance.is_finite() || !(1e-9..=1.0).contains(&tolerance) {
return Err(KernelError::Invalid("planar boolean tolerance must be finite and between 1e-9 and 1 mm".into()));
}
let mut exact: Vec<(Loop2, Loop2)> = Vec::new();
let mut pieces = 0usize;
let (a, b) = (fit_free_form(a, &mut exact, &mut pieces)?, fit_free_form(b, &mut exact, &mut pieces)?);
let (a, b): (&[Region2], &[Region2]) = (&a, &b);
let loops = || a.iter().chain(b).flat_map(|r| std::iter::once(&r.outer).chain(&r.holes));
let mut edges = 0usize;
let mut samples = 0usize;
@@ -59,11 +71,72 @@ pub fn planar_regions_boolean(a: &[Region2], b: &[Region2], op: BoolOp, toleranc
}
}
}
boolean2d(a, b, op, tolerance)
.ok_or_else(|| KernelError::Failed("planar region arrangement failed or exceeded its bounded processing budget".into()))
let out = boolean2d(a, b, op, tolerance)
.ok_or_else(|| KernelError::Failed("planar region arrangement failed or exceeded its bounded processing budget".into()))?;
Ok(restore_exact_loops(out, &exact, tolerance))
})
}
/// Regions with their free-form loops fitted by lines and arcs; `exact` records each fitted
/// loop with the loop it stands for, `pieces` counts the fitted segments. Regions of lines and
/// arcs alone are passed on as they are.
fn fit_free_form<'a>(regions: &'a [Region2], exact: &mut Vec<(Loop2, Loop2)>, pieces: &mut usize) -> Result<Cow<'a, [Region2]>> {
if regions.iter().all(Region2::is_line_arc) {
return Ok(Cow::Borrowed(regions));
}
let mut fit = |lp: &Loop2| -> Result<Loop2> {
if lp.is_line_arc() {
return Ok(lp.clone());
}
let fitted = lp.fit_arcs(PLANAR_FIT_TOL).filter(|f| {
*pieces = pieces.saturating_add(f.segs.len());
*pieces <= MAX_PLANAR_SEGMENTS
});
let fitted = fitted.ok_or_else(|| {
KernelError::Invalid("planar boolean: a free-form boundary is not finite or takes more arcs than the geometry budget".into())
})?;
exact.push((fitted.clone(), lp.clone()));
Ok(fitted)
};
let fitted: Result<Vec<Region2>> =
regions.iter().map(|r| Ok(Region2 { outer: fit(&r.outer)?, holes: r.holes.iter().map(&mut fit).collect::<Result<_>>()? })).collect();
fitted.map(Cow::Owned)
}
/// Output loops that are a fitted free-form loop, whole (from any start, either way round), get
/// the exact loop back, running the same way.
fn restore_exact_loops(regions: Vec<Region2>, exact: &[(Loop2, Loop2)], tol: f64) -> Vec<Region2> {
if exact.is_empty() {
return regions;
}
let near = |p: Vec2, q: Vec2| p.dist(q) <= tol * 10.0;
let same_run = |l: &Loop2, f: &Loop2| -> bool {
let n = f.segs.len();
let Some(first) = l.segs.first() else { return false };
(0..n).any(|k| {
f.segs.get(k).is_some_and(|s| near(s.start(), first.start()))
&& l.segs.iter().enumerate().all(|(i, s)| {
f.segs.get((k + i) % n).is_some_and(|g| near(g.start(), s.start()) && near(g.end(), s.end()) && near(g.mid(), s.mid()))
})
})
};
let restore = |l: Loop2| -> Loop2 {
for (fitted, original) in exact {
if fitted.segs.len() != l.segs.len() {
continue;
}
if same_run(&l, fitted) {
return original.clone();
}
if same_run(&l, &fitted.reversed()) {
return original.reversed();
}
}
l
};
regions.into_iter().map(|r| Region2 { outer: restore(r.outer), holes: r.holes.into_iter().map(&restore).collect() }).collect()
}
/// Extrude normalized planar material as one logical compound, retaining every disjoint
/// region and its holes. Planar arrangement certifies separation before shells are joined.
pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Body> {
@@ -95,8 +168,10 @@ pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, en
if normalized.is_empty() {
return Err(KernelError::Invalid("empty planar material extrusion".into()));
}
for (i, a) in normalized.iter().enumerate() {
for b in normalized.iter().skip(i + 1) {
// The separation test knows lines and arcs: free-form loops take part as their arcs.
let fitted = fit_free_form(&normalized, &mut Vec::new(), &mut 0)?;
for (i, a) in fitted.iter().enumerate() {
for b in fitted.iter().skip(i + 1) {
for s in std::iter::once(&a.outer).chain(&a.holes).flat_map(|l| &l.segs) {
for t in std::iter::once(&b.outer).chain(&b.holes).flat_map(|l| &l.segs) {
remaining_pairs = remaining_pairs
@@ -511,7 +586,7 @@ fn top_of(f: &mt::Face, plane: &Plane) -> f64 {
/// Certify that a curved wall is swept along `d` from its surface representation.
/// Seam vertices alone cannot establish this: a half-cylinder's seam normals can
/// both be perpendicular to a transverse direction, silently filling its hole.
fn wall_along(surface: &mt::Surface, d: Vec3, tol: f64, remaining: &mut usize) -> bool {
pub(crate) fn wall_along(surface: &mt::Surface, d: Vec3, tol: f64, remaining: &mut usize) -> bool {
if let mt::Surface::RevolutedCurve(processor) = surface {
let revolution = processor.entity();
let mt::Curve::Line(line) = revolution.entity_curve() else { return false };
@@ -887,19 +962,61 @@ fn dist_to(regions: &[Region2], p: Vec2) -> f64 {
.fold(f64::INFINITY, f64::min)
}
/// Direction of the boundary of `regions` through `p` (on it), as the boundary runs.
fn dir_at(regions: &[Region2], p: Vec2, tol: f64) -> Option<Vec2> {
for r in regions {
for l in std::iter::once(&r.outer).chain(&r.holes) {
for s in &l.segs {
let t = s.closest_param(p);
if s.point_at(t).dist(p) < tol {
return s.derivative(t).normalized();
/// The bounding box of a line or an arc, grown by twice the tolerance (and by the rounding of
/// its own corners): (min, max). Whatever lies within the tolerance of the segment lies in the
/// box. `None` for other segments and for values that are not finite: no box, and nothing is
/// skipped for it.
type SegBox = (Vec2, Vec2);
fn seg_box(s: &Seg2, tol: f64) -> Option<SegBox> {
let mut pts = vec![s.start(), s.end()];
match *s {
Seg2::Line { .. } => {}
Seg2::Arc { center, radius, start, sweep } => {
// The axis extremes the arc passes.
for k in 0..4 {
let ang = k as f64 * std::f64::consts::FRAC_PI_2;
let delta =
if sweep >= 0.0 { (ang - start).rem_euclid(std::f64::consts::TAU) } else { (start - ang).rem_euclid(std::f64::consts::TAU) };
if delta <= sweep.abs() {
pts.push(center + Vec2::from_angle(ang) * radius);
}
}
}
_ => return None,
}
None
let lo = pts.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |m, p| Vec2::new(m.x.min(p.x), m.y.min(p.y)));
let hi = pts.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |m, p| Vec2::new(m.x.max(p.x), m.y.max(p.y)));
let g = tol * 2.0 + 1e-12 * pts.iter().fold(0.0, |m: f64, p| m.max(p.x.abs()).max(p.y.abs()));
(pts.iter().all(|p| p.is_finite()) && g.is_finite()).then_some((Vec2::new(lo.x - g, lo.y - g), Vec2::new(hi.x + g, hi.y + g)))
}
fn boxes_meet(a: Option<SegBox>, b: Option<SegBox>) -> bool {
match (a, b) {
(Some((a0, a1)), Some((b0, b1))) => a0.x <= b1.x && b0.x <= a1.x && a0.y <= b1.y && b0.y <= a1.y,
_ => true,
}
}
fn box_holds(b: Option<SegBox>, p: Vec2) -> bool {
b.is_none_or(|(lo, hi)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y)
}
/// Distance from `p` to the nearest of `segs` whose box holds it: infinite when none does (no
/// segment is within the tolerance of `p` then).
fn near_dist(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2) -> f64 {
segs.iter().zip(boxes).filter(|(_, b)| box_holds(**b, p)).map(|(s, _)| s.point_at(s.closest_param(p)).dist(p)).fold(f64::INFINITY, f64::min)
}
/// Direction of the first of `segs` through `p` (within `tol`), as it runs.
fn near_dir(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2, tol: f64) -> Option<Vec2> {
let (s, t) = segs
.iter()
.zip(boxes)
.filter(|(_, b)| box_holds(**b, p))
.map(|(s, _)| (s, s.closest_param(p)))
.find(|(s, t)| s.point_at(*t).dist(p) < tol)?;
s.derivative(t).normalized()
}
/// 2D boolean of regions bounded by lines and arcs.
@@ -922,12 +1039,19 @@ fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Se
if sa.iter().chain(&sb).any(|s| !matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
return None;
}
// Only segments whose bounding boxes meet can cross, touch or lie on each other: the other
// pairs skip the exact tests (a closest-point search each).
let (box_a, box_b): (Vec<_>, Vec<_>) = (sa.iter().map(|s| seg_box(s, tol)).collect(), sb.iter().map(|s| seg_box(s, tol)).collect());
let apart = |i: usize, j: usize| !boxes_meet(box_a.get(i).copied().flatten(), box_b.get(j).copied().flatten());
// Split every segment where it crosses the other side's boundary.
let mut cut_a: Vec<Vec<f64>> = vec![Vec::new(); sa.len()];
let mut cut_b: Vec<Vec<f64>> = vec![Vec::new(); sb.len()];
let mut events = 0usize;
for (i, s) in sa.iter().enumerate() {
for (j, t) in sb.iter().enumerate() {
if apart(i, j) {
continue;
}
for (u, v) in crossings(s, t, tol) {
events += 2;
if events > MAX_PLANAR_EVENTS {
@@ -940,7 +1064,10 @@ fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Se
}
// Ends of the other side's segments lying on a segment split it too (touching corners).
for (i, s) in sa.iter().enumerate() {
for t in &sb {
for (j, t) in sb.iter().enumerate() {
if apart(i, j) {
continue;
}
for p in [t.start(), t.end()] {
let u = s.closest_param(p);
if s.point_at(u).dist(p) < tol {
@@ -954,7 +1081,10 @@ fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Se
}
}
for (j, t) in sb.iter().enumerate() {
for s in &sa {
for (i, s) in sa.iter().enumerate() {
if apart(i, j) {
continue;
}
for p in [s.start(), s.end()] {
let v = t.closest_param(p);
if t.point_at(v).dist(p) < tol {
@@ -997,9 +1127,9 @@ fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Se
let (n, tag) = (split.len(), ta.get(i).copied().unwrap_or(BLANK));
for (k, p) in split.into_iter().enumerate() {
let m = p.point_at(0.5);
let on = dist_to(&b, m) < tol;
let on = near_dist(&sb, &box_b, m) < tol;
let keep = if on {
let same = dir_at(&b, m, tol).is_some_and(|d| d.dot(p.derivative(0.5)) > 0.0);
let same = near_dir(&sb, &box_b, m, tol).is_some_and(|d| d.dot(p.derivative(0.5)) > 0.0);
match op {
BoolOp::Union | BoolOp::Intersect => same,
BoolOp::Cut => !same,
@@ -1028,7 +1158,7 @@ fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Se
let (n, tag) = (split.len(), tb.get(j).copied().unwrap_or(BLANK));
for (k, p) in split.into_iter().enumerate() {
let m = p.point_at(0.5);
if dist_to(&a, m) < tol {
if near_dist(&sa, &box_a, m) < tol {
continue;
}
let ins = inside(&a, m);
@@ -2205,6 +2335,101 @@ mod planar_tests {
assert!(extrude_planar_regions(&plane, &circles, 0.0, 2.0).is_err());
}
/// An ellipse about `c` with semi-axes `a` (along x) and `b`, as four exact conics.
fn ellipse(c: Vec2, a: f64, b: f64) -> Loop2 {
let corners = [c + Vec2::new(a, 0.0), c + Vec2::new(0.0, b), c - Vec2::new(a, 0.0), c - Vec2::new(0.0, b)];
let w = std::f64::consts::FRAC_1_SQRT_2;
Loop2 {
segs: (0..4).map(|i| Seg2::Conic { a: corners[i], apex: corners[i] + corners[(i + 1) % 4] - c, b: corners[(i + 1) % 4], w }).collect(),
}
}
#[test]
fn free_form_cut_outs_stay_exact_when_whole_and_follow_arcs_when_cut() {
let plate = rectangle(Vec2::ZERO, Vec2::new(100.0, 60.0));
let hole = Region2 { outer: ellipse(Vec2::new(40.0, 30.0), 15.0, 8.0), holes: vec![] };
let out = planar_regions_boolean(std::slice::from_ref(&plate), std::slice::from_ref(&hole), BoolOp::Cut, 1e-7).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].holes.len(), 1);
// The whole ellipse comes back as its four exact conics, running as a hole (clockwise):
// the plate less π·15·8.
let h = &out[0].holes[0];
assert_eq!(h.segs.len(), 4);
assert!(h.segs.iter().all(|s| hole.outer.segs.contains(&s.reversed())), "{h:?}");
assert!(h.signed_area() < 0.0);
let exact = 6000.0 - std::f64::consts::PI * 15.0 * 8.0;
assert!((out[0].area() - exact).abs() < 1e-9, "{} vs {exact}", out[0].area());
// What the cut takes is the ellipse, whole and exact, counter-clockwise.
let taken = planar_regions_boolean(std::slice::from_ref(&plate), std::slice::from_ref(&hole), BoolOp::Intersect, 1e-7).unwrap();
assert!(taken.len() == 1 && taken[0].outer.segs.iter().all(|s| hole.outer.segs.contains(s)), "{taken:?}");
// An ellipse centred on the plate's edge makes a notch of half the ellipse: its part
// inside follows arcs within the fit tolerance (times half the perimeter, 37 mm).
let over = Region2 { outer: ellipse(Vec2::new(100.0, 30.0), 15.0, 8.0), holes: vec![] };
let out = planar_regions_boolean(std::slice::from_ref(&plate), std::slice::from_ref(&over), BoolOp::Cut, 1e-7).unwrap();
assert_eq!(out.len(), 1);
assert!(out[0].holes.is_empty() && out[0].outer.is_line_arc());
let notched = 6000.0 - std::f64::consts::PI * 15.0 * 8.0 / 2.0;
assert!((out[0].area() - notched).abs() < PLANAR_FIT_TOL * 37.0, "{} vs {notched}", out[0].area());
// Two ellipses that overlap join into one outline of arcs; apart, both stay exact.
let beside = |dx: f64| Region2 { outer: ellipse(Vec2::new(40.0 + dx, 30.0), 15.0, 8.0), holes: vec![] };
let joined = planar_regions_boolean(std::slice::from_ref(&hole), &[beside(20.0)], BoolOp::Union, 1e-7).unwrap();
assert!(joined.len() == 1 && joined[0].outer.is_line_arc(), "{joined:?}");
let apart = planar_regions_boolean(std::slice::from_ref(&hole), &[beside(40.0)], BoolOp::Union, 1e-7).unwrap();
assert!(apart.len() == 2 && apart.iter().all(|r| r.outer.segs.len() == 4 && !r.outer.is_line_arc()), "{apart:?}");
// The flat material extrudes with the exact elliptic wall: four conic faces, two caps.
let body = extrude_planar_regions(&Plane::XY, &planar_regions_boolean(&[plate], &[hole], BoolOp::Cut, 1e-7).unwrap(), 0.0, 2.0).unwrap();
assert_eq!(body.face_count(), 4 + 4 + 2);
let v = crate::measure(&body).unwrap().volume;
assert!((v - exact * 2.0).abs() < 1e-3 * exact, "{v}");
}
#[test]
fn free_form_loops_that_cannot_be_followed_are_refused() {
let plate = rectangle(Vec2::ZERO, Vec2::new(100.0, 60.0));
let mut bad = ellipse(Vec2::new(40.0, 30.0), 15.0, 8.0);
bad.segs[1] = Seg2::Conic { a: bad.segs[1].start(), apex: Vec2::new(f64::NAN, 0.0), b: bad.segs[1].end(), w: 1.0 };
let e = planar_regions_boolean(std::slice::from_ref(&plate), &[Region2 { outer: bad, holes: vec![] }], BoolOp::Cut, 1e-7).unwrap_err();
assert!(e.to_string().contains("free-form"), "{e}");
// More fitted arcs than the arrangement's segment budget.
let many: Vec<Region2> = (0..400).map(|i| Region2 { outer: ellipse(Vec2::new(40.0 * i as f64, 0.0), 15.0, 8.0), holes: vec![] }).collect();
assert!(planar_regions_boolean(&[plate], &many, BoolOp::Cut, 1e-7).is_err());
}
#[test]
fn segment_boxes_hold_what_lies_within_the_tolerance() {
// An arc of radius 10 about the origin from 45° to 135° passes the top of its circle:
// x within ±10/√2, y from 10/√2 to 10, grown by twice the tolerance.
let r = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: std::f64::consts::FRAC_PI_4, sweep: std::f64::consts::FRAC_PI_2 };
for s in [arc, arc.reversed()] {
let (lo, hi) = seg_box(&s, 1e-3).unwrap();
assert!(lo.dist(Vec2::new(-r - 2e-3, r - 2e-3)) < 1e-9 && hi.dist(Vec2::new(r + 2e-3, 10.0 + 2e-3)) < 1e-9, "{lo:?} {hi:?}");
}
let (lo, hi) = seg_box(&Seg2::Line { a: Vec2::new(3.0, -1.0), b: Vec2::new(-2.0, 4.0) }, 0.5).unwrap();
assert!(lo.dist(Vec2::new(-3.0, -2.0)) < 1e-9 && hi.dist(Vec2::new(4.0, 5.0)) < 1e-9, "{lo:?} {hi:?}");
// A line 3 tolerances above the arc's top meets its box (each is grown by two); one
// 5 tolerances above does not. No box (a free-form segment, a value that is not
// finite) hides nothing.
let level = |y: f64| seg_box(&Seg2::Line { a: Vec2::new(-1.0, y), b: Vec2::new(1.0, y) }, 1e-3);
assert!(boxes_meet(seg_box(&arc, 1e-3), level(10.003)) && !boxes_meet(seg_box(&arc, 1e-3), level(10.005)));
let free = ellipse(Vec2::ZERO, 2.0, 1.0).segs[0];
assert!(seg_box(&free, 1e-3).is_none() && seg_box(&Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::X }, 1e-3).is_none());
assert!(boxes_meet(None, level(500.0)) && box_holds(None, Vec2::new(500.0, 500.0)));
}
#[test]
fn many_small_cut_outs_far_apart_cut_as_before() {
// 400 round holes in a strip: almost every pair of segments is far apart.
let plate = rectangle(Vec2::ZERO, Vec2::new(2000.0, 20.0));
let holes: Vec<Region2> =
(0..400).map(|i| Region2 { outer: Loop2::circle(Vec2::new(2.5 + 5.0 * i as f64, 10.0), 1.8), holes: vec![] }).collect();
let out = planar_regions_boolean(&[plate], &holes, BoolOp::Cut, 1e-7).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].holes.len(), 400);
let exact = 40_000.0 - 400.0 * std::f64::consts::PI * 1.8 * 1.8;
assert!((out[0].area() - exact).abs() < 1e-6, "{}", out[0].area());
}
#[test]
fn analytic_circle_classification_respects_signed_sweeps_and_seams() {
for seam in [0.0, 0.13, std::f64::consts::FRAC_PI_2, -std::f64::consts::FRAC_PI_2] {

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