Compare commits

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

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

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

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

Fixes #556

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #513

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 00:11:55 -04:00
William Dunn aaeb7ceea7 Sketch Palette: a right-aligned control column, one Look At icon, a disclosure triangle, plain-noun labels (#497)
Every Options row puts its control in one right-aligned column, Look At is a single label with an icon button, Options folds with a disclosure triangle, and the toggles use plain nouns (with dedicated ids for Profile and Points so they don't collide with other labels).
2026-10-12 23:58:58 -04:00
William Dunn 10c6b865c7 Sketch Palette: Linetype tooltips say what the click does (#499)
The Linetype buttons now read "Draw as {linetype}" with nothing selected and "Convert the selection to {linetype}" with a selection, in all four locales.
2026-10-12 23:58:40 -04:00
223 changed files with 33888 additions and 1206 deletions

No files matched your search

+5 -2
View File
@@ -73,10 +73,13 @@ installing SolidCraft; keep both remotes in the same user installation:
```sh
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak remote-add --user --if-not-exists solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo
flatpak install --user solidcraft ai.storyteller.solvecraft
flatpak run ai.storyteller.solvecraft
flatpak install --user solidcraft ai.storyteller.solidcraft
flatpak run ai.storyteller.solidcraft
```
Flatpak installs from 0.7 or earlier use the old ID `ai.storyteller.solvecraft`: `flatpak update --user`
offers to replace it with `ai.storyteller.solidcraft`, and settings move along.
Single-file `.flatpak` bundles include the runtime repository address; missing runtimes
still need an internet connection. [Linux installation details](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
+42 -1
View File
@@ -29,6 +29,19 @@ env:
CARGO_TERM_COLOR: always
jobs:
dependency-advisories:
name: Dependency advisories
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- name: Install advisory scanner
run: cargo install cargo-deny --version 0.20.2 --locked
- name: Check locked dependencies
run: cargo deny --locked check --warn unmaintained advisories bans sources
changes:
name: Select checks
runs-on: ubuntu-24.04
@@ -172,6 +185,34 @@ jobs:
path: target/nextest/ci/junit.xml
retention-days: 7
action-matrix:
# Every ordinary action on every face, edge, vertex and body of the design corpus, against
# tests/action_matrix/baseline.json (docs/action-matrix.md). Every test run checks a sample;
# the whole matrix runs nightly (or by hand), a quarter per job.
name: Action matrix (${{ matrix.part }}/4)
needs: build-tests
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
part: [1, 2, 3, 4]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Run a quarter of the matrix
env:
PART: ${{ matrix.part }}
run: |
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --run-ignored only -E "binary(action_matrix) & test(=action_matrix_full_$PART)"
doc-tests:
name: Doc tests
needs: changes
@@ -315,4 +356,4 @@ jobs:
run: |
. "$HOME/.cargo/env"
export CRAFT_FONTS_DIR="$PWD/craft-fonts"
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
+8 -4
View File
@@ -53,9 +53,13 @@ jobs:
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/solidcraft.wxs packaging/windows/solidcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.solvecraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solvecraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solidcraft'"
desktop-file-validate packaging/linux/ai.storyteller.solidcraft.desktop
# The window's X11 class is its app id; the old id stays only as the rename shims.
grep -qx 'StartupWMClass=ai.storyteller.solidcraft' packaging/linux/ai.storyteller.solidcraft.desktop
grep -q '<id>ai.storyteller.solvecraft</id>' packaging/linux/ai.storyteller.solidcraft.metainfo.xml.in
grep -qF -e '--end-of-life-rebase=' packaging/flatpak-site.sh
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solidcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solidcraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solidcraft' and m['command'] == 'solidcraft'"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/snap/snapcraft.yaml')); assert m['name'] == 'solidcraft' and m['confinement'] == 'strict' and m['apps']['solidcraft']['command'] == 'usr/bin/solidcraft'"
python3 -m py_compile packaging/linux/snap-smoke.py
+1 -1
View File
@@ -94,7 +94,7 @@ Generated-in-code assets are original and have no file to list:
| Directory | Source | Licence | Changes |
|---|---|---|---|
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, subdivision and provisional fixed-grid previews (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
+7
View File
@@ -100,6 +100,8 @@ The [README](README.md#architecture) has the layer table. The short version:
not.) Permissive
dependencies are fine. Note which one you're adding and why in the PR, and run
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see
[the local command and coverage limits](docs/book/src/dev/testing.md#dependency-advisories).
3. **Every asset is original or openly licensed and has a row in
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
`cargo xtask assets` enforces this.
@@ -166,6 +168,11 @@ Pull requests skip a few heavy tests (one to four minutes each), which run on ev
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
**The action matrix** tries every ordinary action on every face, edge, vertex and body of a
corpus of designs against a checked-in ratchet ([docs/action-matrix.md](docs/action-matrix.md)).
A pull request that makes an action pass should record it with `cargo xtask action-matrix --bless`;
one that makes a passing action fail fails the sample every CI run checks.
**The reference tests** are 76 parts in [`tests/oracle/`](tests/oracle/), each a `recipe.json`
in our own format with an `expected.json` beside it. SolidCraft rebuilds the recipe and compares
body count, volume, area, centre of mass and bounding box (and, where there is a `reference.stl`,
Generated
+4 -12
View File
@@ -2765,15 +2765,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -3297,7 +3288,7 @@ dependencies = [
"embed-resource",
"hidapi",
"log",
"quick-xml 0.38.4",
"quick-xml",
"rfd",
"serde_json",
"solidcraft-engine",
@@ -3358,7 +3349,7 @@ dependencies = [
name = "solidcraft-io"
version = "0.7.0"
dependencies = [
"quick-xml 0.38.4",
"quick-xml",
"serde",
"serde_json",
"sha2",
@@ -3375,6 +3366,7 @@ version = "0.7.0"
dependencies = [
"log",
"serde",
"serde_json",
"solidcraft-geom",
"thiserror 2.0.21",
"truck-meshalgo",
@@ -4181,7 +4173,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.41.0",
"quick-xml",
"quote",
]
+1 -1
View File
@@ -40,7 +40,7 @@ truck-topology = "0.6"
# packages (3MF)
zip = { version = "3", default-features = false, features = ["deflate"] }
quick-xml = "0.38"
quick-xml = "0.41"
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
+2 -1
View File
@@ -112,7 +112,8 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
separately from through-hole outlines in the flat preview and DXF.
- **Files**: design containers (`.solidcraft`) with lossless binary mesh payloads;
plain JSON remains supported for opening, Save As and export. Desktop Open can show a saved
experimental preview (explicit `preview: true` engine saves) while checking geometry before editing. Export to STEP (AP242 with body names, colours and
provisional picture from warm geometry caches while rebuilding before editing; caches never
replace the parametric design. Export to STEP (AP242 with body names, colours and
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ;
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
+41 -7
View File
@@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature | in progress | 36 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular) chosen by the rule | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
@@ -61,6 +61,20 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Dimensioning and error reporting
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
`batch`: body changes and created sketch IDs stay, while repeated lists are omitted.
Both tools retain full replies by default.
- Sketch Create groups drawing variants under Line, Rectangle, Circle, Arc, Slot,
Polygon and Spline. Each open part remembers its last interactive choice per family
across sketch sessions and document-tab switches; closing the part resets these UI choices.
- Inscribed, circumscribed and edge polygon tools preview their actual outline before
placement, including orientation and the selected side count.
- Construction (X) preserves the active sketch drawing tool, picked anchors and inline
dimension values, so line type can change while a shape is in progress.
- Planar sketch construction previews and hover feedback keep the same dashed pattern as
committed geometry; centerlines retain their dash-dot pattern.
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
@@ -147,6 +161,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## View navigation
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
its layout stable during a drag and applies the selected scale on release.
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
@@ -156,6 +173,11 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Known limitations
- Scheduled and manual CI scan locked dependency advisories, bans and sources with
cargo-deny. Vulnerabilities fail the scan; unmaintained crates warn. Local vendored
patches require separate upstream review because advisory databases cannot version-match
their path dependencies. Pull requests retain the existing licence checks.
- Associative shell-face sketches require a unique certified planar source. Nested shell
attachments and ambiguous coincident sources remain unsupported.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
@@ -323,6 +345,10 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
tapered or bend-crossing machining refuses atomically instead of restoring material later.
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
A flange on another flange's far edge (a return) follows that flange's closed end and keeps
clear of the flange beside it, and the returns of one feature are mitred toward each other
with the rule's gap where their flanges meet at a corner; returns of separate features, and
flanges on returns, are not closed toward each other.
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
@@ -356,13 +382,21 @@ and reference-object placement remain separate work, as do loft rails and center
- Design containers (#303, phase one) separate and deduplicate STEP, raw images and
lossless binary mesh arrays while keeping plain JSON as a supported Save As/export
format. Generated-mesh storage measurements reduce serialization and decoding work;
evaluated geometry caching and instant cold display remain separate follow-up work.
a seekable reader defers original blobs until after provisional display.
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
- Desktop Open can show an experimental, opt-in bounded saved display preview while rebuilding in the existing
file worker. Ordinary saves omit display caches; explicit `preview: true` saves cache
only already warm meshes. Document hashes and application versions reject
stale previews. Preview geometry cannot be edited or measured, and cancelled or stale
completions cannot replace a newer document. Evaluated B-rep restoration remains open.
- Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology
and geometry records, with a separate truck adapter and bounded binary reader. Exact
face-source certificates and operation history survive codec round trips. A provisional
picture can be generated before original blobs are read; editing and measurement wait
for authoritative reconstruction. Cached metadata binds preview and reconstruction to
the same document despite concurrent file writes. Default saves omit this cache and never force cold
evaluation or meshing; explicit `preview: true` remains a separate warm-mesh option.
Keys, resource budgets and document/revision guards reject incompatible, hostile or
stale results. A second modelling kernel still requires its own tested adapter.
- Warm saves with explicit `cache: true` can also carry a bounded PNG thumbnail from already cached meshes.
Home reads it without rebuilding the design when its local picture is missing;
mismatched or malformed thumbnails fall back to normal generation. The image is
kernel-independent and saving never forces cold meshing to produce it.
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
+32
View File
@@ -0,0 +1,32 @@
# Security policy
## Supported versions
SolidCraft is young and moves quickly, so only the latest release gets security fixes. Please
update to the [latest release](https://github.com/bherbruck/solidcraft/releases/latest) before
reporting, and check whether the problem is still on `main`.
| Version | Supported |
| --- | --- |
| Latest release | ✅ |
| Nightly builds | ✅ (fixes land here first) |
| Older releases | ❌ |
## Reporting a vulnerability
Please **don't open a public issue** for a security problem. Report it privately through GitHub:
open the repository's [Security tab](https://github.com/bherbruck/solidcraft/security) and choose
**Report a vulnerability**
([direct link](https://github.com/bherbruck/solidcraft/security/advisories/new)).
Please include what you found, how to reproduce it (a design file, command or MCP request), the
version and platform, and the impact you expect. I'll acknowledge the report as soon as I can,
keep you updated in the advisory, and credit you when the fix is published unless you'd rather
stay anonymous.
Areas where reports are especially useful:
- opening untrusted design, STEP, STL or 3MF files (crashes, excessive memory or CPU, path tricks)
- the MCP server and the control API (file access outside the allowed folders, command injection)
- the web app (anything reaching beyond the page's own storage)
- release packages and the update paths (AppImage, Flatpak, MSI, deb, rpm, snap)
+2 -3
View File
@@ -1,6 +1,6 @@
Third-party software in SolidCraft
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 454 crates below.
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 453 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
@@ -262,7 +262,6 @@ proc-macro2 1.0.107 (MIT OR Apache-2.0)
profiling 1.0.18 (MIT OR Apache-2.0)
pxfm 0.1.30 (BSD-3-Clause OR Apache-2.0)
quick-error 2.0.1 (MIT/Apache-2.0)
quick-xml 0.38.4 (MIT)
quick-xml 0.41.0 (MIT)
quote 1.0.47 (MIT OR Apache-2.0)
r-efi 5.3.0 (MIT OR Apache-2.0 OR LGPL-2.1-or-later)
@@ -8709,7 +8708,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
quick-xml 0.38.4, quick-xml 0.41.0
quick-xml 0.41.0
================================================================================
The MIT License (MIT)
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -16,11 +16,13 @@ hostile variants of it never panic.
| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) |
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
| `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) |
| `move-bodies` | 00baf1d | `solid.move` before it could copy: two bodies, one translated in place, one translated and turned 90 deg about Z (no `copy` key anywhere in the file) |
| `split-body-pieces` | v0.3.2 (07e263d) | a slot along X cuts a block into two bodies; a fillet, a chamfer and an appearance refer to the pieces by body name (`Body1`, `Body1 (1)`) |
| `issue-489-stacked-join` | v0.6.0 (809ecf7), desktop | a 70 × 84 extrude, then a 70 × 62 strip joined on its top face, sketched partly over the face's projected edges (issue #489; expected values from main at 2880525) |
| `issue-616-lid-ribs` | v0.7.0 (a9879fa), CLI | a rounded lid with a skirt ring, a window and a slot cut through, then four ribs joined on the plate over the skirt, flush with the window's sides; v0.7.0 displaced the ribs and left a slit through the skirt and plate (issue #616; script `examples/regressions/lid_ribs_flush_with_window.json`, expected values from the fix) |
The scripts that made them are in `scripts/` (`issue-489-stacked-join` was drawn in the desktop
app and has none). Every flat face of every final body must also take an attached sketch and an
app and has none; `issue-616-lid-ribs` is made by its regression script). Every flat face of every final body must also take an attached sketch and an
offset plane; the faces that can't yet are listed with their issues in `design_corpus.rs`.
Never regenerate a file with a newer build: the point is that it was written by the old one. Add new files for new eras instead.
@@ -0,0 +1,227 @@
{
"bodies": [
{
"area_mm2": 8430.29544246263,
"bbox": {
"max": [
176.8000000000002,
89.30000000000001,
14.100000000000001
],
"min": [
99.99999999999991,
-1.820765760385257e-14,
0.0
]
},
"center_of_mass": [
138.3999999716193,
51.04392591016203,
2.0990634391586322
],
"density_g_cm3": 1.29,
"edges": 156,
"face_types": {
"cylinder": 4,
"plane": 50
},
"faces": 54,
"kernel": {
"edges": 156,
"faces": 54,
"shells": 1,
"vertices": 104
},
"mass_g": 9.101726874183207,
"material": "Default",
"name": "Lid",
"vertices": 104,
"volume_mm3": 7055.602228048997
}
],
"body_count": 1,
"params": [],
"sketches": [
{
"curves": 8,
"dof": 8,
"id": 3,
"name": "Skirt",
"profiles": 2,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 1,
"ms": 1.178957,
"name": "Box1",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 2,
"ms": 83.24342399999999,
"name": "Fillet1",
"rolled_back": false,
"suppressed": false,
"type": "FilletFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 3,
"ms": 0.150889,
"name": "Skirt",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 4,
"ms": 94.465481,
"name": "Extrude1",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 5,
"ms": 50.426736,
"name": "Box2",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 6,
"ms": 40.383005,
"name": "Box3",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 7,
"ms": 59.041461000000005,
"name": "Box4",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [
{
"face_shift_mm": 0.0,
"rotation_rad": 0.0,
"strategy": "planar_fallback",
"tolerance_mm": 0.0,
"translation_mm": 0.0
}
],
"truncated": false
},
"error": null,
"id": 8,
"ms": 562.5477970000001,
"name": "Rib1",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": "Boolean retry: exact planar fallback (no displacement)"
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 9,
"ms": 89.56076,
"name": "Rib2",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 10,
"ms": 65.330634,
"name": "Rib3",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 11,
"ms": 97.229403,
"name": "Rib4",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
}
],
"total": {
"area_mm2": 8430.29544246263,
"edges": 156,
"faces": 54,
"mass_g": 9.101726874183207,
"vertices": 104,
"volume_mm3": 7055.602228048997
}
}
@@ -0,0 +1,507 @@
{
"features": [
{
"body_names": [
"Lid"
],
"corner": [
100.0,
0.0,
0.0
],
"height": "2.4",
"id": 1,
"length": "76.8",
"name": "Box1",
"operation": "new_body",
"param_names": [
"d1",
"d2",
"d3"
],
"suppressed": false,
"type": "box",
"width": "89.3"
},
{
"edge_names": [
"F1:-x|F1:-y",
"F1:+x|F1:-y",
"F1:+y|F1:-x",
"F1:+x|F1:+y"
],
"edge_refs": [
{
"dir": [
0.0,
0.0,
-1.0
],
"max": [
176.8,
89.3,
2.4
],
"min": [
100.0,
0.0,
0.0
]
},
{
"dir": [
0.0,
0.0,
1.0
],
"max": [
176.8,
89.3,
2.4
],
"min": [
100.0,
0.0,
0.0
]
},
{
"dir": [
0.0,
0.0,
1.0
],
"max": [
176.8,
89.3,
2.4
],
"min": [
100.0,
0.0,
0.0
]
},
{
"dir": [
0.0,
0.0,
1.0
],
"max": [
176.8,
89.3,
2.4
],
"min": [
100.0,
0.0,
0.0
]
}
],
"edges": [
[
100.0,
0.0,
1.2
],
[
176.8,
0.0,
1.2
],
[
100.0,
89.3,
1.2
],
[
176.8,
89.3,
1.2
]
],
"id": 2,
"name": "Fillet1",
"param_names": [
"d4"
],
"radius": "3",
"suppressed": false,
"type": "fillet"
},
{
"id": 3,
"name": "Skirt",
"param_names": [
"d5"
],
"plane": {
"base": {
"name": "XY",
"type": "origin"
},
"distance": "2.4",
"type": "offset"
},
"sketch": {
"constraints": [
{
"id": "k1",
"l": 0,
"type": "horizontal"
},
{
"id": "k2",
"l": 2,
"type": "horizontal"
},
{
"id": "k3",
"l": 1,
"type": "vertical"
},
{
"id": "k4",
"l": 3,
"type": "vertical"
},
{
"id": "k5",
"l": 4,
"type": "horizontal"
},
{
"id": "k6",
"l": 6,
"type": "horizontal"
},
{
"id": "k7",
"l": 5,
"type": "vertical"
},
{
"id": "k8",
"l": 7,
"type": "vertical"
}
],
"counters": {
"k": 8,
"l": 8,
"p": 8
},
"curves": [
{
"a": 1,
"b": 2,
"construction": false,
"id": "l1",
"type": "line"
},
{
"a": 2,
"b": 3,
"construction": false,
"id": "l2",
"type": "line"
},
{
"a": 3,
"b": 4,
"construction": false,
"id": "l3",
"type": "line"
},
{
"a": 4,
"b": 1,
"construction": false,
"id": "l4",
"type": "line"
},
{
"a": 5,
"b": 6,
"construction": false,
"id": "l5",
"type": "line"
},
{
"a": 6,
"b": 7,
"construction": false,
"id": "l6",
"type": "line"
},
{
"a": 7,
"b": 8,
"construction": false,
"id": "l7",
"type": "line"
},
{
"a": 8,
"b": 5,
"construction": false,
"id": "l8",
"type": "line"
}
],
"points": [
{
"fixed": true,
"id": "origin",
"pos": [
0.0,
0.0
]
},
{
"fixed": false,
"id": "p1",
"pos": [
102.65,
2.65
]
},
{
"fixed": false,
"id": "p2",
"pos": [
174.15,
2.65
]
},
{
"fixed": false,
"id": "p3",
"pos": [
174.15,
86.65
]
},
{
"fixed": false,
"id": "p4",
"pos": [
102.65,
86.65
]
},
{
"fixed": false,
"id": "p5",
"pos": [
104.15,
4.15
]
},
{
"fixed": false,
"id": "p6",
"pos": [
172.65,
4.15
]
},
{
"fixed": false,
"id": "p7",
"pos": [
172.65,
85.15
]
},
{
"fixed": false,
"id": "p8",
"pos": [
104.15,
85.15
]
}
]
},
"suppressed": false,
"type": "sketch"
},
{
"extent": {
"direction": "positive",
"distance": "3"
},
"id": 4,
"name": "Extrude1",
"operation": "join",
"param_names": [
"d6"
],
"profiles": {
"loops": [
[
"l1",
"l2",
"l3",
"l4"
]
],
"type": "curves"
},
"sketch": 3,
"suppressed": false,
"targets": [
"Lid"
],
"type": "extrude"
},
{
"corner": [
104.4,
5.4,
-1.0
],
"height": "8",
"id": 5,
"length": "68",
"name": "Box2",
"operation": "cut",
"param_names": [
"d7",
"d8",
"d9"
],
"suppressed": false,
"type": "box",
"width": "61"
},
{
"corner": [
116.4,
72.0,
-1.0
],
"height": "8",
"id": 6,
"length": "44",
"name": "Box3",
"operation": "cut",
"param_names": [
"d10",
"d11",
"d12"
],
"suppressed": false,
"type": "box",
"width": "10"
},
{
"corner": [
127.4,
84.0,
2.4
],
"height": "4",
"id": 7,
"length": "22",
"name": "Box4",
"operation": "cut",
"param_names": [
"d13",
"d14",
"d15"
],
"suppressed": false,
"type": "box",
"width": "6"
},
{
"corner": [
102.8,
14.0,
2.4
],
"height": "11.7",
"id": 8,
"length": "1.6",
"name": "Rib1",
"operation": "join",
"param_names": [
"d16",
"d17",
"d18"
],
"suppressed": false,
"type": "box",
"width": "6"
},
{
"corner": [
102.8,
48.0,
2.4
],
"height": "11.7",
"id": 9,
"length": "1.6",
"name": "Rib2",
"operation": "join",
"param_names": [
"d19",
"d20",
"d21"
],
"suppressed": false,
"type": "box",
"width": "6"
},
{
"corner": [
172.4,
14.0,
2.4
],
"height": "11.7",
"id": 10,
"length": "1.6",
"name": "Rib3",
"operation": "join",
"param_names": [
"d22",
"d23",
"d24"
],
"suppressed": false,
"type": "box",
"width": "6"
},
{
"corner": [
172.4,
48.0,
2.4
],
"height": "11.7",
"id": 11,
"length": "1.6",
"name": "Rib4",
"operation": "join",
"param_names": [
"d25",
"d26",
"d27"
],
"suppressed": false,
"type": "box",
"width": "6"
}
],
"format": "solidcraft/15",
"name": "issue-616-lid-ribs",
"next_id": 12,
"params": [],
"units": "mm"
}
@@ -0,0 +1,194 @@
{
"bodies": [
{
"area_mm2": 2400.0,
"bbox": {
"max": [
50.0,
20.0,
20.0
],
"min": [
30.0,
0.0,
0.0
]
},
"center_of_mass": [
40.0,
10.0,
10.0
],
"density_g_cm3": 1.29,
"edges": 12,
"face_types": {
"plane": 6
},
"faces": 6,
"kernel": {
"edges": 12,
"faces": 6,
"shells": 1,
"vertices": 8
},
"mass_g": 10.32,
"material": "Default",
"name": "Block",
"vertices": 8,
"volume_mm3": 8000.0
},
{
"area_mm2": 600.0,
"bbox": {
"max": [
3.552713678800501e-15,
90.0,
10.0
],
"min": [
-9.999999999999998,
80.0,
0.0
]
},
"center_of_mass": [
-4.999999999999999,
85.0,
5.000000000000001
],
"density_g_cm3": 1.29,
"edges": 12,
"face_types": {
"plane": 6
},
"faces": 6,
"kernel": {
"edges": 12,
"faces": 6,
"shells": 1,
"vertices": 8
},
"mass_g": 1.29,
"material": "Default",
"name": "Peg",
"vertices": 8,
"volume_mm3": 1000.0
}
],
"body_count": 2,
"params": [],
"sketches": [
{
"curves": 4,
"dof": 2,
"id": 1,
"name": "Base",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 4,
"dof": 4,
"id": 3,
"name": "Peg",
"profiles": 1,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 1,
"ms": 0.204585,
"name": "Base",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 2,
"ms": 2.318662,
"name": "Extrude1",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 3,
"ms": 0.109196,
"name": "Peg",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 4,
"ms": 0.338859,
"name": "Extrude2",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 5,
"ms": 0.312259,
"name": "Move1",
"rolled_back": false,
"suppressed": false,
"type": "MoveFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 6,
"ms": 0.23144699999999999,
"name": "Move2",
"rolled_back": false,
"suppressed": false,
"type": "MoveFeature",
"warning": null
}
],
"total": {
"area_mm2": 3000.0,
"edges": 24,
"faces": 12,
"mass_g": 11.61,
"vertices": 16,
"volume_mm3": 9000.0
}
}
@@ -0,0 +1 @@
{"features":[{"id":1,"name":"Base","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"},{"id":"k5","p":0,"q":1,"type":"coincident"}],"counters":{"k":5,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[0.0,0.0]},{"fixed":false,"id":"p2","pos":[20.0,0.0]},{"fixed":false,"id":"p3","pos":[20.0,20.0]},{"fixed":false,"id":"p4","pos":[0.0,20.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Block"],"extent":{"direction":"positive","distance":"20"},"id":2,"name":"Extrude1","operation":"new_body","param_names":["d1"],"profiles":{"type":"all"},"sketch":1,"suppressed":false,"type":"extrude"},{"id":3,"name":"Peg","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"}],"counters":{"k":4,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[40.0,0.0]},{"fixed":false,"id":"p2","pos":[50.0,0.0]},{"fixed":false,"id":"p3","pos":[50.0,10.0]},{"fixed":false,"id":"p4","pos":[40.0,10.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Peg"],"extent":{"direction":"positive","distance":"10"},"id":4,"name":"Extrude2","operation":"new_body","param_names":["d2"],"profiles":{"type":"all"},"sketch":3,"suppressed":false,"type":"extrude"},{"bodies":["Block"],"id":5,"name":"Move1","param_names":["d3","d4","d5"],"suppressed":false,"translate":["30","0","0"],"type":"move"},{"angle":"90 deg","bodies":["Peg"],"id":6,"name":"Move2","param_names":["d6","d7","d8","d9"],"rotate_axis":[0.0,0.0,1.0],"suppressed":false,"translate":["0","40","0"],"type":"move"}],"format":"solidcraft/15","name":"old-move-bodies","next_id":7,"params":[],"units":"mm"}
@@ -0,0 +1,14 @@
{
"commands": [
{"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [20, 20]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 20, "body_name": "Block"}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "Peg"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [40, 0], "p1": [50, 10]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 10, "operation": "new", "body_name": "Peg"}},
{"command": "solid.move", "params": {"bodies": ["Block"], "translate": [30, 0, 0]}},
{"command": "solid.move", "params": {"bodies": ["Peg"], "translate": [0, 40, 0], "axis": [0, 0, 1], "angle": "90 deg"}}
]
}
+8 -25
View File
@@ -137,18 +137,13 @@ 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); 9] = [
("issue-489-stacked-join", "Body1", Some(&[([1., 0., 0.], 35.), ([-1., 0., 0.], 35.)]), 491),
("issue-489-stacked-join", "Body1", Some(&[([0., 0., 1.], 30.)]), 492),
("m0-revolve-primitives", "Body2", Some(&[([-FRAC_1_SQRT_2, 0., FRAC_1_SQRT_2], -21. * FRAC_1_SQRT_2)]), 493),
("split-body-pieces", "Body1", Some(&[([0., FRAC_1_SQRT_2, FRAC_1_SQRT_2], 38.5 * FRAC_1_SQRT_2), ([0., -1., 0.], -18.)]), 493),
("split-body-pieces", "Body1 (1)", Some(&[([0., 1., 0.], 12.)]), 493),
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),
];
use std::f64::consts::FRAC_1_SQRT_2;
/// 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)> {
@@ -267,24 +262,6 @@ fn known_faces_attach(issue: u32) {
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
#[test]
#[ignore = "fails until #491 is fixed"]
fn a_side_face_join_merged_from_two_extrudes_takes_an_attached_sketch() {
known_faces_attach(491);
}
#[test]
#[ignore = "fails until #492 is fixed"]
fn a_face_split_into_pieces_by_a_later_feature_takes_an_attached_sketch() {
known_faces_attach(492);
}
#[test]
#[ignore = "fails until #493 is fixed"]
fn cut_walls_and_chamfer_faces_take_an_attached_sketch() {
known_faces_attach(493);
}
#[test]
#[ignore = "fails until #494 is fixed"]
fn faces_after_revolve_offset_face_and_boss_take_an_attached_sketch() {
@@ -297,6 +274,12 @@ fn sheet_metal_thickness_faces_take_an_attached_sketch() {
known_faces_attach(495);
}
#[test]
#[ignore = "fails until #628 is fixed"]
fn faces_of_a_lid_with_joined_ribs_take_an_attached_sketch() {
known_faces_attach(628);
}
/// A small deterministic generator (xorshift).
struct Rng(u64);
impl Rng {
+5 -2
View File
@@ -110,7 +110,7 @@ mod web {
let changed = s.is_dirty() && self.autosaved != Some(s.revision);
let wait = AUTOSAVE_S - (now - self.autosaved_at);
if changed && wait <= 0.0 {
files::autosave(&self.files, Some((&s.doc, s.path.as_deref())));
files::autosave(&self.files, Some((&s.design(), s.path.as_deref())));
self.autosaved = Some(s.revision);
self.autosaved_at = now;
} else if changed {
@@ -251,7 +251,10 @@ mod web {
},
"download_open" => {
let d = &self.app.session.doc;
let result = solidcraft_engine::io::write_design_container(d)
let result = self
.app
.session
.design_container(solidcraft_engine::io::DesignCompression::Fast, false, false)
.map_err(|e| e.to_string())
.and_then(|bytes| files::download(&format!("{}.{}", d.name, solidcraft_engine::io::DESIGN_EXT), &bytes));
if let Err(e) = result {
+19 -9
View File
@@ -141,17 +141,28 @@ fn graphics_path() -> Option<std::path::PathBuf> {
/// Compatibility shim (docs/compatibility.md): on the first launch after the rename, copy the
/// settings of earlier releases into the new folder. They kept `graphics.json` (and, on Linux
/// and macOS, the recovery folder) under the old product name, and eframe's `app.ron` under the
/// window's app id. The old folders are never changed.
/// window's old app id. In the Flatpak sandbox, releases before 0.8 kept everything under the
/// old app id's `~/.var/app` folder; that copy comes first, as it is the newest. The old folders
/// are never changed.
fn adopt_legacy_settings() {
let (Some(current), Some(legacy)) = (settings_dir(), eframe::storage_dir("SolveCraft")) else { return };
let legacy_eframe = eframe::storage_dir(APP_ID);
if let Err(e) = adopt_settings(&current, &legacy, legacy_eframe.as_deref()) {
let legacy_eframe = eframe::storage_dir(solidcraft_engine::io::LEGACY_APP_ID);
let old_flatpak = solidcraft_engine::io::legacy_flatpak_path(&current);
if let Err(e) = adopt_settings(&current, old_flatpak.as_deref(), &legacy, legacy_eframe.as_deref()) {
log::warn!("settings: could not copy the previous release's settings into {}: {e}", current.display());
}
}
fn adopt_settings(current: &std::path::Path, legacy: &std::path::Path, legacy_eframe: Option<&std::path::Path>) -> std::io::Result<()> {
fn adopt_settings(
current: &std::path::Path,
old_flatpak: Option<&std::path::Path>,
legacy: &std::path::Path,
legacy_eframe: Option<&std::path::Path>,
) -> std::io::Result<()> {
let fresh = !current.exists();
if let Some(old) = old_flatpak {
solidcraft_engine::io::adopt_legacy_dir(current, old)?;
}
solidcraft_engine::io::adopt_legacy_dir(current, legacy)?;
if let Some(old) = legacy_eframe.map(|d| d.join("app.ron")).filter(|p| fresh && p.is_file()) {
std::fs::create_dir_all(current)?;
@@ -203,9 +214,8 @@ fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
.with_app_id(APP_ID))
}
/// The window's app id: it names the desktop entry, so it stays the packages' reverse-DNS id,
/// which is kept from before the rename (compatibility shim, docs/compatibility.md).
const APP_ID: &str = "ai.storyteller.solvecraft";
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
const APP_ID: &str = solidcraft_engine::io::APP_ID;
fn main() -> eframe::Result {
adopt_legacy_settings();
@@ -408,14 +418,14 @@ mod msaa_tests {
std::fs::create_dir_all(&legacy_eframe).unwrap();
std::fs::write(legacy.join("graphics.json"), r#"{"msaa":8}"#).unwrap();
std::fs::write(legacy_eframe.join("app.ron"), "(prefs)").unwrap();
super::adopt_settings(&current, &legacy, Some(&legacy_eframe)).unwrap();
super::adopt_settings(&current, None, &legacy, Some(&legacy_eframe)).unwrap();
assert_eq!(std::fs::read_to_string(current.join("graphics.json")).unwrap(), r#"{"msaa":8}"#);
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(prefs)");
assert!(legacy.join("graphics.json").is_file() && legacy_eframe.join("app.ron").is_file());
// Later launches leave the current settings alone.
std::fs::write(current.join("app.ron"), "(mine)").unwrap();
std::fs::remove_file(current.join("graphics.json")).unwrap();
super::adopt_settings(&current, &legacy, Some(&legacy_eframe)).unwrap();
super::adopt_settings(&current, None, &legacy, Some(&legacy_eframe)).unwrap();
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(mine)");
assert!(!current.join("graphics.json").exists());
let _ = std::fs::remove_dir_all(&d);
+2 -2
View File
@@ -147,7 +147,7 @@ impl Document {
.get(name)
.cloned()
.or_else(|| self.appearances.components.get(&self.model_body_component(body)).cloned())
.or_else(|| self.materials.get(name).and_then(|material| material_look(material)))
.or_else(|| self.materials.get(name).and_then(|material| self.material_look(material)))
}
/// Effective body opacity, including paint carried from an imported body.
pub fn model_body_opacity(&self, body: &crate::ModelBody) -> f64 {
@@ -168,7 +168,7 @@ impl Document {
let c = self.body_component(name, feature);
(c != 0).then(|| self.appearances.components.get(&c).cloned()).flatten()
})
.or_else(|| self.materials.get(name).and_then(|m| material_look(m)))
.or_else(|| self.materials.get(name).and_then(|m| self.material_look(m)))
}
/// Face appearances on a body: (point on the face, look).
+9 -12
View File
@@ -91,17 +91,14 @@ pub fn appearance_cell(value: &str) -> Result<Option<crate::appearance::Look>> {
Ok(Some(look))
}
/// Parse a physical material cell, returning None for the default material.
pub fn material_cell(value: &str) -> Result<Option<String>> {
let name = crate::MATERIALS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(value.trim()))
.map(|(n, _)| *n)
.ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
Ok((name != "Default").then(|| name.to_string()))
}
impl Document {
/// Parse a physical material cell (a built-in material or one defined in this design),
/// returning None for the default material.
pub fn material_cell(&self, value: &str) -> Result<Option<String>> {
let name = self.material_name(value).ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
Ok((name != "Default").then_some(name))
}
/// Freeze design-unit-sensitive configuration literals when changing display units.
/// Explicit units and nonliteral expressions retain their source text.
pub fn preserve_configuration_units(&mut self, previous_unit: &str, kept_parameters: &[String]) {
@@ -199,7 +196,7 @@ impl Document {
let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?;
f.suppressed = on;
}
Column::Material(body) => match material_cell(v)? {
Column::Material(body) => match self.material_cell(v)? {
Some(material) => {
next.materials.insert(body.clone(), material);
}
@@ -274,7 +271,7 @@ impl Document {
self.config_current(c)
.is_some_and(|current| current.chars().filter(|c| !c.is_whitespace()).eq(expected.chars().filter(|c| !c.is_whitespace())))
}
Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
Column::Material(body) => self.material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()),
Column::ComponentAppearance(id) => appearance_cell(v).is_ok_and(|look| self.appearances.components.get(id) == look.as_ref()),
Column::FaceAppearance(target) => appearance_cell(v).is_ok_and(|look| self.config_face_look(target) == look),
+161 -2
View File
@@ -179,6 +179,13 @@ pub enum FaceCertificate {
source: Box<FaceCertificate>,
inner: bool,
},
/// A flat face known by its exact history name (the target's `name`, without piece
/// numbers): what the kernel's face history says it came from, followed through every
/// feature since (a cut wall `F5:side:l1`, a chamfer `F7:blend:…`, a pattern copy
/// `F4:[email protected]`, a face a join merged from two features). Used where no construction
/// certificate above names the face uniquely. Its plane is the face's exact current plane in
/// the frame [`lineage_frame`] gives it. See docs/face-attachments.md.
Lineage,
}
impl FaceCertificate {
@@ -210,8 +217,9 @@ impl FaceCertificate {
&& payload_sha256.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
Self::ShellPlanar { source_producer, source, .. } => {
*source_producer != 0 && !matches!(source.as_ref(), Self::ShellPlanar { .. }) && source.validate().is_ok()
*source_producer != 0 && !matches!(source.as_ref(), Self::ShellPlanar { .. } | Self::Lineage) && source.validate().is_ok()
}
Self::Lineage => true,
};
if valid { Ok(()) } else { Err(DocError::Invalid("invalid face source certificate".into())) }
}
@@ -258,13 +266,16 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()>
.and_then(serde_json::Value::as_str)
.is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
}
Some("lineage") => fields.len() == 1,
Some("shell_planar") => {
fields.len() == 4
&& fields.keys().all(|key| ["kind", "source_producer", "source", "inner"].contains(&key.as_str()))
&& nonzero("source_producer")
&& fields.get("inner").and_then(serde_json::Value::as_bool).is_some()
&& fields.get("source").is_some_and(|source| {
source.as_object().is_some_and(|source| source.get("kind").and_then(serde_json::Value::as_str) != Some("shell_planar"))
source
.as_object()
.is_some_and(|source| !matches!(source.get("kind").and_then(serde_json::Value::as_str), Some("shell_planar" | "lineage")))
&& validate_certificate_json(source).is_ok()
})
}
@@ -286,6 +297,7 @@ pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> R
FaceCertificate::ExtrudeCap { high, outer, holes, .. } => format!("cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes)),
FaceCertificate::Imported { payload_sha256, item, face } => format!("import:{payload_sha256}:{item}:{face}"),
FaceCertificate::ShellPlanar { .. } => return Err(DocError::Invalid("nested shell source certificates are unsupported".into())),
FaceCertificate::Lineage => return Err(DocError::Invalid("a shell's source needs a construction certificate".into())),
};
Ok(format!("shell:{}:{role}", if inner { "inner" } else { "outer" }))
}
@@ -551,6 +563,153 @@ impl Document {
}
}
/// The frame a [`FaceCertificate::Lineage`] face's plane is given, from the plane alone (never
/// from how a kernel parametrizes the surface, so it survives rebuilds and kernel changes): its
/// origin where the body's definition origin projects onto the plane, its x axis the first of
/// the definition's X, Y and Z axes that is not steep to the plane (`|cos| < 0.6`; one always
/// is), projected onto it. A face that moves along its normal keeps the frame where it was;
/// one that moves within its plane (a Move feature) keeps it too, as a sketch on a face does
/// in the definition's coordinates.
pub fn lineage_frame(body: &ModelBody, plane: &solidcraft_geom::Plane) -> Option<solidcraft_geom::Plane> {
use solidcraft_geom::Vec3;
let n = plane.normal();
let m = &body.source_transform;
let origin = crate::apply_point(m, Vec3::ZERO);
let axis = [Vec3::X, Vec3::Y, Vec3::Z].into_iter().filter_map(|a| crate::apply_vector(m, a).normalized()).find(|a| a.dot(n).abs() < 0.6)?;
let x = axis - n * axis.dot(n);
let origin = origin - n * (origin - plane.origin).dot(n);
solidcraft_geom::Plane::new(origin, x, n.cross(x))
}
/// Whether two exact planes are one oriented plane (pieces of one face).
fn same_oriented_plane(a: &solidcraft_geom::Plane, b: &solidcraft_geom::Plane, scale: f64) -> bool {
a.normal().dot(b.normal()) >= 1. - 1e-8 && (b.origin - a.origin).dot(a.normal()).abs() <= 1e-7 * (1. + scale)
}
impl Document {
/// The exact plane an anchor on face `index` of `body` is measured in: the construction
/// certificate's plane, or for a [`FaceCertificate::Lineage`] face its current exact plane in
/// the [`lineage_frame`].
pub fn anchor_face_plane(&self, body: &ModelBody, index: usize, certificate: Option<&FaceCertificate>) -> Option<solidcraft_geom::Plane> {
match certificate {
Some(FaceCertificate::Lineage) => body.body.planar_face(index).and_then(|plane| lineage_frame(body, &plane)),
_ => body.body.face_plane(index),
}
}
/// A target for flat face `index` of `body` by its exact history name
/// ([`FaceCertificate::Lineage`]), when that name follows exact history, names a feature of
/// the design and finds this face (with its other pieces, all on one oriented plane).
pub(crate) fn lineage_face_target(
&self,
body: &ModelBody,
index: usize,
point: solidcraft_geom::Vec3,
) -> Option<crate::config::ConfigFaceTarget> {
let names = crate::naming::names(body);
if !names.verified(index) {
return None;
}
let name = crate::naming::strip_piece(names.faces.get(index)?).to_string();
let origin = name.strip_prefix('F')?.split_once(':')?.0.parse::<u64>().ok()?;
let inverse = crate::mat_inverse(&body.source_transform)?;
let target = crate::config::ConfigFaceTarget {
body: body.definition_name().into(),
origin_feature: origin,
name,
point: crate::apply_point(&inverse, point),
certificate: Some(FaceCertificate::Lineage),
};
self.configuration_face_indices(body, &target).ok()?.contains(&index).then_some(target)
}
/// The faces of `body` an exact history name finds: those named `name` or a piece of it, else
/// (a later join merged it into another face) the face that answers to it. Every one must
/// follow exact history and lie on one oriented plane.
pub(crate) fn lineage_face_indices(&self, body: &ModelBody, name: &str) -> Result<Vec<usize>> {
let names = crate::naming::names(body);
let mut indices: Vec<usize> =
names.faces.iter().enumerate().filter(|(_, face)| crate::naming::strip_piece(face) == name).map(|(index, _)| index).collect();
if indices.is_empty() {
indices =
names.info.iter().enumerate().filter(|(_, info)| info.aliases.iter().any(|alias| alias == name)).map(|(index, _)| index).collect();
}
if indices.is_empty() {
return Err(DocError::Unknown(format!("face `{name}` on `{}` no longer exists", body.name)));
}
self.coplanar_pieces(body, &indices, |index| names.verified(index))?;
Ok(indices)
}
/// Check that `indices` are pieces of one flat face: each `exact` and planar, all on one
/// oriented plane, and not split on purpose (Split Face: its pieces are separate faces, so
/// a pick on one never stands for the whole; docs/face-attachments.md).
pub(crate) fn coplanar_pieces(&self, body: &ModelBody, indices: &[usize], exact: impl Fn(usize) -> bool) -> Result<()> {
let scale = body.body.size();
let mut first = None;
for index in indices {
if !exact(*index) {
return Err(DocError::Invalid("this face's history is not exact".into()));
}
let plane = body.body.planar_face(*index).ok_or_else(|| DocError::Invalid("attached face is not flat".into()))?;
match &first {
None => first = Some(plane),
Some(first) if same_oriented_plane(first, &plane, scale) => {}
Some(_) => return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into())),
}
}
if let (Some(plane), true) = (first, indices.len() > 1) {
let tolerance = 1e-6 * (1. + scale);
let on_plane = |line: &Vec<solidcraft_geom::Vec3>| {
!line.is_empty() && line.iter().all(|p| (*p - plane.origin).dot(plane.normal()).abs() <= tolerance)
};
if body.body.split_lines().is_some_and(|lines| lines.iter().any(on_plane)) {
return Err(DocError::Invalid(
"this face was split on purpose (Split Face): pick an exact face piece's own plane with fixed:true".into(),
));
}
}
Ok(())
}
/// Of several faces carrying one construction certificate of feature `origin`, the pieces of
/// that feature's own face: copies a pattern or mirror made carry their seed's certificate
/// but are named as copies (`…@F<pattern>.<instance>`, docs/face-attachments.md), and pieces
/// a later feature split the face into share its exact name and plane.
pub(crate) fn certified_pieces(&self, body: &ModelBody, indices: Vec<usize>, origin: u64) -> Result<Vec<usize>> {
if indices.len() < 2 {
return Ok(indices);
}
let names = crate::naming::names(body);
let own = format!("F{origin}:");
let stem = |index: usize| names.faces.get(index).map(|name| crate::naming::strip_piece(name));
let seed: Vec<usize> =
indices.into_iter().filter(|index| stem(*index).is_some_and(|name| name.starts_with(&own) && !name.contains('@'))).collect();
let ambiguous = || DocError::Unknown("attached planar face certificate is missing or ambiguous".into());
let first = seed.first().and_then(|index| stem(*index)).ok_or_else(ambiguous)?;
if seed.iter().any(|index| stem(*index) != Some(first)) {
return Err(ambiguous());
}
// The certificate is exact; the names only tell the seed from its copies here.
self.coplanar_pieces(body, &seed, |_| true).map_err(|_| ambiguous())?;
Ok(seed)
}
/// Of several faces carrying one certificate of feature `origin`, those that are not a
/// pattern's or mirror's copies (named `…@F<pattern>.<instance>`), when any is not.
pub(crate) fn without_copies(&self, body: &ModelBody, indices: Vec<usize>) -> Vec<usize> {
if indices.len() < 2 {
return indices;
}
let names = crate::naming::names(body);
let copy = |index: &usize| names.faces.get(*index).is_some_and(|name| crate::naming::strip_piece(name).contains('@'));
if indices.iter().all(copy) {
return indices;
}
indices.into_iter().filter(|index| !copy(index)).collect()
}
}
pub(crate) fn payload_digest(payload: &str) -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(payload.as_bytes()))
+3
View File
@@ -253,6 +253,7 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
before,
depth: 1,
mats: vec![],
sheets: vec![],
})));
let captured = crate::naming::face_name_at_body(&old_body, Vec3::new(20., 6., 1.)).unwrap();
assert_eq!(crate::naming::parent_piece(&captured), Some(stem.as_str()), "actual strip capture records a piece name");
@@ -376,6 +377,7 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
before,
depth: 1,
mats: vec![],
sheets: vec![],
})));
let t = std::f64::consts::FRAC_1_SQRT_2;
let at = Vec3::new(34. + 6. * t, 24. + 6. * t, 1.);
@@ -515,6 +517,7 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
before,
depth: 1,
mats: vec![],
sheets: vec![],
})));
let at = Vec3::new(0., 15., 1.);
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
+8 -4
View File
@@ -145,6 +145,7 @@ impl Document {
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Circle { .. } | solidcraft_sketch::CurveKind::Arc { .. }))))
}
(super::FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => role.strip_prefix("face:").is_some_and(|index| index.parse::<usize>().is_ok()),
(super::FaceCertificate::Lineage, _) => !role.is_empty(),
(super::FaceCertificate::ShellPlanar { source_producer, source, inner }, FeatureKind::Shell { .. }) => {
*source_producer != target.origin_feature
&& self.flat_certificate_producer_matches(*source_producer, source)
@@ -185,6 +186,9 @@ impl Document {
if body.body.is_mesh() || body.definition_name() != target.body {
return Err(DocError::Invalid("configured face definition does not match".into()));
}
if let Some(super::FaceCertificate::Lineage) = &target.certificate {
return self.lineage_face_indices(body, &target.name);
}
if let Some(super::FaceCertificate::ShellPlanar { source_producer, source, inner }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, current)| {
matches!(current, Some(solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), source: current_source, inner: current_inner, .. }) if *producer == target.origin_feature && inner == current_inner && super::flat_source_matches(source, current_source, *source_producer)).then_some(index)
@@ -238,10 +242,10 @@ impl Document {
matched.then_some(index)
})
.collect();
if indices.len() != 1 {
if indices.is_empty() {
return Err(DocError::Unknown("attached planar face certificate is missing or ambiguous".into()));
}
return Ok(indices);
return self.certified_pieces(body, indices, target.origin_feature);
}
if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
@@ -250,7 +254,7 @@ impl Document {
if indices.is_empty() {
return Err(DocError::Unknown("constructed side certificate no longer exists".into()));
}
return Ok(indices);
return Ok(self.without_copies(body, indices));
}
if let Some(super::FaceCertificate::Imported { payload_sha256, item, face }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
@@ -259,7 +263,7 @@ impl Document {
if indices.is_empty() {
return Err(DocError::Unknown("immutable import face certificate no longer exists".into()));
}
return Ok(indices);
return Ok(self.without_copies(body, indices));
}
let names = crate::naming::face_names(body);
let indices: Vec<usize> =
+126 -3
View File
@@ -942,6 +942,10 @@ pub enum FeatureKind {
rotate_axis: Option<Vec3>,
#[serde(default, skip_serializing_if = "Option::is_none")]
angle: Option<String>,
/// When set, the original bodies are kept and the transform is applied to a new copy
/// of each instead of moving them in place.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
copy: bool,
},
/// A non-parametric base feature: bodies imported from a STEP file. The STEP text is kept
/// in the design (it is the B-rep), so the design reopens without the original file.
@@ -1694,6 +1698,18 @@ pub struct StandardPart {
pub length: Option<f64>,
}
/// A component whose contents come from another design file (Insert Design): where that file
/// is, and the Import feature in the component that holds its bodies (Update Links replaces
/// them; Break Link keeps them and forgets the file).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DesignLink {
/// The design file: relative to this design's folder once this design is saved, else as
/// given (`/` separates folders).
pub path: String,
/// The Import feature holding the linked design's bodies.
pub feature: u64,
}
/// The document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
@@ -1727,6 +1743,9 @@ pub struct Document {
/// Components that are standard parts (by component id): what to rebuild them from.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub parts: std::collections::BTreeMap<u64, StandardPart>,
/// Components linked to another design file (by component id).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub links: std::collections::BTreeMap<u64, DesignLink>,
/// Configurations (rows of parameter values and suppressions).
#[serde(default, skip_serializing_if = "crate::config::ConfigTable::is_empty")]
pub configs: crate::config::ConfigTable,
@@ -1742,6 +1761,9 @@ pub struct Document {
/// Physical material per body (by body name); others use the default.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub materials: std::collections::BTreeMap<String, String>,
/// Physical materials defined in this design (`material.create`), next to the built-in ones.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub user_materials: Vec<UserMaterial>,
/// Appearance colour per body (by body name, sRGB); it overrides the material's look.
#[serde(default, skip_serializing_if = "crate::appearance::Appearances::is_empty")]
pub appearances: crate::appearance::Appearances,
@@ -1761,6 +1783,38 @@ pub struct Document {
/// Named views: saved cameras.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub named_views: Vec<NamedView>,
/// What was hidden when the design was saved. A session takes it from here when the design
/// opens and keeps the live state itself (`browser.visibility`: saved, not an undo step), so
/// a document in a session leaves this empty; the session puts it back when it saves.
#[serde(default, skip_serializing_if = "Visibility::is_empty")]
pub visibility: Visibility,
}
/// Items hidden or shown one by one. Saved with the design, but not an undo step.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Visibility {
/// Bodies by name.
pub hidden_bodies: Vec<String>,
/// Exact occurrence placements hidden with all descendants.
pub hidden_occurrences: Vec<Vec<u64>>,
/// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction geometry, by name.
pub hidden_origin: Vec<String>,
/// Sketches by feature id.
pub hidden_sketches: Vec<u64>,
/// Finished sketches shown although a feature uses them.
pub shown_sketches: Vec<u64>,
}
impl Visibility {
/// Nothing hidden or shown one by one (the default view).
pub fn is_empty(&self) -> bool {
self.hidden_bodies.is_empty()
&& self.hidden_occurrences.is_empty()
&& self.hidden_origin.is_empty()
&& self.hidden_sketches.is_empty()
&& self.shown_sketches.is_empty()
}
}
/// A saved camera (orbit camera: target, yaw, pitch, distance; `fov` 0 for orthographic).
@@ -1819,9 +1873,11 @@ impl Document {
plastic: Default::default(),
configs: Default::default(),
parts: Default::default(),
links: Default::default(),
body_components: Default::default(),
body_offsets: Default::default(),
materials: Default::default(),
user_materials: Vec::new(),
appearances: Default::default(),
favorites: Default::default(),
canvases: Vec::new(),
@@ -1829,6 +1885,7 @@ impl Document {
browser_groups: Vec::new(),
browser_order: Default::default(),
named_views: Vec::new(),
visibility: Visibility::default(),
}
}
@@ -1861,7 +1918,46 @@ impl Document {
/// Density of a body's material (g/cm³).
pub fn density(&self, body: &str) -> f64 {
self.materials.get(body).and_then(|m| material_density(m)).unwrap_or(DEFAULT_DENSITY)
self.materials.get(body).and_then(|m| self.material_density(m)).unwrap_or(DEFAULT_DENSITY)
}
/// A material defined in this design, by name (case-insensitive).
pub fn user_material(&self, name: &str) -> Option<&UserMaterial> {
let name = name.trim();
self.user_materials.iter().find(|m| m.name.eq_ignore_ascii_case(name))
}
/// A physical material's name as the library or this design spells it (case-insensitive
/// lookup): built-in materials first, then the design's own.
pub fn material_name(&self, name: &str) -> Option<String> {
MATERIALS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(name.trim()))
.map(|(n, _)| n.to_string())
.or_else(|| self.user_material(name).map(|m| m.name.clone()))
}
/// Density of a physical material (g/cm³): built-in or defined in this design. A design
/// material with an unusable density (a damaged file) has none.
pub fn material_density(&self, name: &str) -> Option<f64> {
material_density(name).or_else(|| self.user_material(name).map(|m| m.density).filter(|d| valid_density(*d)))
}
/// The look a physical material gives a body without an appearance of its own.
pub fn material_look(&self, name: &str) -> Option<crate::appearance::Look> {
crate::appearance::material_look(name).or_else(|| {
let mut look = self.user_material(name)?.appearance.clone()?;
// A damaged file's opacity is shown opaque rather than trusted.
if !(look.opacity.is_finite() && (0.0..=1.0).contains(&look.opacity)) {
look.opacity = 1.0;
}
Some(look)
})
}
/// Every physical material's name: the built-in library, then the design's own.
pub fn material_names(&self) -> Vec<String> {
MATERIALS.iter().map(|(n, _)| n.to_string()).chain(self.user_materials.iter().map(|m| m.name.clone())).collect()
}
/// The component a body belongs to: where it was moved, else its feature's.
@@ -2281,18 +2377,25 @@ impl Document {
/// the sketch over is refused. Offsets whose sources moved are refreshed and solved again:
/// from the stored state again after a refused or failed first solve (the geometry is still
/// the stored one), from the solved one otherwise.
///
/// `moved_from`: the stored sketch, when `sk` is that sketch with its links refreshed. A
/// link's points are where their source is now; the sides and turns the solve keeps are
/// those of the stored state ([`Sketch::positions_in`]), not the ones read beside a line
/// that has moved past a point.
pub fn solve_sketch(
&self,
vals: &std::collections::BTreeMap<String, expr::Value>,
sk: &mut Sketch,
stored: &solidcraft_sketch::DrivingValues,
moved_from: Option<&Sketch>,
) -> Result<solidcraft_sketch::SolveReport> {
self.apply_dimension_values(vals, sk)?;
let mut report = solidcraft_sketch::solve_after_value_edit(sk, stored);
let was = |sk: &Sketch| moved_from.and_then(|from| sk.positions_in(from));
let mut report = solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref());
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
// Source dimensions may have moved curves during the solve.
sk.refresh_offsets()?;
report = if report.ok() { solidcraft_sketch::solve(sk) } else { solidcraft_sketch::solve_after_value_edit(sk, stored) };
report = if report.ok() { solidcraft_sketch::solve(sk) } else { solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref()) };
}
Ok(report)
}
@@ -2339,6 +2442,26 @@ pub fn material_color(name: &str) -> Option<[u8; 3]> {
})
}
/// A physical material defined in a design (`material.create`): its name, its density and,
/// optionally, the look it gives bodies without an appearance of their own.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct UserMaterial {
pub name: String,
/// Density (g/cm³).
pub density: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub appearance: Option<crate::appearance::Look>,
}
/// Largest density a material may have (g/cm³); the densest elements are about 22.6.
pub const MAX_DENSITY: f64 = 1000.0;
/// A density a material can have: finite, above zero and at most [`MAX_DENSITY`] g/cm³.
pub fn valid_density(d: f64) -> bool {
d.is_finite() && d > 0.0 && d <= MAX_DENSITY
}
/// Density of a built-in physical material (g/cm³).
pub fn material_density(name: &str) -> Option<f64> {
MATERIALS.iter().find(|(n, _)| n.eq_ignore_ascii_case(name.trim())).map(|(_, d)| *d)
}
+208 -63
View File
@@ -62,12 +62,14 @@ impl ModelBody {
pub fn definition_name(&self) -> &str {
self.source_name.as_deref().unwrap_or(&self.name)
}
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
/// Display mesh, computed once: chord tolerance 1/1000 of the body size, and no facet of a
/// curved face or edge turning more than [`kernel::DISPLAY_ANGLE`], so that a bend or a
/// hole that is small beside the body is still drawn round.
pub fn mesh(&self) -> Arc<Mesh> {
self.mesh
.get_or_init(|| {
let tol = (self.body.size() * 1e-3).max(1e-3);
Arc::new(self.body.display_mesh(tol).unwrap_or_default())
Arc::new(self.body.display_mesh_angular(tol, kernel::DISPLAY_ANGLE).unwrap_or_default())
})
.clone()
}
@@ -262,23 +264,38 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
}
/// A constant fillet of `edges`; with [`FilletFit::Reduce`](crate::FilletFit::Reduce), a radius too
/// large for the geometry around them is reduced to the largest that works for all of them, which
/// is returned.
/// large for the geometry around them is reduced where it doesn't fit, to the largest that works
/// for each group of edges sharing a face; the radius per edge is returned.
fn fillet_fitted(
body: &kernel::Body,
edges: &[Vec3],
r: f64,
fit: crate::FilletFit,
) -> std::result::Result<(kernel::Body, Option<f64>), kernel::KernelError> {
) -> std::result::Result<(kernel::Body, Option<Vec<f64>>), kernel::KernelError> {
match kernel::fillet(body, edges, r) {
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max(body, edges, r) {
Some((used, b)) => Ok((b, Some(used))),
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max_each(body, edges, r) {
// Built at the radius asked for after all (the groups apart): nothing reduced.
Some((used, b)) => Ok((b, used.iter().any(|u| *u < r).then_some(used))),
None => Err(kernel::KernelError::TooLarge(why)),
},
other => other.map(|b| (b, None)),
}
}
/// The warning of a fillet whose radius `asked` was reduced to `used` (per edge).
fn reduced_note(used: &[f64], asked: f64) -> String {
let mut values: Vec<f64> = used.iter().copied().filter(|r| *r < asked).collect();
values.sort_by(f64::total_cmp);
values.dedup();
let shown = values.iter().map(|v| show_mm(*v)).collect::<Vec<_>>().join(", ");
let n = used.iter().filter(|r| **r < asked).count();
if n == used.len() && values.len() == 1 {
format!("the radius is reduced to {shown} mm, the largest that fits these edges")
} else {
format!("the radius is reduced to {shown} mm on {n} of {} edges, the largest that fits them", used.len())
}
}
/// Add `note` to a feature's warning.
fn append_warning(warning: &mut Option<String>, note: String) {
*warning = Some(match warning.take() {
@@ -294,9 +311,23 @@ fn show_mm(v: f64) -> String {
}
impl ModelState {
/// The radius fillet `feature` used when it reduced a radius too large for its edges.
/// The smallest radius fillet `feature` used when it reduced a radius too large for its edges.
pub fn fitted_radius(&self, feature: u64) -> Option<f64> {
self.fitted.get(&feature).copied()
self.fitted_range(feature).map(|(lo, _)| lo)
}
/// The smallest and largest radius fillet `feature` used when it reduced a radius too large
/// for some of its edges.
pub fn fitted_range(&self, feature: u64) -> Option<(f64, f64)> {
self.fitted.get(&feature)?.iter().fold(None, |acc, r| Some(acc.map_or((*r, *r), |(lo, hi): (f64, f64)| (lo.min(*r), hi.max(*r)))))
}
/// Each edge fillet `feature` blended (a point on it) with the radius it used, when it
/// reduced a radius too large for some of its edges.
pub fn fitted_edges(&self, feature: u64) -> Option<Vec<(Vec3, f64)>> {
let radii = self.fitted.get(&feature)?;
let edges = self.blend_edges.get(&feature)?;
Some(edges.iter().copied().zip(radii.iter().copied()).collect())
}
}
@@ -319,9 +350,9 @@ pub struct ModelState {
/// a pattern of the feature repeats these, not the points picked when it was made, which a
/// parameter change may have moved off the edges.
blend_edges: BTreeMap<u64, Vec<Vec3>>,
/// The radius a fillet used when it reduced a radius too large for its edges (by feature id,
/// see `FilletFit::Reduce`).
fitted: BTreeMap<u64, f64>,
/// The radius per edge a fillet used when it reduced a radius too large for some of its edges
/// (by feature id, see `FilletFit::Reduce`).
fitted: BTreeMap<u64, Vec<f64>>,
}
impl ModelState {
@@ -347,9 +378,11 @@ impl ModelState {
[p.x, p.y, p.z].map(f64::to_bits).hash(&mut h);
}
}
for (feature, r) in &self.fitted {
for (feature, radii) in &self.fitted {
feature.hash(&mut h);
r.to_bits().hash(&mut h);
for r in radii {
r.to_bits().hash(&mut h);
}
}
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
@@ -362,7 +395,8 @@ impl ModelState {
fn stamp(&mut self, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
let feature = f.id;
let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1;
let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats });
let sheets = if f.kind.is_sheet() { self.sheets.clone() } else { Vec::new() };
let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats, sheets });
// Pieces a cut left are named after their faces, not the kernel's shell order (unless
// the feature was saved when they were named in that order).
if !f.shell_piece_order {
@@ -854,8 +888,11 @@ impl Model {
let mut restored = 0;
let marker = doc.marker.unwrap_or(usize::MAX);
let ctx = context_digest(doc, &vals);
let watched = crate::progress::begin(doc.features.len());
for (i, f) in doc.features.iter().enumerate() {
let rolled_back = i >= marker;
// Opening was cancelled: the rest is skipped and the result discarded.
let cancelled = watched.step(i);
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back);
prev_fp = fp;
if reuse
@@ -871,7 +908,7 @@ impl Model {
continue;
}
reuse = false;
if f.suppressed || rolled_back {
if f.suppressed || rolled_back || cancelled {
out.push(FeatureResult {
id: f.id,
name: f.name.clone(),
@@ -924,7 +961,14 @@ impl Model {
let f = if !explicit_emboss && f.face_names.iter().any(|n| !n.is_empty()) {
let mut g = f.clone();
let mut notes = Vec::new();
for (p, n) in g.kind.face_points_mut().into_iter().zip(&f.face_names) {
// A hole's position is a place on its face, not a pick of the face: it follows
// the face along the hole's axis and never slides sideways to the face's nearest
// point when the face no longer reaches it.
let hole_axis = match &f.kind {
FeatureKind::Hole { direction, points: None, .. } => direction.normalized(),
_ => None,
};
for (k, (p, n)) in g.kind.face_points_mut().into_iter().zip(&f.face_names).enumerate() {
if n.is_empty() {
continue;
}
@@ -933,7 +977,18 @@ impl Model {
if let Some(note) = hit.note(n) {
notes.push(note);
}
*p = hit.point;
match (k, hole_axis) {
(0, Some(axis)) => match hit.along(&state, *p, axis) {
Some(q) => *p = q,
// The place still lies on the face (the axis may run along it).
None if hit.on_point => {}
None => notes.push(format!(
"the hole's position [{}, {}, {}] is no longer on face `{n}`: it stays where it was placed",
p.x, p.y, p.z
)),
},
_ => *p = hit.point,
}
}
None => notes.push(format!("face `{n}` no longer exists; using the face at the picked point")),
}
@@ -1045,10 +1100,12 @@ pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region
}
let out: Vec<Region2> = match sel {
// "All" means what was drawn: projected reference geometry (a face's auto-projected
// edges) only counts when the drawn curves alone make no profile.
// edges) only counts when the drawn curves alone make no profile. Text is drawn, and
// its letters keep their counters as holes (the counters are not filled in).
ProfileSel::All => {
let drawn = solidcraft_sketch::find_drawn_profiles(&ss.sketch);
if drawn.is_empty() { ps.iter().map(|p| p.region.clone()).collect() } else { drawn.into_iter().map(|p| p.region).collect() }
let chosen = if drawn.is_empty() { ps.clone() } else { drawn };
solidcraft_sketch::without_text_counters(&ss.sketch, chosen).into_iter().map(|p| p.region).collect()
}
ProfileSel::Indices { indices } => {
let mut v = Vec::new();
@@ -1121,6 +1178,25 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String {
}
}
/// The k-th body of a body pattern: its stored name, else a copy of `source`. Once `body.rename`
/// has stored this feature's names, a copy beyond them (the count was raised) is numbered after
/// the highest stored `source (n)`, so it never takes a number freed by a rename (#372).
/// Features without stored names keep the first free `source (n)`.
fn pattern_copy_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> String {
if f.body_names.get(k).is_some_and(|name| !name.trim().is_empty()) {
return copy_body_name(state, f, k, source);
}
let base = if source.trim().is_empty() { "Body" } else { source.trim() };
let prefix = format!("{base} (");
let highest = f.body_names.iter().filter_map(|n| n.trim().strip_prefix(prefix.as_str())?.strip_suffix(')')?.parse::<u32>().ok()).max();
match highest {
Some(h) => {
(u64::from(h) + 1..).map(|i| format!("{base} ({i})")).find(|n| state.body(n).is_none()).unwrap_or_else(|| unique_body_name(state, base))
}
None => unique_body_name(state, base),
}
}
fn copy_body_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> String {
let base = f.body_names.get(k).filter(|name| !name.trim().is_empty()).map(String::as_str).unwrap_or(source);
unique_body_name(state, base)
@@ -1176,6 +1252,25 @@ fn intersection_tool_union(tools: Vec<Traced>) -> Result<Vec<Traced>> {
Ok(groups)
}
/// After a cut handled some sheet bodies as flat machining, apply it to the other targets.
fn apply_beside_sheet_cut(st: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Operation, targets: &[String], handled: &[String]) -> Result<()> {
let effective = if targets.is_empty() { &f.participants } else { targets };
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
let rest: Vec<String> = st
.bodies
.iter()
.filter(|b| {
!handled.contains(&b.name)
&& (all || if effective.is_empty() { b.component == f.component || b.feature == f.id } else { effective.contains(&b.name) })
})
.map(|b| b.name.clone())
.collect();
if rest.is_empty() {
return Ok(());
}
apply_traced(st, f, tools, op, &rest)
}
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
apply_traced(state, f, tools.into_iter().map(Traced::unnamed).collect(), op, targets)
}
@@ -1305,7 +1400,9 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
});
let live: std::collections::BTreeSet<&str> = state.bodies.iter().map(|b| b.name.as_str()).collect();
state.threads.retain(|t| live.contains(t.body.as_str()));
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)").
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)"),
// numbered on past names already taken (a body cut in pieces before).
let mut taken: std::collections::BTreeSet<String> = state.bodies.iter().map(|b| b.name.clone()).collect();
let mut split: Vec<ModelBody> = Vec::new();
for mb in std::mem::take(&mut state.bodies) {
if !affected.contains(&mb.name) || state.sheets.iter().any(|s| s.body == mb.name) {
@@ -1321,7 +1418,12 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
// (so pieces of one face in different bodies are told apart).
let lineages = crate::lineage::Lineage::lumps(&mb.body, mb.lineage().as_deref(), &pieces);
for (k, (p, lineage)) in pieces.into_iter().zip(lineages).enumerate() {
let name = if k == 0 { mb.name.clone() } else { format!("{} ({k})", mb.name) };
let name = if k == 0 {
mb.name.clone()
} else {
(k..=k + taken.len()).map(|j| format!("{} ({j})", mb.name)).find(|n| !taken.contains(n)).unwrap_or_default()
};
taken.insert(name.clone());
split.push(ModelBody::new(name, p, mb.feature).with_lineage(lineage));
}
}
@@ -1446,7 +1548,10 @@ fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, target
/// Booleans struggle with coincident faces (a cut starting on the face it cuts, a boss
/// starting on the face it joins). Extending the tool past such an end changes nothing when the
/// space beyond is empty (cut) or already material (join), so do exactly that.
/// space beyond is empty (cut) or already material (join), so do exactly that. Only an end
/// that lies on a target face parallel to it moves, and by less than half the way to the next
/// such face level: the samples can miss material beyond the end (a thin floor between a recess
/// above and a ring pocket below), which a longer tool would cut through.
fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi: f64, op: Operation, targets: &[String]) -> (f64, f64) {
if !matches!(op, Operation::Cut | Operation::Join) || st.bodies.is_empty() {
return (lo, hi);
@@ -1461,18 +1566,40 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi:
let samples = region_samples(r);
let n = plane.normal();
let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p));
let ok_beyond = |h: f64, dir: f64| {
// Heights of the targets' faces parallel to the profile plane.
let tol = (size * 1e-7).max(1e-9);
let levels: Vec<f64> = meshes
.iter()
.flat_map(|m| {
m.triangles.iter().filter_map(|t| {
let [a, b, c] = m.tri(t)?;
let normal = (b - a).cross(c - a).normalized()?;
(normal.dot(n).abs() > 1.0 - 1e-9).then(|| (a - plane.origin).dot(n))
})
})
.collect();
// How far the end at `h` may move in `dir`: zero unless a face lies on it, then short of
// half the gap to the next face level beyond it.
let room = |h: f64, dir: f64| {
if !levels.iter().any(|z| (z - h).abs() < tol) {
return 0.0;
}
let gap = levels.iter().map(|z| (z - h) * dir).filter(|g| *g > tol).fold(f64::INFINITY, f64::min);
delta.min(gap * 0.5)
};
let ok_beyond = |h: f64, dir: f64, step: f64| {
samples.iter().all(|s| {
let base = plane.to_world(*s);
[0.5, 1.0].iter().all(|k| {
let p = base + n * (h + dir * delta * k);
let p = base + n * (h + dir * step * k);
let inside = inside_any(p);
if op == Operation::Cut { !inside } else { inside }
})
})
};
let lo2 = if ok_beyond(lo, -1.0) { lo - delta } else { lo };
let hi2 = if ok_beyond(hi, 1.0) { hi + delta } else { hi };
let (down, up) = (room(lo, -1.0), room(hi, 1.0));
let lo2 = if down > tol && ok_beyond(lo, -1.0, down) { lo - down } else { lo };
let hi2 = if up > tol && ok_beyond(hi, 1.0, up) { hi + up } else { hi };
(lo2, hi2)
}
@@ -2862,8 +2989,10 @@ fn replay(
let nb = match &src.kind {
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &moved, r, distance2, angle, *flip)?,
// A copy is congruent to the original: the radius it was reduced to first.
FeatureKind::Fillet { fit, .. } => match st.fitted.get(&src.id).copied() {
Some(used) => kernel::fillet(&mb.body, &moved, used).or_else(|_| fillet_fitted(&mb.body, &moved, r, *fit).map(|x| x.0))?,
FeatureKind::Fillet { fit, .. } => match st.fitted.get(&src.id) {
Some(used) => {
kernel::fillet_each(&mb.body, &moved, used).or_else(|_| fillet_fitted(&mb.body, &moved, r, *fit).map(|x| x.0))?
}
None => fillet_fitted(&mb.body, &moved, r, *fit)?.0,
},
_ => kernel::fillet(&mb.body, &moved, r)?,
@@ -3254,9 +3383,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
if let Some(w) = face_warning {
warns.insert(0, w);
}
// The stored sketch is solved for its stored values.
// The stored sketch is solved for its stored values, with its links where they
// were stored: a link that moved must not change the side a dimension from it is on.
let stored = sk.driving_values();
let report = doc.solve_sketch(vals, &mut sk, &stored)?;
let report = doc.solve_sketch(vals, &mut sk, &stored, Some(sketch))?;
if !report.keeps_orientation() {
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
}
@@ -3282,26 +3412,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref())?;
if !handled.is_empty() {
let effective = if targets.is_empty() { &f.participants } else { targets };
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
let rest: Vec<String> = st
.bodies
.iter()
.filter(|b| {
!handled.contains(&b.name)
&& (all
|| if effective.is_empty() {
b.component == f.component || b.feature == f.id
} else {
effective.contains(&b.name)
})
})
.map(|b| b.name.clone())
.collect();
if rest.is_empty() {
return Ok(());
}
return apply_traced(st, f, tools, *operation, &rest);
return apply_beside_sheet_cut(st, f, tools, *operation, targets, &handled);
}
}
apply_traced(st, f, tools, *operation, targets)
@@ -3405,13 +3516,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let fst = crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?;
doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?
}
None => doc.resolve_plane_in(vals, plane, 0, Some(st))?,
// Picks on bodies of other (moved) components are where those bodies are shown.
None => doc.resolve_plane_in(vals, plane, 0, Some(&construction_state(doc, st, f.component)))?,
};
st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Plane(pl) });
Ok(())
}
FeatureKind::ConstructionAxis { def } => {
let (origin, dir) = crate::construct::resolve_axis(&crate::construct::Ctx { doc, vals, st, depth: 0 }, def)?;
let (origin, dir) =
crate::construct::resolve_axis(&crate::construct::Ctx { doc, vals, st: &construction_state(doc, st, f.component), depth: 0 }, def)?;
st.construct.push(crate::construct::Construct {
feature: f.id,
name: f.name.clone(),
@@ -3420,7 +3533,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
Ok(())
}
FeatureKind::ConstructionPoint { def } => {
let p = crate::construct::resolve_point(&crate::construct::Ctx { doc, vals, st, depth: 0 }, def)?;
let p =
crate::construct::resolve_point(&crate::construct::Ctx { doc, vals, st: &construction_state(doc, st, f.component), depth: 0 }, def)?;
st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Point(p) });
Ok(())
}
@@ -3541,7 +3655,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let mb = st.body(n).cloned().ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
for (k, m) in mats.iter().enumerate() {
let copy = moved_copy(&mb, kernel::transform_matrix(&mb.body, *m)?, k + 1);
let name = copy_body_name(st, f, output, &mb.name);
let name = pattern_copy_name(st, f, output, &mb.name);
st.bodies.push(ModelBody::traced_new(name, copy, f.id));
output += 1;
}
@@ -3653,8 +3767,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
FeatureKind::Fillet { fit, .. } => {
let (b, used) = fillet_fitted(&mb.body, &edges, r, *fit)?;
if let Some(used) = used {
append_warning(warning, reduced_note(&used, r));
st.fitted.insert(f.id, used);
append_warning(warning, format!("the radius is reduced to {} mm, the largest that fits these edges", show_mm(used)));
}
(b, input)
}
@@ -3675,12 +3789,24 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let corner = point(vals, corner)?;
let b = kernel::box_solid(corner, corner + s)?;
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b };
apply_traced(st, f, vec![crate::lineage::box_tool(b)], *operation, &[])
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation)?;
let tools = vec![crate::lineage::box_tool(b)];
if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
}
apply_traced(st, f, tools, *operation, &[])
}
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
let b = kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
let (base, radius, height) = (point(vals, base)?, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?);
let b = kernel::cylinder(base, *axis, radius, height)?;
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_cylinder_certificate(f.id)? } else { b };
apply_traced(st, f, vec![crate::lineage::cylinder_tool(b)], *operation, &[])
let prism = more::PrimitivePrism::Cylinder { base, axis: *axis, radius, height };
let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation)?;
let tools = vec![crate::lineage::cylinder_tool(b)];
if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
}
apply_traced(st, f, tools, *operation, &[])
}
FeatureKind::Sphere { center, radius, operation } => {
let b = kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?;
@@ -3779,7 +3905,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
st.bodies.push(ModelBody::traced_new(name, b, f.id));
Ok(())
}
FeatureKind::Move { bodies, translate, rotate_axis, angle } => {
FeatureKind::Move { bodies, translate, rotate_axis, angle, copy } => {
let t =
Vec3::new(val(vals, &translate[0], Kind::Length)?, val(vals, &translate[1], Kind::Length)?, val(vals, &translate[2], Kind::Length)?);
let ang = match angle {
@@ -3787,11 +3913,20 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
None => 0.0,
};
let axis = rotate_axis.unwrap_or(Vec3::Z);
// With `copy` set, the source body is kept in place and the transform lands on a new
// body pushed beside it instead of replacing it.
let mut output = 0;
for n in bodies {
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
if let Some(mb) = st.bodies.get_mut(i) {
let nb = Traced::step(kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?, &[&mb.traced()]);
*mb = ModelBody::traced_new(mb.name.clone(), nb, mb.feature);
let Some(mb) = st.bodies.get(i).cloned() else { continue };
let nb = kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?;
if *copy {
let traced = moved_copy(&mb, nb, 1);
let name = copy_body_name(st, f, output, &mb.name);
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
output += 1;
} else if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::traced_new(mb.name.clone(), Traced::step(nb, &[&mb.traced()]), mb.feature);
}
}
Ok(())
@@ -3868,6 +4003,16 @@ fn unique_body_name(st: &ModelState, base: &str) -> String {
/// it are moved into its frame (through both placements), so a sketch on another component's
/// face finds the face where it is shown and projects its edges there. The model itself when
/// every placement agrees (the usual case).
/// What construction geometry of component `comp` is built on: the model as that component sees
/// it, other components' bodies where they are placed. In the root, every placement of a
/// component (its pasted instances too) is there, as the viewport shows it.
fn construction_state<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
if comp == 0 && !doc.occurrences.is_empty() {
return std::borrow::Cow::Owned(world_state(doc, st));
}
state_in_frame(doc, st, comp)
}
pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
state_in_frame_at(doc, st, comp, doc.component_transform(comp))
}
+147 -31
View File
@@ -325,6 +325,21 @@ fn sheet_for(st: &ModelState, body: &Option<String>, pick: Option<Vec3>) -> Resu
.ok_or_else(|| DocError::Invalid("there is no sheet metal body".into()))
}
/// Whether a point touches sheet `i`'s material: inside it, on one of its faces, or within its
/// display tolerance of one (picks land on the display mesh). A sheet with no mesh to test
/// against counts as touched.
fn on_sheet_material(st: &ModelState, i: usize, p: Vec3) -> bool {
let Some(mb) = st.sheets.get(i).and_then(|sh| st.body(&sh.body)) else { return true };
let mesh = mb.mesh();
if mesh.triangles.is_empty() {
return true;
}
// Twice the display mesh's chord tolerance (see `ModelBody::mesh`).
let step = 2.0 * (mb.body.size() * 1e-3).max(1e-3);
let offsets = [-step, 0.0, step];
offsets.iter().any(|&dx| offsets.iter().any(|&dy| offsets.iter().any(|&dz| mesh.contains(p + Vec3::new(dx, dy, dz)))))
}
/// A region mirrored in the sketch x axis (y → −y); curves other than lines and arcs as polylines.
fn mirror_y(r: &Region2) -> Region2 {
let f = |p: Vec2| Vec2::new(p.x, -p.y);
@@ -500,12 +515,25 @@ pub(super) fn sheet_eval(
doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius
}
};
// A fixed point that touches no material at all (e.g. far above the sheet) still picks
// the side it lies on, so a stored fold keeps recomputing; it says so (#371).
if let Some(p) = fixed
&& !on_sheet_material(st, i, *p)
{
*warning = Some(format!(
"{} at ({:.3}, {:.3}, {:.3}); the side of the fold line it lies on stays",
sheet::FOLD_FIXED_OFF_SHEET,
p.x,
p.y,
p.z
));
}
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
if sh.flat {
return Err(DocError::Invalid("refold the sheet before folding it".into()));
}
let n0 = sh.flanges.len();
sh.add_fold(a, b, ang, rr, position, *flip, *fixed)?;
sh.add_fold(a, b, ang, rr, position, *flip, *fixed, warning)?;
tag_flanges(sh, n0, f.id);
rebuild_sheet(st, i)
}
@@ -640,24 +668,112 @@ pub(super) fn sheet_cut(
constructor: Option<&ExtrudeConstructor>,
) -> Result<Vec<String>> {
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
if extent.taper.is_some() {
return Ok(Vec::new());
}
let targets = if targets.is_empty() { &f.participants } else { targets };
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
let eligible = |original: &crate::sheet::SheetBody| {
all || if targets.is_empty() {
st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id))
} else {
targets.contains(&original.body)
}
};
if !st.sheets.iter().any(&eligible) {
if extent.taper.is_some() || !any_eligible_sheet(f, st, targets) {
return Ok(Vec::new());
}
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let n = ss.plane.normal();
sheet_prism_cut(f, st, &ss.plane, &regions, *operation, tools, targets, constructor, extent.to.is_some())
}
/// Whether a cut of `f` with these targets (else its participants, else its component's
/// bodies) machines the sheet `original`.
fn sheet_eligible(f: &Feature, st: &ModelState, targets: &[String], original: &crate::sheet::SheetBody) -> bool {
let targets = if targets.is_empty() { &f.participants } else { targets };
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
all || if targets.is_empty() {
st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id))
} else {
targets.contains(&original.body)
}
}
/// Whether a cut of `f` with these targets machines any sheet metal body.
fn any_eligible_sheet(f: &Feature, st: &ModelState, targets: &[String]) -> bool {
st.sheets.iter().any(|original| sheet_eligible(f, st, targets, original))
}
/// A Box or Cylinder cut (or intersect) whose tool is a straight prism along the normal of
/// the sheet it meets: machined like the equivalent extrude of its footprint, so the sheet
/// keeps it as a flat cut-out or pocket. Returns the sheet bodies it handled; none when the
/// primitive is not such a prism (the generic preflight then refuses real 3D machining).
pub(super) fn sheet_primitive_cut(f: &Feature, st: &mut ModelState, tool: &Body, prism: PrimitivePrism, operation: Operation) -> Result<Vec<String>> {
if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) {
return Ok(Vec::new());
}
// The first sheet the primitive meets decides the machining direction.
let mut normal = None;
for sh in st.sheets.iter().filter(|sh| sheet_eligible(f, st, &[], sh)) {
if let Some(mb) = st.body(&sh.body)
&& kernel::boolean(&mb.body, tool, kernel::BoolOp::Intersect)?.is_some()
{
normal = Some(sh.frame.normal());
break;
}
}
let Some(normal) = normal else { return Ok(Vec::new()) };
let Some((plane, region, interval)) = prism.along(normal) else { return Ok(Vec::new()) };
let tools = kernel::extrude(&plane, std::slice::from_ref(&region), interval.0, interval.1)?;
sheet_prism_cut(f, st, &plane, std::slice::from_ref(&region), operation, &tools, &[], None, false)
}
/// A primitive tool's exact geometry, for [`sheet_primitive_cut`].
pub(super) enum PrimitivePrism {
/// An axis-aligned box between two corners.
Box { lo: Vec3, hi: Vec3 },
/// A cylinder: base centre, unit axis, radius and signed height.
Cylinder { base: Vec3, axis: Vec3, radius: f64, height: f64 },
}
impl PrimitivePrism {
/// The sketch-like plane, footprint region and depth interval of this tool as a prism along
/// `normal` (either sense), or None when it is not one.
fn along(&self, normal: Vec3) -> Option<(Plane, Region2, (f64, f64))> {
let n = normal.normalized()?;
match *self {
PrimitivePrism::Box { lo, hi } => {
let (lo, hi) = (lo.min(hi), lo.max(hi));
let s = hi - lo;
// Plane axes (x, y) and the depth axis, as a right-handed frame.
let (x, y, depth) = if n.cross(Vec3::Z).len() <= 1e-12 {
(Vec3::X, Vec3::Y, s.z)
} else if n.cross(Vec3::X).len() <= 1e-12 {
(Vec3::Y, Vec3::Z, s.x)
} else if n.cross(Vec3::Y).len() <= 1e-12 {
(Vec3::Z, Vec3::X, s.y)
} else {
return None;
};
let plane = Plane::new(lo, x, y)?;
let (w, h) = (s.dot(x), s.dot(y));
let outer = Loop2::polygon(&[Vec2::ZERO, Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)]);
Some((plane, Region2 { outer, holes: vec![] }, (0.0, depth)))
}
PrimitivePrism::Cylinder { base, axis, radius, height } => {
let axis = axis.normalized()?;
if axis.cross(n).len() > 1e-12 {
return None;
}
let plane = Plane::from_normal(base, axis)?;
Some((plane, Region2 { outer: Loop2::circle(Vec2::ZERO, radius), holes: vec![] }, (height.min(0.0), height.max(0.0))))
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn sheet_prism_cut(
f: &Feature,
st: &mut ModelState,
plane: &Plane,
regions: &[Region2],
operation: Operation,
tools: &[Body],
targets: &[String],
constructor: Option<&ExtrudeConstructor>,
footprints_bound: bool,
) -> Result<Vec<String>> {
let n = plane.normal();
let mut work = sheet::FlatWork::new();
let faces = tools.iter().fold(0usize, |count, body| count.saturating_add(body.face_count()));
if tools.len() > 1000 || faces > 16_384 {
@@ -668,7 +784,7 @@ pub(super) fn sheet_cut(
let mut cap_roundoff: f64 = 0.0;
let mut current_footprints = Vec::new();
for tool in tools {
let Some((lo, hi, roundoff, footprint)) = sheet_tool_span(tool, &ss.plane) else {
let Some((lo, hi, roundoff, footprint)) = sheet_tool_span(tool, plane) else {
// Modified tools need a current geometric prism certificate. The generic
// sheet preflight permits actual no-change, otherwise refuses stale metadata.
return Ok(Vec::new());
@@ -688,7 +804,7 @@ pub(super) fn sheet_cut(
let selected_edges =
regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).fold(0usize, usize::saturating_add);
work.charge(faces.saturating_mul(selected_edges).saturating_mul(16))?;
let nominal = constructor.filter(|c| c.plane == ss.plane).and_then(|c| {
let nominal = constructor.filter(|c| c.plane == *plane).and_then(|c| {
let placed_basis = Plane::new(c.plane.origin, c.plane.x, c.plane.y)?;
(current_footprints.len() == tools.len()
&& current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r)))
@@ -696,23 +812,23 @@ pub(super) fn sheet_cut(
});
let mut plans = Vec::new();
'sheets: for original in &st.sheets {
if !eligible(original) {
if !sheet_eligible(f, st, targets, original) {
continue;
}
let Some(mb) = st.body(&original.body) else {
continue;
};
let mut sh = original.clone();
if *operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) {
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
if operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) {
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
continue;
}
if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, *operation)? {
if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, operation)? {
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
continue;
}
for bends in sh.unmodeled_solids(&ss.plane)? {
for bends in sh.unmodeled_solids(plane)? {
for tool in tools {
if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() {
return Err(DocError::Invalid(
@@ -724,21 +840,21 @@ pub(super) fn sheet_cut(
let candidates = if sh.flat {
if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|r| (None, r)).collect() }
} else {
sh.material_panels(Some(&ss.plane), &mut work)?
sh.material_panels(Some(plane), &mut work)?
};
let mut modeled = false;
for (pi, panel) in candidates {
let w = if sh.flat { sh.world(None) } else { sh.world(pi) };
let zorigin = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n);
let zorigin = (apply_point(&w, Vec3::ZERO) - plane.origin).dot(n);
let sign = crate::apply_vector(&w, Vec3::Z).dot(n);
if sign.abs() < 1e-6 {
continue;
}
let (a, b) = ((span.0 - zorigin) / sign, (span.1 - zorigin) / sign);
let roundoff = (cap_roundoff
+ 128.0 * f64::EPSILON * (apply_point(&w, Vec3::ZERO).len() + ss.plane.origin.len() + span.0.abs() + span.1.abs() + sh.t).max(1.0))
+ 128.0 * f64::EPSILON * (apply_point(&w, Vec3::ZERO).len() + plane.origin.len() + span.0.abs() + span.1.abs() + sh.t).max(1.0))
/ sign.abs();
let nominal_depth = if apply_point(&w, Vec3::ZERO) == ss.plane.origin
let nominal_depth = if apply_point(&w, Vec3::ZERO) == plane.origin
&& let Some((lo, hi)) = nominal
&& (span.0 - lo).abs() <= roundoff
&& (span.1 - hi).abs() <= roundoff
@@ -758,7 +874,7 @@ pub(super) fn sheet_cut(
None
};
if nominal_depth.is_none() && roundoff > 1e-7 {
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
plans.push((original.body.clone(), Some((original.clone(), mb.body.clone()))));
continue 'sheets;
}
@@ -766,12 +882,12 @@ pub(super) fn sheet_cut(
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
// Sub-resolution layers must not be silently erased by endpoint snapping.
let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6);
let mapped = if extent.to.is_some() && current_footprints.len() == tools.len() {
let mapped = if footprints_bound && current_footprints.len() == tools.len() {
current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
} else {
regions.iter().map(|r| sh.region_to_flat(pi, &ss.plane, r)).collect::<Result<Vec<_>>>()?
regions.iter().map(|r| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
};
if *operation == Operation::Intersect {
if operation == Operation::Intersect {
if z1 <= z0 {
sh.holes.push(panel);
modeled = true;
@@ -827,7 +943,7 @@ pub(super) fn sheet_cut(
}
}
if !modeled {
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
} else if sh.flat_material()?.is_empty() {
// Empty exact material removes geometry and metadata together. Budget or
+28 -8
View File
@@ -144,15 +144,30 @@ impl Document {
author: u64,
author_path: &[u64],
) -> Result<FaceAnchor> {
let source = body
// A construction certificate that names this face alone, else its exact history name.
let certified = body
.body
.face_plane(index)
.ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))
.and_then(|source| {
let target = self.strong_face_target(body, index, source.origin)?;
if target.certificate.is_none() {
return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into()));
}
let indices = self.configuration_face_indices(body, &target)?;
self.coplanar_pieces(body, &indices, |_| true)?;
Ok(target)
});
let target = match certified {
Ok(target) => target,
Err(error) => match body.body.planar_face(index).and_then(|plane| self.lineage_face_target(body, index, plane.origin)) {
Some(target) => target,
None => return Err(error),
},
};
let source = self
.anchor_face_plane(body, index, target.certificate.as_ref())
.ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))?;
let target = self.strong_face_target(body, index, source.origin)?;
if target.certificate.is_none() {
return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into()));
}
self.configuration_face_indices(body, &target)?;
let mut anchor = FaceAnchor {
target,
component: self.model_body_component(body),
@@ -178,9 +193,14 @@ impl Document {
state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("attached body `{}` no longer exists", anchor.target.body)))?;
let indices = self.configuration_face_indices(body, &anchor.target)?;
let first = indices.first().copied().ok_or_else(|| DocError::Unknown("attached face no longer exists".into()))?;
let plane = body.body.face_plane(first).ok_or_else(|| DocError::Invalid("attached face no longer has an exact planar source".into()))?;
let certificate = anchor.target.certificate.as_ref();
let plane = self
.anchor_face_plane(body, first, certificate)
.ok_or_else(|| DocError::Invalid("attached face no longer has an exact planar source".into()))?;
for index in indices.iter().skip(1) {
let other = body.body.face_plane(*index).ok_or_else(|| DocError::Invalid("attached face pieces are not exactly planar".into()))?;
let other = self
.anchor_face_plane(body, *index, certificate)
.ok_or_else(|| DocError::Invalid("attached face pieces are not exactly planar".into()))?;
if plane.normal().dot(other.normal()) < 1. - 1e-8 || (other.origin - plane.origin).dot(plane.normal()).abs() > 1e-7 {
return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into()));
}
+25 -2
View File
@@ -51,13 +51,35 @@
//! anchors can name a sheet metal panel side (`sheet:<body>:<panel>:<side>`). Version 14
//! designs need no transformation; the version stamp lets older readers request an update
//! instead of encountering an unknown certificate or link kind.
//! - 16: sheet metal rules can choose a corner relief shape (`corner_relief_shape`: notch,
//! round or rectangular). Version 15 designs need no transformation (an absent shape is the
//! notch they always had); the version stamp lets older readers request an update instead of
//! silently building notches where a design asks for another shape.
//! Under the same version, sketch text can be set in a font other than the built-in one
//! (`font` in the text link's source: an installed family or a font file); a text without
//! `font` is in the built-in font, as it always was.
//! A Move feature can also keep its source bodies and transform a copy (`copy: true`); a
//! move without it saves as before.
//! A component can link to another design file (`links`: component id → `{path, feature}`,
//! the path relative to the design's folder); the linked bodies are kept in the component's
//! Import feature, so an older build opens the design with them and only loses the link.
//! A design can also define its own physical materials (`user_materials`: name, density in
//! g/cm³, optional look), which bodies (`materials`) and configuration material cells name
//! like built-in ones; a design without them saves as before.
//! A design can also keep what was hidden when it was saved (`visibility`: hidden bodies,
//! occurrence placements, sketches and origin items; `browser.visibility`). A design without
//! it shows everything, as it always did; a reader that ignores it loses only the hiding.
//! Sketch face anchors can name a flat face by its exact history name (a `lineage`
//! certificate: the target's `name`, e.g. a cut wall `F5:side:l1` or a pattern copy
//! `F4:[email protected]`), where no construction certificate names it alone. Anchors with
//! construction certificates resolve as before.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 15;
pub const FORMAT: u32 = 16;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -74,7 +96,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 14] = [
const JSON_STEPS: [JsonStep; 15] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -89,6 +111,7 @@ const JSON_STEPS: [JsonStep; 14] = [
|_| Ok(()),
v13_to_v14,
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
+1
View File
@@ -32,6 +32,7 @@ pub mod naming;
mod params;
mod piece_names;
mod profile_refs;
pub mod progress;
pub use profile_refs::{capture_profile_selection, profile_selection_indices};
pub mod plastic;
pub mod project;
+29
View File
@@ -211,6 +211,35 @@ impl FaceHit {
}
}
impl FaceHit {
/// Where the line through `p` along `dir` meets the face found (the hit nearest `p`, on
/// either side of it), `None` when the line misses the face or runs along it. A place on
/// a face follows the face this way, along its own axis, instead of sliding sideways to
/// [`point`](Self::point), the face's nearest point.
pub fn along(&self, st: &ModelState, p: Vec3, dir: Vec3) -> Option<Vec3> {
let b = st.bodies.get(self.body)?;
let d = dir.normalized()?;
let m = b.mesh();
let slack = (b.body.size() * 1e-9).max(1e-9);
let mut best: Option<f64> = None;
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
if *f as usize != self.face {
continue;
}
let Some([a, bb, c]) = m.tri(t) else { continue };
// A hair's width around each triangle, so a place on the seam of two is on the face.
let centre = (a + bb + c) * (1.0 / 3.0);
let grow = |v: Vec3| v + (v - centre).normalized().unwrap_or(Vec3::ZERO) * slack;
if let Some(hit) = naming::ray_hit(p, d, grow(a), grow(bb), grow(c))
&& best.is_none_or(|old| hit.abs() < old.abs())
{
best = Some(hit);
}
}
best.map(|t| p + d * t)
}
}
impl EdgeHit {
/// What a feature says about how it found an edge by its stored name `name` (`None`: exactly).
pub fn note(&self, name: &str) -> Option<String> {
+27 -9
View File
@@ -31,6 +31,8 @@ pub struct NamingOrigin {
pub depth: usize,
/// Instance transforms of a pattern or mirror (its copies are named after their sources).
pub mats: Vec<crate::Mat>,
/// The sheet metal bodies after a sheet feature (their folds place moved faces).
pub sheets: Vec<crate::sheet::SheetBody>,
}
/// The lazily computed names of a body, shared by its clones.
@@ -334,6 +336,24 @@ fn geometric_from(b: &ModelBody, o: &NamingOrigin, nf: usize, names_of: fn(&Mode
names = got.into_iter().map(|n| n.map(|n| format!("{n}@F{fid}.{}", k + 1))).collect();
}
}
// A sheet feature that moved panels (a fold, an unfold): faces it carried along lie on the
// faces they were, taken back through the panel they are on.
if let (Some(after), Some(before), Some(input)) = (
o.sheets.iter().find(|s| s.body == b.name),
o.before.sheets.iter().find(|s| s.body == b.name),
inputs.iter().find(|(x, _, _)| x.name == b.name),
) {
let input = std::slice::from_ref(input);
for (name, ps) in names.iter_mut().zip(&pts) {
if name.is_some() {
continue;
}
let back: Option<Vec<Vec3>> = ps.iter().map(|p| after.point_before(before, *p)).collect();
if let Some(back) = back.filter(|b| !b.is_empty()) {
*name = vote(&back, input, tol, None);
}
}
}
let empty = Vec::new();
let names: Vec<String> = names
.into_iter()
@@ -386,7 +406,7 @@ fn behind(ps: &[Vec3], ns: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<St
votes.first().filter(|(_, k)| *k * 2 >= ps.len().max(1)).map(|(n, _)| n.clone())
}
fn ray_hit(o: Vec3, d: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Option<f64> {
pub(crate) fn ray_hit(o: Vec3, d: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Option<f64> {
let (e1, e2) = (b - a, c - a);
let h = d.cross(e2);
let det = e1.dot(h);
@@ -454,14 +474,12 @@ pub fn strip_edge_piece(n: &str) -> String {
/// Edges sharing a name: number them along the longest axis of their spread.
fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
let mut seen: Vec<String> = Vec::new();
for i in 0..names.len() {
let n = names[i].clone();
if seen.contains(&n) {
continue;
}
seen.push(n.clone());
let same: Vec<usize> = (0..names.len()).filter(|j| names[*j] == n).collect();
// The pieces of each name, gathered in one pass (a faceted body has thousands of names).
let mut groups: std::collections::BTreeMap<String, Vec<usize>> = std::collections::BTreeMap::new();
for (i, n) in names.iter().enumerate() {
groups.entry(n.clone()).or_default().push(i);
}
for (n, same) in groups {
if same.len() < 2 {
continue;
}
+133
View File
@@ -0,0 +1,133 @@
//! Progress and cooperative cancellation for a long evaluation (opening a large design) run
//! off the window thread. The caller wraps the work in [`watch`]; the timeline's evaluation
//! loop reports each feature it reaches and, once [`Progress::cancel`] is called, skips the
//! rest. Nothing changes for evaluations that are not watched.
use std::cell::RefCell;
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
/// Shared between the worker evaluating a design and the window showing its progress.
#[derive(Debug, Default)]
pub struct Progress {
cancelled: AtomicBool,
done: AtomicUsize,
total: AtomicUsize,
}
impl Progress {
/// Ask the watched evaluation to stop at its next feature.
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Relaxed);
}
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Relaxed)
}
/// Features evaluated so far and the timeline's length (0 before evaluation starts).
pub fn features(&self) -> (usize, usize) {
(self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed))
}
}
struct Current {
progress: Arc<Progress>,
/// Nested evaluations (derived designs) don't overwrite the outer timeline's count.
depth: usize,
}
thread_local! {
static CURRENT: RefCell<Option<Current>> = const { RefCell::new(None) };
}
/// Run `f` with `progress` watching every evaluation it makes on this thread.
pub fn watch<R>(progress: Arc<Progress>, f: impl FnOnce() -> R) -> R {
let previous = CURRENT.with(|c| c.replace(Some(Current { progress, depth: 0 })));
struct Restore(Option<Option<Current>>);
impl Drop for Restore {
fn drop(&mut self) {
if let Some(previous) = self.0.take() {
CURRENT.with(|c| *c.borrow_mut() = previous);
}
}
}
let _restore = Restore(Some(previous));
f()
}
/// Entered at the start of an evaluation; the guard leaves it.
pub(crate) struct Evaluation {
outer: bool,
}
pub(crate) fn begin(total: usize) -> Evaluation {
let outer = CURRENT.with(|c| {
let mut c = c.borrow_mut();
let Some(cur) = c.as_mut() else { return false };
cur.depth += 1;
if cur.depth == 1 {
cur.progress.total.store(total, Ordering::Relaxed);
cur.progress.done.store(0, Ordering::Relaxed);
true
} else {
false
}
});
Evaluation { outer }
}
impl Evaluation {
/// Feature `index` is next. True when the watcher asked to stop: skip it.
pub(crate) fn step(&self, index: usize) -> bool {
CURRENT.with(|c| {
let c = c.borrow();
let Some(cur) = c.as_ref() else { return false };
if self.outer {
cur.progress.done.store(index, Ordering::Relaxed);
}
cur.progress.is_cancelled()
})
}
}
impl Drop for Evaluation {
fn drop(&mut self) {
CURRENT.with(|c| {
if let Some(cur) = c.borrow_mut().as_mut() {
cur.depth = cur.depth.saturating_sub(1);
if self.outer {
let total = cur.progress.total.load(Ordering::Relaxed);
cur.progress.done.store(total, Ordering::Relaxed);
}
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unwatched_evaluations_never_stop_and_watched_ones_report_and_stop() {
let e = begin(3);
assert!(!e.step(0));
drop(e);
let p = Arc::new(Progress::default());
watch(p.clone(), || {
let e = begin(5);
assert!(!e.step(2));
assert_eq!(p.features(), (2, 5));
{
let inner = begin(100);
assert!(!inner.step(50));
}
assert_eq!(p.features(), (2, 5), "a nested evaluation keeps the outer count");
p.cancel();
assert!(e.step(3));
});
assert_eq!(p.features(), (5, 5));
assert!(!begin(1).step(0), "the watch ends with its scope");
}
}
+1 -1
View File
@@ -262,7 +262,7 @@ fn resolve_uncached(
LinkSource::SketchAxis { name } => {
vec![solidcraft_sketch::sketch_axis_geom(name).ok_or_else(|| DocError::Unknown(format!("sketch axis `{name}`")))?]
}
LinkSource::Text { text, at, height, angle } => solidcraft_sketch::text_geometry(text, *at, *height, *angle)?,
LinkSource::Text { text, at, height, angle, font } => solidcraft_sketch::text_geometry_in(font.as_deref(), text, *at, *height, *angle)?,
LinkSource::Plane { name } => {
let pr =
if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } };
+1940 -42
View File
File diff suppressed because it is too large. Load diff
@@ -88,3 +88,68 @@ fn struts_ending_on_a_bore_wall_join_without_a_panic() {
result.unwrap();
assert_eq!(s.model.state().bodies.len(), 1);
}
/// A blind Ø2 hole into the flat back of a long D-profile bar ends 2 mm short of the round front,
/// yet the cut was refused (#544). It removes π · 1² · 10 mm³ (within 5 %: the measure tessellates
/// the small bore) and adds the bore's wall and floor to the bar's six faces.
#[test]
fn blind_hole_into_a_long_d_profile_bar_stops_short_of_the_round_front() {
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/blind_hole_in_long_d_profile.json")).unwrap();
let steps = script.get("commands").and_then(Value::as_array).unwrap();
let mut s = Session::default();
s.run_script(&Value::Array(steps[..5].to_vec())).unwrap();
let bar = volume(&s, "Slat");
s.run_script(&Value::Array(steps[5..].to_vec())).unwrap();
let hole = std::f64::consts::PI * 10.0;
let removed = bar - volume(&s, "Slat");
assert!((removed - hole).abs() < 0.05 * hole, "removed {removed} vs {hole}");
let faces = s.model.state().body("Slat").map(|b| solidcraft_kernel::measure(&b.body).unwrap().merged.faces);
assert_eq!(faces, Some(8));
}
/// A ring pocket cut from below a recess leaves a 0.5 mm floor joining the outer wall to the inner
/// block: one connected part, yet the cut left two bodies and lost volume (#545).
#[test]
fn ring_cut_below_a_recess_keeps_the_part_in_one_body() {
let s = replay(include_str!("../../../examples/regressions/ring_cut_below_recess_thin_floor.json"));
let bodies: Vec<(String, f64)> = s.model.state().bodies.iter().map(|b| (b.name.clone(), volume(&s, &b.name))).collect();
let want = 100.0 * 100.0 * 30.0 - 88.0 * 88.0 * 12.0 - (95.0 * 95.0 - 68.0 * 68.0) * 17.5;
assert_eq!(bodies.len(), 1, "{bodies:?}");
let got = volume(&s, "Frame");
assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}");
}
/// Four ribs joined on a rounded lid, standing on the plate over the skirt and flush with the
/// window's sides: each adds exactly its own material and nothing changes away from it. The
/// joins displaced the tool and one left a slit through the far side of the skirt and plate
/// (#616), which no volume shows: the area does. The reply of each join carries its warning, as
/// the timeline does (an agent placing parts over MCP saw none).
#[test]
fn ribs_flush_with_a_window_change_nothing_away_from_them() {
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/lid_ribs_flush_with_window.json")).unwrap();
let steps = script.get("commands").and_then(Value::as_array).unwrap();
let (lid, ribs) = steps.split_at(steps.len() - 4);
let mut s = Session::default();
s.run_script(&Value::Array(lid.to_vec())).unwrap();
let measure = |s: &Session| s.model.state().body("Lid").map(|b| solidcraft_kernel::measure(&b.body).unwrap()).unwrap();
let (v0, a0) = (measure(&s).volume, measure(&s).area);
assert!((v0 - 6703.5222).abs() < 1e-3, "{v0}");
for (i, rib) in ribs.iter().enumerate() {
let reply = s.execute(rib["command"].as_str().unwrap(), &rib["params"]).unwrap();
let id = reply["feature"].as_u64().unwrap();
let result = s.model.result(id).unwrap();
assert_eq!(reply["warning"], json!(result.warning), "the reply says what the timeline says");
assert!(!result.warning.as_deref().unwrap_or_default().contains("tool displacement"), "{:?}", result.warning);
let m = measure(&s);
let n = (i + 1) as f64;
// 1.6 x 6 x 11.7 less the 1.35 x 6 x 3 inside the skirt.
assert!((m.volume - (v0 + n * 88.02)).abs() < 1e-6, "rib {}: {}", i + 1, m.volume - v0);
// Top 9.6, outer side 6 x 8.7 above the skirt, inner side 6 x 11.7 (flush with the
// window's side), ends 2 x (1.6 x 11.7 - 1.35 x 3); the skirt top 8.1, its inner side
// 18 and 1.5 of the plate go under the rib.
let area = 9.6 + 52.2 + 70.2 + 29.34 - 8.1 - 18.0 - 1.5;
assert!((m.area - (a0 + n * area)).abs() < 1e-6, "rib {}: area {} (no slit adds any)", i + 1, m.area - a0);
}
let bounds = s.model.state().body("Lid").unwrap().body.tessellate(0.01).unwrap().bounds();
assert!((bounds.max.z - 14.1).abs() < 1e-9, "{}", bounds.max.z);
}
+65 -1
View File
@@ -33,10 +33,13 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"),
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
CommandSpec::new("view.list", "List Named Views", view_list).noundo().doc_only(),
CommandSpec::new("view.apply", "Restore Named View", view_apply).icon("camera").noundo().params(
"view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (radians, mm) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step",
),
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
or folder?: bodies|sketches|construction|origin (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). View state: not saved, not an undo step",
or folder?: bodies|sketches|construction|origin (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design; not an undo step",
),
];
@@ -375,6 +378,38 @@ fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "view": name }))
}
fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "view.apply";
let cam = match (str_(p, "view"), p.get("camera")) {
(Some(_), Some(_)) => return Err(bad(cmd, "give `view` or `camera`, not both")),
(None, None) => return Err(bad(cmd, "`view` (a name) or `camera` is required")),
(None, Some(c)) => {
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
camera_of(&v)
}
// Standard view names come first, as in the browser's Named Views folder.
(Some(name), None) => match crate::render::StandardView::parse(name) {
Some(sv) => {
let mut c = crate::view::home_camera(s);
c.set_view(sv);
c.fit(&crate::view::bounds(s));
c
}
None => s.doc.named_views.iter().find(|v| v.name == name).map(camera_of).ok_or_else(|| bad(cmd, format!("no named view `{name}`")))?,
},
};
if !cam.is_valid() || !cam.fov.is_finite() || cam.fov < 0.0 {
return Err(bad(cmd, "the camera must be finite with a positive distance"));
}
s.camera = Some(cam);
Ok(json!({ "camera": cam }))
}
/// The orbit camera of a named view (Z up, no roll).
fn camera_of(v: &solidcraft_doc::NamedView) -> crate::render::Camera {
crate::render::Camera { target: v.target, yaw: v.yaw, pitch: v.pitch, distance: v.distance, fov: v.fov, ..Default::default() }
}
fn view_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let k = str_(p, "view").ok_or_else(|| bad(cmd, "`view` must be a view name"))?;
s.doc.named_views.iter().position(|v| v.name == k).ok_or_else(|| bad(cmd, format!("no named view `{k}`")))
@@ -724,6 +759,35 @@ mod tests {
assert_eq!(s.doc.param("w").map(|p| p.expr.as_str()), Some("2 in"));
}
#[test]
fn applying_a_named_view_or_camera_moves_the_view() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 10}));
run(&mut s, "view.save", json!({"name": "Photo", "camera": {"target": [1, 2, 3], "yaw": 0.5, "pitch": -0.6, "distance": 150}}));
let undo = s.undo.len();
assert!(s.camera.is_none());
let r = run(&mut s, "view.apply", json!({"view": "Photo"}));
let c = s.camera.expect("a camera for the view");
assert_eq!((c.target, c.yaw, c.pitch, c.distance), (solidcraft_geom::Vec3::new(1.0, 2.0, 3.0), 0.5, -0.6, 150.0));
assert_eq!(r["camera"]["yaw"], 0.5);
// A standard view looks from its direction and frames the model.
run(&mut s, "view.apply", json!({"view": "top"}));
let c = s.camera.expect("a camera for the top view");
assert!((c.pitch - std::f64::consts::FRAC_PI_2).abs() < 1e-3 && (c.target.x - 20.0).abs() < 1e-6, "{c:?}");
run(&mut s, "view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 1, "pitch": 0.2, "distance": 80, "fov": 0.5}}));
assert_eq!(s.camera.map(|c| (c.yaw, c.distance, c.fov)), Some((1.0, 80.0, 0.5)));
assert_eq!(s.undo.len(), undo, "view state, not an undo step");
for p in [
json!({}),
json!({"view": "Nope"}),
json!({"view": "Photo", "camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 1}}),
json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0}}),
json!({"camera": "x"}),
] {
assert!(s.execute("view.apply", &p).is_err(), "{p}");
}
}
#[test]
fn named_views_are_saved_renamed_and_deleted() {
let mut s = Session::default();
+329
View File
@@ -0,0 +1,329 @@
//! Linked designs: a component whose bodies come from another design file (Insert Design).
//!
//! The linked design is opened and evaluated on its own, and its solid bodies, as placed in it,
//! join a new component through an Import feature (exact B-rep, kept in this design so it opens
//! without the part file). The link remembers the file, relative to this design's folder once it
//! is saved; Update Links reads the file again and replaces the Import feature's bodies, and
//! Break Link keeps the bodies and forgets the file.
use std::path::{Component as PathPart, Path, PathBuf};
use serde_json::{Value, json};
use solidcraft_doc::{DesignLink, Document, FeatureKind};
use super::CommandSpec;
use crate::params::{bad, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.insert_design", "Insert Design", insert_design).icon("component").params(
"path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design; \
name? (default: the design's name); parent?: component id or name (default: the active one); translate?: [x,y,z]",
),
CommandSpec::new("component.update_links", "Update Links", update_links).params(
"component?: id or name (default: every linked component) — reads the linked designs again and replaces their bodies where they changed",
),
CommandSpec::new("component.break_link", "Break Link", break_link)
.params("component: id or name of a linked component — keeps its bodies as they are and forgets the design file"),
CommandSpec::new("component.links", "List Links", list_links)
.noundo()
.params("→ linked components: component, name, path, file (resolved), found"),
];
/// The folder relative link paths are kept against: the design's own, once it has a file.
fn base_dir(path: Option<&str>) -> Option<PathBuf> {
path.and_then(|p| absolute(Path::new(p)).parent().map(Path::to_path_buf))
}
/// A path as given, made absolute when the platform can, with `.` and `..` worked out.
fn absolute(p: &Path) -> PathBuf {
let full = std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf());
let mut out = PathBuf::new();
for c in full.components() {
match c {
PathPart::CurDir => {}
PathPart::ParentDir if matches!(out.components().next_back(), Some(PathPart::Normal(_))) => {
out.pop();
}
c => out.push(c.as_os_str()),
}
}
out
}
/// `target` relative to the folder `base` (both absolute), when they share a root.
fn relative(base: &Path, target: &Path) -> Option<PathBuf> {
let (b, t): (Vec<_>, Vec<_>) = (base.components().collect(), target.components().collect());
let root = |c: &[PathPart]| c.iter().take_while(|x| matches!(x, PathPart::Prefix(_) | PathPart::RootDir)).count();
if root(&b) == 0 || b.get(..root(&b)) != t.get(..root(&b)) {
return None;
}
let common = b.iter().zip(&t).take_while(|(x, y)| x == y).count();
let mut out = PathBuf::new();
for _ in common..b.len() {
out.push("..");
}
for c in t.iter().skip(common) {
out.push(c.as_os_str());
}
Some(out)
}
/// How a link stores `file` for a design saved at `design` (`None`: not saved yet).
fn stored(design: Option<&str>, file: &Path) -> String {
let file = absolute(file);
match base_dir(design).and_then(|d| relative(&d, &file)) {
// Relative paths are written with `/`, so a design moves between systems with its parts.
Some(rel) => rel.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::<Vec<_>>().join("/"),
None => file.to_string_lossy().into_owned(),
}
}
/// The file a stored link path names, for a design saved at `design`.
fn resolve(design: Option<&str>, link: &str) -> PathBuf {
let p = Path::new(link);
match base_dir(design) {
Some(d) if p.is_relative() => d.join(p),
_ => p.to_path_buf(),
}
}
/// The links of `doc` stored for a design saved at `to` instead of at `from`.
pub(super) fn rebased(doc: &Document, from: Option<&str>, to: &str) -> std::collections::BTreeMap<u64, DesignLink> {
doc.links.iter().map(|(c, l)| (*c, DesignLink { path: stored(Some(to), &resolve(from, &l.path)), feature: l.feature })).collect()
}
/// The solid bodies of the design at `file`, evaluated, as an Import feature.
fn linked_bodies(file: &Path, own: Option<&str>) -> Result<(String, solidcraft_io::StepFeature)> {
let shown = file.display().to_string();
if own.is_some_and(|o| absolute(Path::new(o)) == absolute(file)) {
return Err(EngineError::Other(format!("{shown}: a design can't be linked into itself")));
}
let bytes = solidcraft_io::vfs::read(&shown, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
let doc = solidcraft_io::read_design(&bytes).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
let name = doc.name.clone();
let part = Session::new(doc);
if let Some(e) = part.model.results.iter().find_map(|r| r.error.clone()) {
return Err(EngineError::Other(format!("{shown}: the design has a feature that fails ({e}); fix it before linking it")));
}
let world = part.world_state();
let solids: Vec<String> = world.bodies.iter().filter(|b| !b.body.is_mesh()).map(|b| b.name.clone()).collect();
if solids.is_empty() {
return Err(EngineError::Other(format!("{shown}: the design has no solid bodies to link")));
}
let step = solidcraft_io::export(&part.doc.painted(&world), &solids, solidcraft_io::Format::Step, &name)
.map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
let mut feature = solidcraft_io::step_import_feature(&step, &shown).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
if world.bodies.len() > solids.len() {
feature.warnings.push(format!("{} mesh bodies of {shown} are not linked", world.bodies.len() - solids.len()));
}
Ok((name, feature))
}
fn component_key(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<u64> {
let k = match p.get(key) {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => return Err(bad(cmd, format!("`{key}` must be a component id or name"))),
};
s.doc.find_component(&k).ok_or_else(|| bad(cmd, format!("no component `{k}`")))
}
fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.insert_design";
let given = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))?;
let translate = match p.get("translate") {
None => solidcraft_geom::Vec3::ZERO,
Some(v) => vec3(v).filter(|t| t.is_finite() && t.len() < 1e9).ok_or_else(|| bad(cmd, "`translate` must be [x, y, z] in mm"))?,
};
let file = absolute(Path::new(given));
let (design_name, sf) = linked_bodies(&file, s.path.as_deref())?;
let name = str_(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string).unwrap_or(design_name);
let mut create = json!({"name": name, "activate": false});
if let Some(parent) = p.get("parent") {
create["parent"] = parent.clone();
}
let made = s.execute("component.create", &create)?;
let comp = made["component"].as_u64().ok_or_else(|| bad(cmd, "the component was not created"))?;
let occ = made["occurrence"].as_u64();
let id = s.doc_mut().add_feature(sf.kind, Some(&name))?;
if let Some(f) = s.doc_mut().feature_mut(id) {
f.component = comp;
f.body_names = sf.body_names;
}
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| Some(o.id) == occ) {
o.transform[3] = [translate.x, translate.y, translate.z, 1.0];
}
let path = stored(s.path.as_deref(), &file);
s.doc_mut().links.insert(comp, DesignLink { path: path.clone(), feature: id });
s.refresh();
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
let st = s.model.state();
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
Ok(
json!({"component": comp, "occurrence": occ, "name": name, "feature": id, "path": path, "bodies": bodies, "created_bodies": bodies, "warnings": sf.warnings}),
)
}
fn update_links(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.update_links";
let only = if p.get("component").is_some() { Some(component_key(s, p, "component", cmd)?) } else { None };
if let Some(c) = only
&& !s.doc.links.contains_key(&c)
{
return Err(bad(cmd, "that component is not linked to a design"));
}
let links: Vec<(u64, DesignLink)> = s.doc.links.iter().filter(|(c, _)| only.is_none_or(|o| o == **c)).map(|(c, l)| (*c, l.clone())).collect();
let (mut updated, mut unchanged, mut warnings) = (Vec::new(), Vec::new(), Vec::new());
for (comp, link) in links {
let cname = s.doc.components.iter().find(|c| c.id == comp).map(|c| c.name.clone());
let Some(cname) = cname else {
// The component was deleted: the link goes with it.
s.doc_mut().links.remove(&comp);
continue;
};
let Some(FeatureKind::Import { step: old, .. }) = s.doc.feature(link.feature).filter(|f| f.component == comp).map(|f| &f.kind) else {
return Err(bad(
cmd,
format!("{cname}: its linked bodies (feature {}) are gone; break the link or insert the design again", link.feature),
));
};
let old = old.clone();
let file = resolve(s.path.as_deref(), &link.path);
let (_, sf) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, format!("{cname}: {e}")))?;
warnings.extend(sf.warnings.iter().cloned());
if matches!(&sf.kind, FeatureKind::Import { step, .. } if *step == old) {
unchanged.push(cname);
continue;
}
if let Some(f) = s.doc_mut().feature_mut(link.feature) {
// Bodies keep their names where the design still has as many.
if f.body_names.len() != sf.body_names.len() {
f.body_names = sf.body_names;
}
f.kind = sf.kind;
}
updated.push(cname);
}
s.refresh();
if let Some(e) = s.doc.links.values().find_map(|l| s.model.result(l.feature).and_then(|r| r.error.clone())) {
return Err(EngineError::Other(e));
}
Ok(json!({"updated": updated, "unchanged": unchanged, "warnings": warnings}))
}
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.break_link";
let comp = component_key(s, p, "component", cmd)?;
let link = s.doc_mut().links.remove(&comp).ok_or_else(|| bad(cmd, "that component is not linked to a design"))?;
Ok(json!({"component": comp, "path": link.path}))
}
fn list_links(s: &mut Session, _p: &Value) -> Result<Value> {
let links: Vec<Value> = s
.doc
.links
.iter()
.map(|(c, l)| {
let file = resolve(s.path.as_deref(), &l.path);
let name = s.doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone());
json!({"component": c, "name": name, "path": l.path, "file": file.display().to_string(), "found": file.is_file()})
})
.collect();
Ok(json!({"links": links}))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn link_paths_are_kept_relative_to_the_design_and_follow_save_as() {
let root = absolute(Path::new("projects"));
let part = root.join("parts").join("plate.solidcraft");
let design = root.join("assembly").join("case.solidcraft");
let design = design.to_string_lossy().into_owned();
assert_eq!(stored(Some(&design), &part), "../parts/plate.solidcraft");
assert_eq!(absolute(&resolve(Some(&design), "../parts/plate.solidcraft")), part);
// A design path given relative to the working folder works the same.
assert_eq!(stored(Some("case.solidcraft"), &absolute(Path::new("parts/plate.solidcraft"))), "parts/plate.solidcraft");
// Before the design has a file the path is kept whole; saving it makes it relative.
let unsaved = stored(None, &part);
assert!(Path::new(&unsaved).is_absolute(), "{unsaved}");
let mut doc = Document::default();
doc.links.insert(7, DesignLink { path: unsaved, feature: 3 });
let moved = rebased(&doc, None, &design);
assert_eq!(moved.get(&7).map(|l| l.path.as_str()), Some("../parts/plate.solidcraft"));
let beside = root.join("parts").join("asm.solidcraft").to_string_lossy().into_owned();
assert_eq!(
rebased(&Document { links: moved, ..Document::default() }, Some(&design), &beside).get(&7).map(|l| l.path.as_str()),
Some("plate.solidcraft")
);
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn save_box(path: &Path, height: f64) {
let mut part = Session::default();
run(&mut part, "solid.box", json!({"length": 10, "width": 20, "height": height, "body_name": "Plate"}));
run(&mut part, "file.save", json!({"path": path}));
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
#[test]
fn a_saved_assembly_reopens_with_its_link_updates_it_and_breaks_it() {
let dir = std::env::temp_dir().join(format!("solidcraft-link-reopen-{}", std::process::id()));
std::fs::create_dir_all(dir.join("parts")).unwrap();
std::fs::create_dir_all(dir.join("asm")).unwrap();
let part = dir.join("parts").join("plate.solidcraft");
let case = dir.join("asm").join("case.solidcraft");
save_box(&part, 30.0);
// Given an assembly holding the part, saved in a folder beside the part's.
let mut a = Session::default();
let inserted = run(&mut a, "component.insert_design", json!({"path": part}));
assert_eq!(inserted["name"], "plate");
run(&mut a, "file.save", json!({"path": case}));
assert!((volume(&mut a) - 6000.0).abs() < 1e-6);
// When it is opened again, the link is kept relative to it and finds the part.
let mut b = Session::default();
run(&mut b, "doc.open", json!({"path": case}));
let links = run(&mut b, "component.links", json!({}));
assert_eq!(links["links"][0]["path"], "../parts/plate.solidcraft", "{links}");
assert_eq!(links["links"][0]["found"], true, "{links}");
assert!((volume(&mut b) - 6000.0).abs() < 1e-6);
// An unchanged part leaves the assembly as it is; an edited one comes in on update.
assert_eq!(run(&mut b, "component.update_links", json!({}))["unchanged"], json!(["plate"]));
save_box(&part, 45.0);
assert_eq!(run(&mut b, "component.update_links", json!({}))["updated"], json!(["plate"]));
assert!((volume(&mut b) - 9000.0).abs() < 1e-6, "{}", volume(&mut b));
// A part file that has gone is reported, and the assembly keeps the bodies it had.
std::fs::remove_file(&part).unwrap();
let err = b.execute("component.update_links", &json!({})).unwrap_err().to_string();
assert!(err.contains("plate"), "{err}");
assert!((volume(&mut b) - 9000.0).abs() < 1e-6);
// Break Link keeps the bodies and forgets the file.
run(&mut b, "component.break_link", json!({"component": "plate"}));
assert_eq!(run(&mut b, "component.links", json!({}))["links"], json!([]));
assert!((volume(&mut b) - 9000.0).abs() < 1e-6);
assert!(b.execute("component.update_links", &json!({"component": "plate"})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn inserting_a_missing_file_or_bad_parameters_changes_nothing() {
let mut s = Session::default();
let missing = std::env::temp_dir().join(format!("solidcraft-no-such-part-{}.solidcraft", std::process::id()));
assert!(s.execute("component.insert_design", &json!({"path": missing})).is_err());
assert!(s.execute("component.insert_design", &json!({})).is_err());
assert!(s.execute("component.insert_design", &json!({"path": "x.solidcraft", "translate": [1, "a", 2]})).is_err());
assert!(s.doc.components.is_empty() && s.doc.links.is_empty() && s.doc.features.is_empty());
}
}
@@ -127,3 +127,175 @@ fn excessively_nested_assembly_refuses_partial_totals() {
let error = s.execute("component.properties", &json!({"component": 0})).unwrap_err();
assert!(error.to_string().contains("traversal limits"), "{error}");
}
/// #532: a product BOM needs materials outside the built-in library (walnut, polypropylene,
/// rubber, leather). A user-defined material with its density is assigned like a built-in one
/// and sets the body's mass: a 10 mm walnut cube at 0.65 g/cm³ weighs 0.65 g.
#[test]
fn user_defined_material_sets_the_body_mass() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Slat"}));
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
run(&mut s, "material.assign", json!({"bodies": ["Slat"], "material": "Walnut"}));
let props = run(&mut s, "component.properties", json!({"component": "root"}));
assert_eq!(props["bodies"][0]["material"], "Walnut", "{props}");
close(&props["bodies"][0]["density_g_cm3"], 0.65);
close(&props["total"]["mass_g"], 0.65);
}
/// A design file path in a fresh temporary folder (removed by the caller).
fn temp_design(tag: &str) -> (std::path::PathBuf, String) {
let dir = std::env::temp_dir().join(format!("solidcraft-user-material-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("design.solidcraft").to_string_lossy().to_string();
(dir, path)
}
fn mass(s: &mut Session) -> Value {
run(s, "component.properties", json!({"component": "root"}))["total"]["mass_g"].clone()
}
/// #532: a design's own material is listed after the library, gives its bodies its look, and
/// can be edited (density and name: the bodies follow) and deleted; each step is undoable.
#[test]
fn user_defined_material_is_edited_renamed_and_deleted() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Pad"}));
assert!(s.execute("material.edit", &json!({"material": "Rubber", "density_g_cm3": 1.2})).is_err(), "nothing to edit yet");
let created = run(&mut s, "material.create", json!({"name": "Rubber", "density_g_cm3": 1.5, "color": "#1c1c1c"}));
assert_eq!(created["appearance"]["color"], "#1c1c1c", "{created}");
let list = run(&mut s, "material.list", json!({}));
let materials = list["materials"].as_array().unwrap();
assert_eq!(materials.len(), solidcraft_doc::MATERIALS.len() + 1);
let rubber = materials.last().unwrap();
assert_eq!((rubber["name"].as_str(), rubber["user"].as_bool()), (Some("Rubber"), Some(true)));
// Assigned (in any case), it sets the mass and the body's colour.
run(&mut s, "material.assign", json!({"bodies": ["Pad"], "material": "rubber"}));
close(&mass(&mut s), 1.5);
let pad = s.model.state().body("Pad").unwrap().clone();
let grey = 28.0 / 255.0;
assert_eq!(s.doc.body_color(&pad), Some([grey, grey, grey]));
// A new density changes the mass; a new name carries the body with it.
run(&mut s, "material.edit", json!({"material": "Rubber", "density_g_cm3": 1.2, "name": "Foot Rubber"}));
close(&mass(&mut s), 1.2);
let props = run(&mut s, "component.properties", json!({"component": "root"}));
assert_eq!(props["bodies"][0]["material"], "Foot Rubber");
run(&mut s, "edit.undo", json!({}));
close(&mass(&mut s), 1.5);
assert_eq!(s.doc.materials.get("Pad").map(String::as_str), Some("Rubber"));
run(&mut s, "edit.redo", json!({}));
// A material in use is not deleted unless its bodies get another one.
let refused = s.execute("material.delete", &json!({"material": "Foot Rubber"})).unwrap_err().to_string();
assert!(refused.contains("body `Pad`"), "{refused}");
run(&mut s, "material.delete", json!({"material": "Foot Rubber", "replace": "Oak"}));
close(&mass(&mut s), 0.75);
assert!(s.doc.user_materials.is_empty());
assert!(s.execute("material.assign", &json!({"bodies": ["Pad"], "material": "Foot Rubber"})).is_err());
run(&mut s, "edit.undo", json!({}));
close(&mass(&mut s), 1.2);
}
/// #532: names and densities are checked before anything changes.
#[test]
fn user_defined_material_refuses_bad_names_and_densities() {
let mut s = Session::default();
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
let doc = s.doc.clone();
for p in [
json!({"name": "Steel", "density_g_cm3": 1.0}),
json!({"name": "walnut", "density_g_cm3": 1.0}),
json!({"name": " ", "density_g_cm3": 1.0}),
json!({"name": "x".repeat(129), "density_g_cm3": 1.0}),
json!({"name": "Leather", "density_g_cm3": 0}),
json!({"name": "Leather", "density_g_cm3": -1}),
json!({"name": "Leather", "density_g_cm3": 1e9}),
json!({"name": "Leather", "density_g_cm3": "heavy"}),
json!({"name": "Leather"}),
json!({"name": "Leather", "density_g_cm3": 0.9, "color": "#zzzzzz"}),
json!({"name": "Leather", "density_g_cm3": 0.9, "appearance": "No Such Look"}),
json!({"name": "Leather", "density_g_cm3": 0.9, "opacity": 0.5}),
] {
assert!(s.execute("material.create", &p).is_err(), "{p}");
}
for p in [
json!({"material": "Steel", "density_g_cm3": 1.0}),
json!({"material": "Walnut", "name": "Oak"}),
json!({"material": "Walnut", "density_g_cm3": 0}),
] {
assert!(s.execute("material.edit", &p).is_err(), "{p}");
}
assert_eq!(*s.doc, *doc);
}
/// #532: a design's materials are saved with it: after reopening, the bodies keep their
/// material, its density and its look.
#[test]
fn user_defined_material_survives_save_and_reopen() {
let (dir, path) = temp_design("saved");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Tab"}));
run(&mut s, "material.create", json!({"name": "Leather", "density_g_cm3": 0.86, "appearance": "Rubber - Black"}));
run(&mut s, "material.assign", json!({"bodies": ["Tab"], "material": "Leather"}));
run(&mut s, "file.save", json!({"path": path}));
assert!(s.doc.to_json().contains("\"solidcraft/16\""));
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
let props = run(&mut s, "component.properties", json!({"component": "root"}));
assert_eq!(props["bodies"][0]["material"], "Leather", "{props}");
close(&props["total"]["mass_g"], 0.86);
assert_eq!(s.doc.material_look("Leather").map(|l| l.name), Some("Rubber - Black".to_string()));
run(&mut s, "material.edit", json!({"material": "Leather", "density_g_cm3": 0.9}));
close(&mass(&mut s), 0.9);
let _ = std::fs::remove_dir_all(&dir);
}
/// #532: designs from before user materials open unchanged: built-in assignments keep their
/// densities and a design without its own materials saves no `user_materials`. A damaged
/// file's unusable density falls back to the default instead of producing nonsense masses.
#[test]
fn designs_without_user_materials_open_unchanged() {
let (dir, path) = temp_design("legacy");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Plate"}));
run(&mut s, "material.assign", json!({"bodies": ["Plate"], "material": "Steel"}));
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
assert!(legacy.get("user_materials").is_none());
legacy["format"] = json!("solidcraft/16");
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
close(&mass(&mut s), 7.85);
assert!(s.doc.user_materials.is_empty());
assert!(s.execute("material.delete", &json!({"material": "Steel"})).is_err(), "no design materials: nothing to delete");
legacy["user_materials"] = json!([{"name": "Broken", "density": -3.0, "appearance": {"name": "Broken", "color": [1, 2, 3], "opacity": 9.0}}]);
legacy["materials"] = json!({"Plate": "Broken"});
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
run(&mut s, "doc.open", json!({"path": path}));
close(&mass(&mut s), solidcraft_doc::DEFAULT_DENSITY);
assert_eq!(s.doc.material_look("Broken").map(|l| l.opacity), Some(1.0));
let _ = std::fs::remove_dir_all(&dir);
}
/// #532: configuration material cells take a design's own material and follow its rename.
#[test]
fn configurations_switch_to_a_user_defined_material() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Slat"}));
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
run(&mut s, "config.configure", json!({"materials": ["Slat"]}));
run(&mut s, "config.row", json!({"name": "Walnut", "values": {"material:Slat": "walnut"}}));
run(&mut s, "config.activate", json!({"row": "Walnut"}));
close(&mass(&mut s), 0.65);
run(&mut s, "material.edit", json!({"material": "Walnut", "name": "Black Walnut"}));
run(&mut s, "config.activate", json!({"row": "Default"}));
run(&mut s, "config.activate", json!({"row": "Walnut"}));
assert_eq!(s.doc.materials.get("Slat").map(String::as_str), Some("Black Walnut"));
run(&mut s, "material.delete", json!({"material": "Black Walnut", "replace": "Default"}));
assert!(!s.doc.materials.contains_key("Slat"));
}
+1 -1
View File
@@ -265,7 +265,7 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
match s.doc.configs.columns.get(col) {
Some(Column::Material(_)) => {
text = solidcraft_doc::config::material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
text = s.doc.material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
}
Some(Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_)) => {
text = match solidcraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? {
+27 -16
View File
@@ -136,31 +136,42 @@ fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() {
}
}
/// A 40 x 30 x 20 box whose top is split at x = 20 and its left half raised 5 by Offset Face:
/// the raised top has no construction certificate and no exact history name yet (#494).
fn raised_half(s: &mut Session, body: &str) {
run(s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":body}));
run(s, "solid.split_face", json!({"faces":[[10,15,20]],"plane":{"origin":[20,0,0],"normal":[1,0,0]}}));
run(s, "solid.offset_face", json!({"faces":[[10,15,20]],"distance":5}));
}
#[test]
fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
let mut s = Session::default();
let component = run(&mut s, "component.create", json!({"name":"Source"}));
let occurrence = component["occurrence"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Stock"}));
// A top merged from two joined boxes has no single certified source: the legacy path.
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"corner":[40,0,0],"operation":"join"}));
// A face Offset Face raised has no construction certificate or exact history name yet:
// the legacy path.
raised_half(&mut s, "Stock");
run(&mut s, "component.activate", json!({"component":0}));
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":false}));
let state = s.model.state();
let index = state
// The raised half's top.
let end = state
.body("Stock")
.unwrap()
.body
.faces(1e-4)
.unwrap()
.iter()
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 20.0).abs() < 1e-6)
.unwrap()
.index;
.into_iter()
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 25.0).abs() < 1e-6)
.unwrap();
let before = s.doc.to_json();
let error =
super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,20],"body":"Stock","index":index,"path":[occurrence]}), "internal")
.unwrap_err();
let error = super::super::sketch::face::capture_internal(
&s,
&json!({"face":[40,15,25],"body":"Stock","index":end.index,"path":[occurrence]}),
"internal",
)
.unwrap_err();
assert!(error.to_string().contains("capture the source occurrence position"), "{error}");
assert_eq!(s.doc.to_json(), before);
}
@@ -278,14 +289,14 @@ fn join_keeps_exact_face_attachments_from_both_operands() {
}
#[test]
fn join_leaves_a_face_merged_from_both_operands_without_an_attachment() {
fn join_gives_a_face_merged_from_both_operands_its_history_name() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10}));
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"corner":[20,0,0],"operation":"join"}));
// Both boxes' tops share one support: no single construction identity owns it.
let error = s.execute("sketch.create", &json!({"plane":{"face":[10,10,10]}})).unwrap_err().to_string();
assert!(error.contains("fixed:true"), "{error}");
run(&mut s, "sketch.create", json!({"plane":{"face":[10,10,10],"fixed":true}}));
// Both boxes' tops share one support: no single construction certificate owns it, but its
// exact history names it (#491).
let top = run(&mut s, "sketch.create", json!({"plane":{"face":[10,10,10]}}))["sketch"].as_u64().unwrap();
assert_eq!(face_anchor(&s, top).target.certificate, Some(solidcraft_doc::config::FaceCertificate::Lineage));
// An unshared support keeps its box role.
run(&mut s, "sketch.finish", json!({}));
let side = run(&mut s, "sketch.create", json!({"plane":{"face":[0,10,5]}}))["sketch"].as_u64().unwrap();
+62 -7
View File
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("fillet")
.key("F")
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce (exact, the default: a radius too large for the geometry around the edges is refused; reduce: it is reduced to the largest that fits all of them, returned as `radius_applied` and noted in the feature's warning)"),
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
@@ -107,7 +107,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("presspull")
.key("Q")
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"),
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place)"),
];
/// Where an extrude starts and which way is positive: a point inside the (first) profile or
@@ -365,6 +365,8 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
/// `add_feature`; `world` is false when `kind` is already in the active component's frame.
fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> Result<Value> {
let name = str_(p, "name");
// The bodies before the feature, to say which ones it changed or removed.
let before = s.model.state();
// Commands speak world coordinates; the feature is authored in the active component.
let mut kind = kind;
if world {
@@ -472,8 +474,20 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
}
let st = s.model.state();
let bodies: Vec<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
// The bodies this feature made, apart from the ones it changed or that were there (#374).
let created: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
// Bodies that were there before and now have other geometry, and bodies that are gone.
let old: std::collections::HashMap<&str, &solidcraft_doc::kernel::Body> = before.bodies.iter().map(|b| (b.name.as_str(), &b.body)).collect();
let now: std::collections::HashSet<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
let changed: Vec<&str> =
st.bodies.iter().filter(|b| b.feature != id && old.get(b.name.as_str()).is_some_and(|o| !o.same(&b.body))).map(|b| b.name.as_str()).collect();
let removed: Vec<&str> = before.bodies.iter().map(|b| b.name.as_str()).filter(|n| !now.contains(n)).collect();
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
Ok(json!({"feature": id, "name": fname, "bodies": bodies, "ms": r.map(|r| r.ms)}))
// A warning (a Boolean that needed a retry, a reference found again elsewhere) is part of
// the reply: a script or an agent sees it without asking the timeline (#616).
let warning = r.as_ref().and_then(|r| r.warning.clone());
Ok(json!({"feature": id, "name": fname, "bodies": bodies, "created_bodies": created, "changed_bodies": changed,
"removed_bodies": removed, "warning": warning, "ms": r.map(|r| r.ms)}))
}
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
@@ -796,11 +810,15 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "`fit: reduce` takes a constant radius"));
}
let mut out = add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string), style, fit })?;
// The radius the fillet used, when `fit: reduce` reduced it.
// The radii the fillet used, when `fit: reduce` reduced it.
if let Some(id) = out.get("feature").and_then(Value::as_u64)
&& let Some(r) = s.model.result(id).and_then(|res| res.state.fitted_radius(id))
&& let Some(state) = s.model.result(id).map(|res| &res.state)
&& let Some(r) = state.fitted_radius(id)
{
out["radius_applied"] = serde_json::json!(r);
let each: Vec<Value> =
state.fitted_edges(id).unwrap_or_default().into_iter().map(|(p, r)| serde_json::json!({"edge": [p.x, p.y, p.z], "radius": r})).collect();
out["radii_applied"] = Value::Array(each);
}
Ok(out)
}
@@ -875,7 +893,11 @@ fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(a) = &angle {
check_expr(s, a, Kind::Angle, cmd, "angle")?;
}
add_feature(s, p, FeatureKind::Move { bodies, translate: t, rotate_axis: p.get("axis").and_then(vec3), angle })
add_feature(
s,
p,
FeatureKind::Move { bodies, translate: t, rotate_axis: p.get("axis").and_then(vec3), angle, copy: bool_(p, "copy").unwrap_or(false) },
)
}
fn source_features(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
@@ -1304,7 +1326,33 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] on the face to drill to"))?),
None => None,
};
add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread, to })
// A hole picked on a body of another component (a moved one, shown where it is placed) cuts
// that body: the pick is already in that body's frame, and the component's own bodies alone
// would leave nothing to cut.
let mut p = p.clone();
if points.is_none()
&& p.get("participants").is_none()
&& let Some(body) = body_of_other_component_at(s, position)
{
p["participants"] = json!([body]);
}
add_feature(s, &p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread, to })
}
/// The body whose face is nearest `p` (within its pick tolerance), when it belongs to another
/// component than the active one.
fn body_of_other_component_at(s: &Session, p: Vec3) -> Option<String> {
let st = s.model.state();
let (_, body) = st
.bodies
.iter()
.filter(|b| !b.body.is_mesh())
.filter_map(|b| {
let (d, _) = solidcraft_doc::appearance::nearest_face(b, p)?;
(d <= solidcraft_doc::appearance::pick_tolerance(b)).then_some((d, b))
})
.min_by(|a, b| a.0.total_cmp(&b.0))?;
(s.doc.model_body_component(body) != s.active_component).then(|| body.name.clone())
}
/// The points of a sketch a hole drills at when only the sketch is given.
@@ -1533,6 +1581,13 @@ mod offset_edit_guard_tests;
#[path = "offset_plane_face_tests.rs"]
mod offset_plane_face_tests;
#[cfg(test)]
#[path = "lineage_face_tests.rs"]
mod lineage_face_tests;
#[cfg(test)]
#[path = "stacked_face_tests.rs"]
mod stacked_face_tests;
#[cfg(test)]
#[path = "solid_move_copy_tests.rs"]
mod solid_move_copy_tests;
@@ -602,3 +602,79 @@ fn cylinder_auto_inference_refuses_ambiguity_curves_and_seam_crossing_atomically
assert_eq!(exact_cylinder_volume(&s), volume);
}
}
/// The design from #396: a loft from a Ø12 circle on a 40 × 30 × 20 box's top to a hexagon 15 up.
fn loft_on_box(op: &str) -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [20, 15]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 20, "body_name": "Box"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 20, "name": "A"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 35, "name": "B"}));
run(&mut s, "sketch.create", json!({"plane": "A", "name": "L0"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "diameter": 12}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "B", "name": "L1"}));
run(&mut s, "sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 4, "sides": 6}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "L0"}, {"sketch": "L1"}], "operation": op}));
s
}
#[test]
fn loft_joined_onto_a_box_exports_one_connected_mesh() {
// #396: one body, but its two shells only touched, and the STL came out in two pieces.
let mut apart = loft_on_box("new_body");
let m = measure(&mut apart);
assert_eq!(m["body_count"], 2);
let loft = m["bodies"][1]["volume_mm3"].as_f64().unwrap();
let mut s = loft_on_box("join");
let m = measure(&mut s);
assert_eq!(m["body_count"], 1);
assert_eq!(m["bodies"][0]["kernel"]["shells"], 1);
let v = volume(&mut s);
assert!(rel(v, 24000.0 + loft) < 1e-5, "{v} vs {}", 24000.0 + loft);
// The exported mesh, its vertices merged, is one connected piece.
let mesh = s.model.state().body("Box").unwrap().body.tessellate(0.01).unwrap();
let (positions, triangles) = solidcraft_io::weld(&mesh.positions, &mesh.triangles, 1e-4);
let mut piece: Vec<usize> = (0..positions.len()).collect();
let root = |piece: &mut Vec<usize>, mut i: usize| {
while piece[i] != i {
i = piece[i];
}
i
};
for t in &triangles {
let r0 = root(&mut piece, t[0] as usize);
for k in [1, 2] {
let rk = root(&mut piece, t[k] as usize);
piece[rk] = r0;
}
}
let pieces: std::collections::BTreeSet<usize> = triangles.iter().map(|t| root(&mut piece, t[0] as usize)).collect();
assert_eq!(pieces.len(), 1);
}
/// A cone that takes only a 1 mm chamfer off a cylinder's top edge leaves nearly all of the
/// cylinder inside it; the intersection is still the chamfered cylinder (#443).
#[test]
fn loft_intersect_that_trims_one_edge_changes_the_body() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 35}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "body_name": "Body"}));
for (name, offset, radius) in [("A", -1, 45), ("B", 11, 33)] {
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": offset, "name": name}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": radius}));
run(&mut s, "sketch.finish", json!({}));
}
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "A"}, {"sketch": "B"}], "operation": "intersect"}));
// r 35 up to z = 9, then a frustum to r 34 at z = 10.
let want = std::f64::consts::PI * (35.0 * 35.0 * 9.0 + (35.0 * 35.0 + 35.0 * 34.0 + 34.0 * 34.0) / 3.0);
let got = volume(&mut s);
assert!((got - want).abs() < want * 2e-4, "{got} vs {want}");
let m = measure(&mut s);
assert_eq!(m["bodies"][0]["face_types"]["cone"], 1, "{m}");
}
+219 -19
View File
@@ -1,5 +1,8 @@
//! Documents and files: new, open (designs and STEP), insert STEP, save, export.
#[path = "file_thumbnail.rs"]
mod thumbnail;
use serde_json::{Value, json};
use solidcraft_doc::Document;
use solidcraft_io::Format;
@@ -27,11 +30,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("save")
.key("Ctrl+S")
.noundo()
.params("path? (default: current file); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); preview?: bool (experimental saved picture, default: false)"),
.params("path? (default: current file); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (warm native geometry, default: false); preview?: bool (saved display meshes, default: false)"),
CommandSpec::new("file.save_as", "Save As", save_as)
.icon("save")
.noundo()
.params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); preview?: bool (experimental saved picture, default: false)"),
.params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (warm native geometry, default: false); preview?: bool (saved display meshes, default: false)"),
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list)
.noundo()
.params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"),
@@ -78,7 +81,7 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
let st = s.model.state();
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "warnings": sf.warnings}))
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": sf.warnings}))
}
/// Add a MeshImport feature for a 3MF or STL file.
@@ -98,7 +101,7 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
let st = s.model.state();
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "warnings": mf.warnings}))
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": mf.warnings}))
}
fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
@@ -131,16 +134,10 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
}
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mut doc = solidcraft_io::read_design(&bytes)?;
let mut doc = solidcraft_io::read_design_file(path, s.open_preview.take()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if let Some(stem) = default_name_for(&doc.name, path) {
doc.name = stem;
}
if let Some(notify) = s.open_preview.take()
&& let Some(preview) = solidcraft_io::read_design_preview(&bytes)
{
notify(preview);
}
*s = Session::new(doc);
s.path = Some(path.to_string());
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
@@ -172,13 +169,13 @@ fn rename_design(s: &mut Session, from: &str, to: &str) {
}
}
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> {
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool) -> Result<Value> {
// Saving an unnamed design names it after its file (not an undoable edit).
let renamed = default_name_for(&s.doc.name, path).map(|stem| (s.doc.name.clone(), stem));
if let Some((from, to)) = &renamed {
rename_design(s, from, to);
}
let r = write_file(s, path, format, compression, preview);
let r = write_file(s, path, format, compression, preview, cache);
if r.is_err()
&& let Some((from, to)) = &renamed
{
@@ -187,15 +184,35 @@ fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview:
r
}
fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> {
fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool) -> Result<Value> {
let compression = match compression {
"fast" => solidcraft_io::DesignCompression::Fast,
"compact" => solidcraft_io::DesignCompression::Compact,
_ => return Err(bad("file.save", "compression must be fast or compact")),
};
// Linked designs are kept relative to the folder the design is saved in.
let links = super::component_link::rebased(&s.doc, s.path.as_deref(), path);
let old_links = (links != s.doc.links).then(|| std::mem::replace(&mut s.doc_mut().links, links));
let r = write_bytes(s, path, format, compression, preview, cache);
if r.is_err()
&& let Some(old) = old_links
{
s.doc_mut().links = old;
}
r
}
fn write_bytes(
s: &mut Session,
path: &str,
format: &str,
compression: solidcraft_io::DesignCompression,
preview: bool,
cache: bool,
) -> Result<Value> {
let bytes = match format {
"container" => solidcraft_io::write_design_container_with_preview(&s.doc, compression, preview.then(|| saved_preview(s)).flatten().as_ref())?,
"json" => solidcraft_io::write_design(&s.doc),
"container" => s.design_container(compression, preview, cache)?,
"json" => solidcraft_io::write_design(&s.design()),
_ => return Err(bad("file.save", "format must be container or json")),
};
// Atomic, keeping the version being replaced as `<file>.bak`.
@@ -205,6 +222,29 @@ fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, prev
Ok(json!({"path": path, "bytes": bytes.len(), "name": s.doc.name}))
}
impl Session {
/// Container bytes for desktop and browser saves. Only already evaluated bodies
/// and warm meshes are eligible; saving never forces evaluation or tessellation.
pub fn design_container(&self, compression: solidcraft_io::DesignCompression, preview: bool, cache: bool) -> solidcraft_io::Result<Vec<u8>> {
let geometry = cache.then(|| saved_geometry(self)).flatten();
let preview = preview.then(|| saved_preview(self)).flatten();
let thumbnail = cache.then(|| thumbnail::saved(self)).flatten();
solidcraft_io::write_design_container_with_thumbnail(&self.design(), compression, preview.as_ref(), geometry.as_deref(), thumbnail.as_deref())
}
}
fn saved_geometry(s: &Session) -> Option<Vec<solidcraft_io::DesignGeometry>> {
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
return None;
}
let world = s.cached_world_state()?;
if world.bodies.is_empty() || world.bodies.len() > 4096 {
return None;
}
let painted = s.doc.painted(&world);
Some(painted.bodies.iter().map(|b| solidcraft_io::DesignGeometry { name: b.name.clone(), body: b.body.clone() }).collect())
}
/// Cache only warm display geometry. A save never waits for an unevaluated mesh.
fn saved_preview(s: &Session) -> Option<solidcraft_io::DesignPreview> {
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
@@ -246,6 +286,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
str_(p, "format").unwrap_or(default_design_format(&path)),
str_(p, "compression").unwrap_or("fast"),
bool_(p, "preview").unwrap_or(false),
bool_(p, "cache").unwrap_or(false),
)
}
@@ -261,6 +302,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
str_(p, "format").unwrap_or(default_design_format(&path)),
str_(p, "compression").unwrap_or("fast"),
bool_(p, "preview").unwrap_or(false),
bool_(p, "cache").unwrap_or(false),
)
}
@@ -271,7 +313,7 @@ fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Resu
if !bodies.is_empty() {
return Err(bad("file.export", "design JSON exports the complete document; omit bodies"));
}
solidcraft_io::write_design(&s.doc)
solidcraft_io::write_design(&s.design())
} else if format == Format::Step && bodies.is_empty() && !s.doc.occurrences.is_empty() {
solidcraft_io::step_assembly(&s.doc, &s.doc.painted(&s.model.state()), &name)?
} else {
@@ -359,7 +401,7 @@ mod tests {
for index in if round % 2 == 0 { [0, 1] } else { [1, 0] } {
let path = target.join(if index == 0 { "issue303-warm.json" } else { "issue303-warm.solidcraft" });
let began = std::time::Instant::now();
session.execute("file.save", &json!({"path":path,"preview":true})).unwrap();
session.execute("file.save", &json!({"path":path,"preview":true,"cache":false})).unwrap();
samples[index].push(began.elapsed().as_secs_f64() * 1000.0);
}
}
@@ -396,7 +438,9 @@ mod tests {
}
let save_path = std::env::temp_dir().join(format!("solidcraft-preview-save-{}.solidcraft", std::process::id()));
s.execute("file.save", &json!({"path":save_path})).unwrap();
assert!(solidcraft_io::read_design_preview(&std::fs::read(&save_path).unwrap()).is_none(), "ordinary saves omit redundant display data");
let saved = std::fs::read(&save_path).unwrap();
assert!(solidcraft_io::read_design_geometry(&saved).is_none(), "ordinary saves omit native geometry caches");
assert!(!saved.windows(b"cache/preview.bin".len()).any(|s| s == b"cache/preview.bin"), "ordinary saves omit redundant display meshes");
s.execute("file.save", &json!({"path":save_path,"preview":true})).unwrap();
assert!(solidcraft_io::read_design_preview(&std::fs::read(&save_path).unwrap()).is_some());
let _ = std::fs::remove_file(&save_path);
@@ -439,6 +483,7 @@ mod tests {
}
*s.world_cache.lock().unwrap() = None;
assert!(saved_preview(&s).is_none());
assert!(saved_geometry(&s).is_none());
assert!(s.world_cache.lock().unwrap().is_none(), "saving must not prepare cold placements");
for body in &s.world_state().bodies {
body.mesh();
@@ -450,6 +495,7 @@ mod tests {
}
assert!(!s.pending_moves.is_empty());
assert!(saved_preview(&s).is_none(), "uncaptured positions are not in the saved document");
assert!(saved_geometry(&s).is_none());
s.execute("component.capture_position", &json!({})).unwrap();
for body in &s.world_state().bodies {
body.mesh();
@@ -464,6 +510,131 @@ mod tests {
}
assert!(editing.active_sketch.is_some());
assert!(saved_preview(&editing).is_none());
assert!(saved_geometry(&editing).is_none());
}
#[test]
fn save_and_save_as_require_explicit_native_cache_opt_in() {
let mut session = Session::new(Document::new("Cache opt-in"));
session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
let world = session.world_state();
for body in &world.bodies {
body.mesh();
}
let path = std::env::temp_dir().join(format!("solidcraft-cache-opt-in-{}.solidcraft", std::process::id()));
for command in ["file.save", "file.save_as"] {
for cache in [None, Some(true), Some(false), None] {
let mut params = json!({"path":path});
if let Some(cache) = cache {
params["cache"] = json!(cache);
}
session.execute(command, &params).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(solidcraft_io::read_design_geometry(&bytes).is_some(), cache == Some(true), "{command}: {cache:?}");
assert_eq!(solidcraft_io::read_design_thumbnail(&bytes).is_some(), cache == Some(true), "{command}: {cache:?}");
assert!(
!bytes.windows(b"cache/preview.bin".len()).any(|entry| entry == b"cache/preview.bin"),
"display meshes require preview: true"
);
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
}
}
std::fs::remove_file(&path).unwrap();
std::fs::remove_file(format!("{}.bak", path.display())).unwrap();
}
#[test]
fn native_cache_does_not_force_meshes_or_supply_editing_state() {
let mut session = Session::new(Document::new("Native cache authority"));
session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
let world = session.world_state();
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()));
let mut cached = saved_geometry(&session).unwrap();
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()), "saving native geometry must not triangulate");
assert!(solidcraft_io::read_design_geometry(&bytes).is_some());
assert!(
solidcraft_io::read_design_geometry(&session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap()).is_none()
);
cached[0].body = solidcraft_kernel::box_solid(solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::new(100., 100., 100.)).unwrap();
let bytes =
solidcraft_io::write_design_container_with_cache(&session.doc, solidcraft_io::DesignCompression::Fast, None, Some(&cached)).unwrap();
let path = std::env::temp_dir().join(format!("solidcraft-native-forged-{}.solidcraft", std::process::id()));
std::fs::write(&path, bytes).unwrap();
let seen = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let callback_seen = seen.clone();
let mut reopened = Session {
open_preview: Some(Box::new(move |p| {
callback_seen.store(!p.meshes.is_empty(), std::sync::atomic::Ordering::Relaxed);
})),
..Session::default()
};
reopened.execute("doc.open", &json!({"path":path})).unwrap();
assert!(seen.load(std::sync::atomic::Ordering::Relaxed));
assert_eq!(reopened.doc, session.doc);
let measured = reopened.execute("inspect.measure", &json!({})).unwrap();
assert!((measured["bodies"][0]["volume_mm3"].as_f64().unwrap() - 6000.).abs() < 1e-6);
std::fs::remove_file(path).unwrap();
}
#[test]
#[ignore = "manual native cache save benchmark; generate issue303-step.json first"]
fn benchmark_native_cache_save() {
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target"));
let input = std::env::var_os("SOLIDCRAFT_BENCH_FILE").map(std::path::PathBuf::from).unwrap_or_else(|| target.join("issue303-step.json"));
let mut session = Session::new(solidcraft_io::read_design(&std::fs::read(input).unwrap()).unwrap());
for body in &session.world_state().bodies {
body.mesh();
}
let modes = [("json", false, "fast"), ("container", false, "fast"), ("container", true, "fast"), ("container", true, "compact")];
let mut samples = [Vec::new(), Vec::new(), Vec::new(), Vec::new()];
for round in 0..9 {
for i in if round % 2 == 0 { [0, 1, 2, 3] } else { [3, 2, 1, 0] } {
let (format, cache, compression) = modes[i];
let path = target.join(format!("issue303-native-{i}.solidcraft"));
let start = std::time::Instant::now();
session.execute("file.save", &json!({"path":path,"format":format,"cache":cache,"compression":compression})).unwrap();
samples[i].push(start.elapsed().as_secs_f64() * 1000.);
}
}
for (i, times) in samples.iter_mut().enumerate() {
times.sort_by(f64::total_cmp);
let bytes = std::fs::read(target.join(format!("issue303-native-{i}.solidcraft"))).unwrap();
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
assert_eq!(solidcraft_io::read_design_geometry(&bytes).is_some(), modes[i].1);
eprintln!("native_save mode={i} median_ms={:.3} bytes={}", times[4], bytes.len());
}
}
#[test]
#[ignore = "manual fresh-process opening benchmark; one encoding per process"]
fn benchmark_native_cache_open() {
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target"));
let mode = std::env::var("SOLIDCRAFT_BENCH_ENCODING").unwrap_or_else(|_| "2".into());
let path = target.join(format!("issue303-native-{mode}.solidcraft"));
let display = std::sync::Arc::new(std::sync::Mutex::new(None));
let notify = display.clone();
let start = std::time::Instant::now();
let mut session = Session {
open_preview: Some(Box::new(move |_| {
*notify.lock().unwrap() = Some(start.elapsed().as_secs_f64() * 1000.);
})),
..Session::default()
};
let result = session.execute("doc.open", &json!({"path":path})).unwrap();
assert_eq!(result["errors"], 0);
let reconstructed = start.elapsed().as_secs_f64() * 1000.;
let mut triangles = 0;
let world = session.world_state();
for body in &world.bodies {
triangles += body.mesh().triangles.len();
}
let ready = start.elapsed().as_secs_f64() * 1000.;
eprintln!(
"native_open mode={mode} provisional_ms={:?} rebuilt_ms={reconstructed:.3} meshes_ready_ms={ready:.3} bodies={} triangles={triangles}",
*display.lock().unwrap(),
world.bodies.len()
);
}
fn name_dir(tag: &str) -> std::path::PathBuf {
@@ -561,4 +732,33 @@ mod tests {
assert!(std::fs::read_to_string(&packaged).unwrap().contains("solidcraft/"));
std::fs::remove_dir_all(dir).unwrap();
}
/// A watched open reports the timeline's progress; cancelled, it skips the rebuild.
#[test]
fn watched_open_reports_progress_and_stops_when_cancelled() {
use solidcraft_doc::progress::{Progress, watch};
use std::sync::Arc;
let dir = std::env::temp_dir().join(format!("solidcraft-watched-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("three.solidcraft").to_string_lossy().to_string();
let mut s = Session::default();
for x in [0, 20, 40] {
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"corner":[x,0,0]})).unwrap();
}
s.execute("file.save", &json!({"path":path})).unwrap();
let progress = Arc::new(Progress::default());
let mut opened = Session::default();
watch(progress.clone(), || opened.execute("doc.open", &json!({"path":path}))).unwrap();
assert_eq!(progress.features(), (3, 3));
assert_eq!(opened.world_state().bodies.len(), 3);
let progress = Arc::new(Progress::default());
progress.cancel();
let mut stopped = Session::default();
watch(progress, || stopped.execute("doc.open", &json!({"path":path}))).unwrap();
assert!(stopped.model.results.iter().all(|r| r.skipped), "no feature was rebuilt after Cancel");
assert!(stopped.world_state().bodies.is_empty());
std::fs::remove_dir_all(dir).unwrap();
}
}
+150
View File
@@ -0,0 +1,150 @@
//! A portable picture from warm display meshes only; never evaluate on save.
use crate::Session;
use solidcraft_geom::Aabb3;
use solidcraft_render::{Camera, Rgb, Scene, SceneMesh, StandardView};
pub(super) fn saved(s: &Session) -> Option<Vec<u8>> {
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
return None;
}
let world = s.cached_world_state()?;
if world.bodies.is_empty() || world.bodies.len() > 1024 {
return None;
}
let mut scene = Scene::default();
let mut bounds = Aabb3::EMPTY;
let mut triangles = 0usize;
let mut points = 0usize;
for body in world.bodies.iter().filter(|b| s.body_visible(&b.name)) {
let mesh = body.cached_mesh()?;
triangles = triangles.saturating_add(mesh.triangles.len());
points = points.saturating_add(mesh.positions.len());
if triangles > 100_000 || points > 100_000 {
return None;
}
bounds = bounds.union(&mesh.bounds());
let channel = |x: f32| (x.clamp(0., 1.) * 255.).round() as u8;
let color = s.doc.body_color(body).map_or(crate::view::colors::BODY, |[r, g, b]| Rgb(channel(r), channel(g), channel(b)));
let face_colors =
s.doc.face_colors(body).into_iter().map(|(face, look)| (face as u32, Rgb(look.color[0], look.color[1], look.color[2]))).collect();
scene.meshes.push(SceneMesh { mesh, color, face_colors });
}
if bounds.is_empty() {
return None;
}
let mut camera = Camera::default();
camera.set_view(StandardView::Iso);
camera.fit(&bounds);
camera.distance *= 0.7;
scene.radius = (bounds.diagonal() * 0.5).max(camera.half_height()).max(10.);
scene.background = Some((crate::view::colors::BG_TOP, crate::view::colors::BG_BOTTOM));
let [width, height] = solidcraft_io::THUMBNAIL_SIZE;
if !within_raster_budget(&scene, &camera, width, height) {
return None;
}
let image = solidcraft_render::render(&scene, &camera, width, height);
solidcraft_render::png(width, height, image.rgba)
}
// Bound overlapping-triangle work too, not only mesh size. The renderer uses
// 2x supersampling; conservatively count each projected triangle's pixel box.
fn within_raster_budget(scene: &Scene, camera: &Camera, width: usize, height: usize) -> bool {
let (w, h) = ((width * 2) as f64, (height * 2) as f64);
let matrix = camera.view_proj(w / h, scene.radius.max(1.));
let mut pixels = 0usize;
let mut colors = 0usize;
for entry in &scene.meshes {
colors = colors.saturating_add(entry.mesh.triangles.len().saturating_mul(entry.face_colors.len()));
if colors > 1_000_000 {
return false;
}
let mut projected = Vec::with_capacity(entry.mesh.positions.len());
for point in &entry.mesh.positions {
let q = matrix.apply(*point);
if q.iter().any(|x| !x.is_finite()) || q[3] <= 1e-9 {
return false;
}
let pixel = [(q[0] / q[3] + 1.) * 0.5 * w, (1. - q[1] / q[3]) * 0.5 * h];
if pixel.iter().any(|x| !x.is_finite()) {
return false;
}
projected.push(pixel);
}
for triangle in &entry.mesh.triangles {
let [Some(a), Some(b), Some(c)] = triangle.map(|i| projected.get(i as usize).copied()) else {
return false;
};
let dx = (a[0].max(b[0]).max(c[0]).ceil().clamp(0., w) - a[0].min(b[0]).min(c[0]).floor().clamp(0., w) + 2.) as usize;
let dy = (a[1].max(b[1]).max(c[1]).ceil().clamp(0., h) - a[1].min(b[1]).min(c[1]).floor().clamp(0., h) + 2.) as usize;
pixels = pixels.saturating_add(dx.saturating_mul(dy));
if pixels > 4_000_000 {
return false;
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn portable_thumbnail_refuses_excessive_overdraw() {
let mut session = Session::default();
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
let mesh = session.world_state().bodies[0].mesh();
let bounds = mesh.bounds();
let mut camera = Camera::default();
camera.set_view(StandardView::Iso);
camera.fit(&bounds);
let mut scene = Scene { radius: bounds.diagonal(), ..Scene::default() };
scene.meshes.push(SceneMesh { mesh: mesh.clone(), color: Rgb(100, 100, 100), face_colors: vec![] });
assert!(within_raster_budget(&scene, &camera, 240, 150));
let mut repeated = (*mesh).clone();
repeated.triangles = vec![repeated.triangles[0]; 100_000];
scene.meshes[0].mesh = std::sync::Arc::new(repeated);
assert!(!within_raster_budget(&scene, &camera, 240, 150));
}
#[test]
fn portable_thumbnail_preserves_saved_visibility() {
let mut session = Session::default();
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30,"body_name":"Visible"})).unwrap();
session.execute("solid.box", &serde_json::json!({"length":100,"width":100,"height":100,"body_name":"Hidden"})).unwrap();
session.execute("browser.visibility", &serde_json::json!({"items":["Hidden"],"visible":false})).unwrap();
let world = session.world_state();
for body in &world.bodies {
body.mesh();
}
let expected = saved(&session).unwrap();
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
let doc = solidcraft_io::read_design(&bytes).unwrap();
assert_eq!(doc.visibility.hidden_bodies, vec!["Hidden"]);
assert_eq!(doc, *session.design());
assert_eq!(solidcraft_io::read_design_thumbnail(&bytes), Some(expected));
let reopened = Session::new(doc);
assert_eq!(reopened.visibility, session.visibility);
let disabled = session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap();
assert_eq!(solidcraft_io::read_design(&disabled).unwrap().visibility, session.visibility);
assert!(solidcraft_io::read_design_thumbnail(&disabled).is_none());
}
#[test]
fn portable_thumbnail_never_forces_cold_evaluation_or_meshing() {
let mut session = Session::default();
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
let world = session.world_state();
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()));
assert!(saved(&session).is_none());
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()));
for body in &world.bodies {
body.mesh();
}
let png = saved(&session).unwrap();
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
assert_eq!(solidcraft_io::read_design_thumbnail(&bytes), Some(png));
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
let disabled = session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap();
assert!(solidcraft_io::read_design_thumbnail(&disabled).is_none());
}
}
+14 -11
View File
@@ -22,7 +22,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("material.assign", "Physical Material", physical_material)
.at("SOLID", "MODIFY")
.icon("material")
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …; `material.list`)"),
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …, or one made with material.create; `material.list`)"),
CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
@@ -34,7 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("appearance.list", "List Appearances", appearance_list)
.noundo()
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
CommandSpec::new("material.list", "List Materials", material_list).noundo().doc_only(),
CommandSpec::new("material.list", "List Materials", material_list)
.noundo()
.doc_only()
.params("→ the built-in materials, then the design's own (user: true, with their look)"),
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
];
@@ -283,11 +286,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "material.assign";
let bodies = string_list(p, "bodies");
let material = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
let canonical = solidcraft_doc::MATERIALS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(material.trim()))
.map(|(n, _)| n.to_string())
.ok_or_else(|| bad(cmd, format!("unknown material `{material}` (see material.list)")))?;
let canonical = s.doc.material_name(material).ok_or_else(|| bad(cmd, format!("unknown material `{material}` (see material.list)")))?;
let st = s.model.state();
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
return Err(bad(cmd, "`bodies` must list existing bodies"));
@@ -303,7 +302,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
}
/// A colour parameter: `"#rrggbb"` or `[r, g, b]` (0–255).
fn color_param(v: &Value) -> Option<[u8; 3]> {
pub(super) fn color_param(v: &Value) -> Option<[u8; 3]> {
if let Some(t) = v.as_str() {
let h = t.trim().trim_start_matches('#');
if h.len() != 6 || !h.is_ascii() {
@@ -455,7 +454,7 @@ fn dist_to(b: &solidcraft_doc::ModelBody, p: solidcraft_geom::Vec3) -> f64 {
c.dist(p)
}
fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
pub(super) fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
json!({"name": l.name, "color": l.hex(), "opacity": l.opacity})
}
@@ -524,6 +523,10 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
fn material_list(_s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"materials": solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d})).collect::<Vec<_>>()}))
fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d}));
let user = s.doc.user_materials.iter().map(
|m| json!({"name": m.name, "density_g_cm3": m.density, "appearance": s.doc.material_look(&m.name).as_ref().map(look_json), "user": true}),
);
Ok(json!({"materials": built_in.chain(user).collect::<Vec<_>>()}))
}
+66 -22
View File
@@ -6,7 +6,7 @@
use serde_json::{Value, json};
use solidcraft_doc::expr::Kind;
use solidcraft_doc::joints::{self, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Snap};
use solidcraft_geom::Vec3;
use solidcraft_geom::{Aabb3, Vec3};
use super::CommandSpec;
use crate::params::{bad, bool_, num, str_, string_list, vec3};
@@ -37,7 +37,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.list", "List Joints", list).noundo().params("→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts"),
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).params("re-place the occurrences from the joints → conflicts, errors, dof"),
CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
"bodies?: [names] (default: all, in world placement) → pairs that overlap with the overlap volume, area, bounding box and centre",
"bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, so any number of bodies is fine; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out)",
),
];
@@ -523,35 +523,79 @@ fn solve_cmd(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn interference(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.interference";
let want = string_list(p, "bodies");
let st = s.world_state();
let bodies: Vec<&solidcraft_doc::ModelBody> =
st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).filter(|b| !b.body.is_mesh()).collect();
if bodies.len() > 200 {
return Err(bad(cmd, "too many bodies (200 at most)"));
}
let mut hits = Vec::new();
for (i, a) in bodies.iter().enumerate() {
for b in bodies.iter().skip(i + 1) {
let (ba, bb) = (a.mesh().bounds(), b.mesh().bounds());
let apart = ba.max.x < bb.min.x
|| bb.max.x < ba.min.x
|| ba.max.y < bb.min.y
|| bb.max.y < ba.min.y
|| ba.max.z < bb.min.z
|| bb.max.z < ba.min.z;
if apart {
// Prune by bounding boxes first (sort and sweep), so the cost follows the pairs that can
// touch rather than the square of the body count.
let boxes: Vec<Aabb3> = bodies.iter().map(|b| b.mesh().bounds()).collect();
let (pairs, skipped) = overlapping_pairs(&boxes, MAX_INTERFERENCE_PAIRS);
let (mut hits, mut unchecked) = (Vec::new(), Vec::new());
for (i, j) in pairs {
let (Some(a), Some(b)) = (bodies.get(i), bodies.get(j)) else { continue };
// A pair whose shared part can't be worked out is reported, not passed as clear.
let (shared, report) =
solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect));
let mut skip = |reason: String| unchecked.push(json!({"a": a.name, "b": b.name, "reason": reason}));
let shared = match shared {
Ok(o) => o,
Err(e) => {
skip(e.to_string());
continue;
}
let Ok(Some(o)) = solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect) else { continue };
let Ok(m) = solidcraft_kernel::measure(&o) else { continue };
if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) {
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}}));
};
if let Some(reason) = report.unverified() {
skip(reason);
}
let Some(o) = shared else { continue };
let m = match solidcraft_kernel::measure(&o) {
Ok(m) => m,
Err(e) => {
skip(e.to_string());
continue;
}
};
if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) {
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}}));
}
}
Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len(), "unchecked": unchecked,
"truncated": skipped > 0, "skipped_pairs": skipped}))
}
/// Most pairs with overlapping bounding boxes `inspect.interference` intersects in one call.
const MAX_INTERFERENCE_PAIRS: usize = 20_000;
/// Index pairs `(i, j)`, `i < j`, whose boxes overlap or touch (sort and sweep along X), at most
/// `limit` of them, plus how many more there were.
fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, usize)>, usize) {
let mut order: Vec<usize> = (0..boxes.len()).collect();
order.sort_by(|&a, &b| match (boxes.get(a), boxes.get(b)) {
(Some(a), Some(b)) => a.min.x.total_cmp(&b.min.x),
_ => std::cmp::Ordering::Equal,
});
let (mut pairs, mut skipped) = (Vec::new(), 0);
for (k, &i) in order.iter().enumerate() {
let Some(a) = boxes.get(i) else { continue };
for &j in order.iter().skip(k + 1) {
let Some(b) = boxes.get(j) else { continue };
// Sorted by min.x: once one starts past `a`, all later ones do.
if b.min.x > a.max.x {
break;
}
if b.max.x < a.min.x || a.max.y < b.min.y || b.max.y < a.min.y || a.max.z < b.min.z || b.max.z < a.min.z {
continue;
}
if pairs.len() < limit {
pairs.push((i.min(j), i.max(j)));
} else {
skipped += 1;
}
}
}
Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len()}))
pairs.sort_unstable();
(pairs, skipped)
}
#[cfg(test)]
+100
View File
@@ -151,6 +151,57 @@ fn rigid_cycles_solve_and_conflicts_report() {
assert!(s.execute("joint.create", &json!({"type": "hinge"})).is_err());
}
/// A 30 x 30 x 3 plate with a through slot whose x = 20 wall carries one rib (0.3 tall, 0.8
/// wide), and a peg in the slot with 0.15 clearance all round.
fn ribbed_slot_and_peg(s: &mut Session, peg_x1: f64) {
run(s, "sketch.create", json!({"plane": "XY"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 30]}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 3, "body_name": "Plate"}));
run(s, "sketch.create", json!({"plane": "XY", "offset": -1}));
let slot = json!([[10, 5], [20, 5], [20, 14.6], [19.7, 14.6], [19.7, 15.4], [20, 15.4], [20, 25], [10, 25]]);
run(s, "sketch.line", json!({"points": slot, "closed": true}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 5, "operation": "cut", "targets": ["Plate"]}));
run(s, "sketch.create", json!({"plane": "XY"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [10.15, 5.15], "p1": [peg_x1, 24.85]}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 3, "operation": "new", "body_name": "Peg"}));
}
/// The peg overlaps only the rib: 0.15 x 0.8 x 3 = 0.36 mm³ (#442).
#[test]
fn interference_finds_a_peg_overlapping_a_small_rib() {
let mut s = Session::default();
ribbed_slot_and_peg(&mut s, 19.85);
let i = run(&mut s, "inspect.interference", json!({}));
assert_eq!(i["count"], 1, "{i}");
assert!((i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0) - 0.36).abs() < 1e-6, "{i}");
let (lo, hi) = (&i["interferences"][0]["bbox"]["min"], &i["interferences"][0]["bbox"]["max"]);
assert!((lo[0].as_f64().unwrap_or(0.0) - 19.7).abs() < 1e-6 && (hi[0].as_f64().unwrap_or(0.0) - 19.85).abs() < 1e-6, "{i}");
}
/// An arc revolved up to a post ends on the post's wall, where the two bodies can't be
/// intersected: the pair is listed as unchecked instead of passing as clear.
#[test]
fn interference_lists_a_pair_it_could_not_check() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 14, -5], "radius": 4, "height": 15, "body_name": "Post"}));
run(&mut s, "sketch.create", json!({"name": "Profile", "plane": "XZ"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [15, 5]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": {"origin": [0, 0, 0], "dir": [0, 0, 1]}, "to": [4, 14, 2], "body_name": "Arc"}));
let i = run(&mut s, "inspect.interference", json!({}));
assert_eq!(i["count"], 0, "{i}");
let unchecked = i["unchecked"].as_array().cloned().unwrap_or_default();
assert_eq!(unchecked.len(), 1, "{i}");
assert!(unchecked[0]["reason"].as_str().is_some_and(|r| r.starts_with("not verified") && !r.contains("displacement")), "{i}");
// Bodies that are clear of each other, or overlap plainly, leave the list empty.
let mut s = two_cubes();
let i = run(&mut s, "inspect.interference", json!({}));
assert_eq!(i["unchecked"].as_array().map(Vec::len), Some(0), "{i}");
}
#[test]
fn motion_links_and_interference() {
let mut s = two_cubes();
@@ -195,3 +246,52 @@ fn faces_mate_by_default() {
run(&mut s, "joint.edit", json!({"joint": "Stack", "flip": true}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 0.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0)));
}
/// #527: an assembly with its fasteners passes 200 bodies quickly; the clash check must still
/// cover the whole design. 201 separate 1 mm cubes 2 mm apart, plus one cube pushed half-way
/// into the first: exactly that pair overlaps, by 0.5 mm³.
#[test]
fn interference_checks_designs_with_more_than_200_bodies() {
let mut s = Session::default();
for i in 0..201 {
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [2 * i, 0, 0], "operation": "new"}));
}
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [0.5, 0, 0], "operation": "new", "body_name": "Clash"}));
assert_eq!(s.model.state().bodies.len(), 202);
let i = run(&mut s, "inspect.interference", json!({}));
assert_eq!(i["checked"], 202, "{i}");
assert_eq!(i["count"], 1, "{i}");
let hit = &i["interferences"][0];
let mut pair = [hit["a"].as_str().unwrap_or(""), hit["b"].as_str().unwrap_or("")];
pair.sort_unstable();
assert_eq!(pair, ["Body1", "Clash"], "{i}");
assert!((hit["volume_mm3"].as_f64().unwrap_or(0.0) - 0.5).abs() < 1e-6, "{i}");
}
/// The bounding-box sweep behind `inspect.interference` finds exactly the pairs a check of every
/// pair finds (touching boxes count), and reports how many it left out past its limit.
#[test]
fn interference_box_sweep_matches_every_pair_check() {
use solidcraft_geom::Aabb3;
let cube = |x: f64, y: f64, z: f64, e: f64| Aabb3 { min: Vec3::new(x, y, z), max: Vec3::new(x + e, y + e, z + e) };
// A deterministic scatter: overlapping, touching, and apart in only one axis.
let boxes: Vec<Aabb3> = (0..60)
.map(f64::from)
.map(|i| cube((i * 7.3) % 20.0, (i * 3.1) % 9.0, (i * 5.7) % 4.0, 1.0 + (i % 3.0)))
.chain([cube(30.0, 0.0, 0.0, 1.0), cube(31.0, 0.0, 0.0, 1.0)])
.collect();
let mut every = Vec::new();
for (i, a) in boxes.iter().enumerate() {
for (j, b) in boxes.iter().enumerate().skip(i + 1) {
let apart = a.max.x < b.min.x || b.max.x < a.min.x || a.max.y < b.min.y || b.max.y < a.min.y || a.max.z < b.min.z || b.max.z < a.min.z;
if !apart {
every.push((i, j));
}
}
}
assert!(every.contains(&(60, 61)), "touching boxes are a candidate pair");
let (pairs, skipped) = super::overlapping_pairs(&boxes, usize::MAX);
assert_eq!((pairs, skipped), (every.clone(), 0));
let (pairs, skipped) = super::overlapping_pairs(&boxes, 10);
assert_eq!((pairs.len(), skipped), (10, every.len() - 10));
}
+166
View File
@@ -0,0 +1,166 @@
//! Sketches on flat faces that no construction certificate names: a Cut's walls and floor, a
//! chamfer, a pattern's copies, a side a Join merged from two features, the pieces of a split
//! face (#491, #492, #493, #557). They attach by the face's exact history name
//! (`FaceCertificate::Lineage`), follow the face through parameter edits and survive a save.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use crate::Session;
use serde_json::{Value, json};
use solidcraft_doc::FaceAttachmentStatus;
use solidcraft_doc::config::FaceCertificate;
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command} {params}: {e}"))
}
fn sketch_on(s: &mut Session, at: [f64; 3], name: &str) {
run(s, "sketch.create", json!({"plane": {"face": at}, "name": name}));
run(s, "sketch.finish", json!({}));
}
fn status(s: &Session, sketch: &str) -> FaceAttachmentStatus {
let id = s.doc.find_feature(sketch).map(|f| f.id).unwrap();
s.placed_document().sketch_face_attachment(id, &s.model)
}
fn healthy(s: &Session, sketch: &str) -> (solidcraft_geom::Plane, Option<FaceCertificate>) {
match status(s, sketch) {
FaceAttachmentStatus::Healthy { plane, target, .. } => (plane, target.certificate),
other => panic!("{sketch}: {other:?}"),
}
}
fn no_errors(s: &Session) {
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
}
fn reopened(s: &Session) -> Session {
let mut t = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
t.refresh();
no_errors(&t);
t
}
/// A 40 x 30 x 10 block with a slot cut 5 deep across it from x = `slot_x` (a parameter), 10 wide.
fn slotted_block() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Block"}));
run(&mut s, "parameters.add", json!({"name": "slot_x", "expression": "15 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "SlotSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [15, -5], "p1": [25, 35]}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p1"], "type": "horizontal", "value": "slot_x"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "10 mm"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "SlotSketch", "distance": 12, "start_offset": 5, "operation": "cut", "name": "Slot"}));
no_errors(&s);
s
}
#[test]
fn a_cut_wall_and_floor_take_a_sketch_that_follows_the_cut() {
let mut s = slotted_block();
sketch_on(&mut s, [15., 15., 7.5], "Wall");
sketch_on(&mut s, [20., 15., 5.], "Floor");
let (wall, certificate) = healthy(&s, "Wall");
assert_eq!(certificate, Some(FaceCertificate::Lineage));
// The wall faces into the slot (+x), its frame's origin over the design origin.
assert!((wall.normal().x - 1.).abs() < 1e-9 && wall.origin.x.abs() > 14.9 && (wall.origin.x - 15.).abs() < 1e-9, "{wall:?}");
assert!((wall.origin.y.abs() + wall.origin.z.abs()) < 1e-9, "{wall:?}");
let (floor, _) = healthy(&s, "Floor");
assert!((floor.normal().z - 1.).abs() < 1e-9 && (floor.origin.z - 5.).abs() < 1e-9, "{floor:?}");
// The slot moves: the sketch on its wall moves with it.
run(&mut s, "parameters.change", json!({"name": "slot_x", "expression": "20 mm"}));
no_errors(&s);
let (wall, _) = healthy(&s, "Wall");
assert!((wall.origin.x - 20.).abs() < 1e-9, "{wall:?}");
let t = reopened(&s);
assert!((healthy(&t, "Wall").0.origin.x - 20.).abs() < 1e-9);
assert!((healthy(&t, "Floor").0.origin.z - 5.).abs() < 1e-9);
}
#[test]
fn the_pieces_of_a_split_top_take_a_sketch() {
let mut s = slotted_block();
// The slot splits the block's top in two; each piece takes a sketch (on that face's plane).
for (name, x) in [("Left", 5.), ("Right", 35.)] {
sketch_on(&mut s, [x, 15., 10.], name);
assert!((healthy(&s, name).0.origin.z - 10.).abs() < 1e-9, "{name}");
}
run(&mut s, "parameters.change", json!({"name": "slot_x", "expression": "20 mm"}));
no_errors(&s);
assert!((healthy(&s, "Left").0.origin.z - 10.).abs() < 1e-9);
}
#[test]
fn a_chamfer_face_takes_a_sketch_that_follows_the_chamfer() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "bevel", "expression": "3 mm"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "name": "Base"}));
run(&mut s, "solid.chamfer", json!({"edges": [[40, 15, 10]], "distance": "bevel", "name": "Bevel"}));
no_errors(&s);
sketch_on(&mut s, [38.5, 15., 8.5], "OnBevel");
let (plane, certificate) = healthy(&s, "OnBevel");
assert_eq!(certificate, Some(FaceCertificate::Lineage));
let n = std::f64::consts::FRAC_1_SQRT_2;
assert!((plane.normal().x - n).abs() < 1e-9 && (plane.normal().z - n).abs() < 1e-9, "{plane:?}");
// A bigger chamfer moves the face inward: the sketch goes with it.
let height = |p: &solidcraft_geom::Plane| (p.origin - solidcraft_geom::Vec3::new(40., 0., 10.)).dot(p.normal());
assert!((height(&plane) + 3. * n).abs() < 1e-9, "{plane:?}");
run(&mut s, "parameters.change", json!({"name": "bevel", "expression": "5 mm"}));
no_errors(&s);
assert!((height(&healthy(&s, "OnBevel").0) + 5. * n).abs() < 1e-9);
healthy(&reopened(&s), "OnBevel");
}
/// A plate with a boss patterned 3 times along x, `pitch` apart.
fn patterned_bosses(sketch_on_seed_first: bool) -> Session {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "pitch", "expression": "20 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [80, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "sketch.create", json!({"plane": {"face": [40, 10, 5]}}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 4}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 6, "operation": "join", "name": "Boss"}));
if sketch_on_seed_first {
sketch_on(&mut s, [10., 10., 11.], "Seed");
}
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Boss"], "dir1": [1, 0, 0], "count1": 3, "spacing1": "pitch", "name": "Row"}));
no_errors(&s);
s
}
#[test]
fn each_patterned_copy_takes_its_own_sketch() {
let mut s = patterned_bosses(true);
// A sketch made on the seed before the pattern keeps its construction certificate: the
// copies carry it too, but are named as copies.
assert!(matches!(healthy(&s, "Seed").1, Some(FaceCertificate::ExtrudeCap { .. })));
for (name, x) in [("First", 10.), ("Middle", 30.), ("Last", 50.)] {
sketch_on(&mut s, [x, 10., 11.], name);
assert!((healthy(&s, name).0.origin.z - 11.).abs() < 1e-9, "{name}");
}
run(&mut s, "parameters.change", json!({"name": "pitch", "expression": "25 mm"}));
no_errors(&s);
for name in ["Seed", "First", "Middle", "Last"] {
healthy(&s, name);
}
let t = reopened(&s);
for name in ["Seed", "Middle", "Last"] {
healthy(&t, name);
}
}
#[test]
fn a_piece_of_a_face_split_on_purpose_still_needs_a_fixed_plane() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Modified"}));
run(&mut s, "solid.split_face", json!({"body": "Modified", "faces": [[5, 5, 10]], "plane": {"origin": [5, 0, 0], "normal": [1, 0, 0]}}));
let error = s.execute("sketch.create", &json!({"plane": {"face": [2.5, 5, 10]}})).unwrap_err().to_string();
assert!(error.contains("fixed:true"), "{error}");
}
+214
View File
@@ -0,0 +1,214 @@
//! Physical materials defined in a design (`material.create`, `material.edit`,
//! `material.delete`): a name, a density and, optionally, a look. They are assigned like the
//! built-in library's (`material.assign`), set masses (`component.properties`) and fill
//! configuration material cells. The design keeps them, so they are saved and undone like
//! features.
use serde_json::{Value, json};
use solidcraft_doc::appearance::{Look, library};
use solidcraft_doc::config::Column;
use solidcraft_doc::{MATERIALS, UserMaterial, valid_density};
use super::CommandSpec;
use super::inspect::{color_param, look_json};
use crate::params::{bad, str_};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("material.create", "New Physical Material", create).icon("material").params(
"name (not a built-in material's); density_g_cm3: (0, 1000]; appearance?: library name (appearance.library) | color?: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1): the look bodies of this material get without an appearance of their own. Assign it with material.assign",
),
CommandSpec::new("material.edit", "Edit Physical Material", edit).icon("material").enabled(has_user_materials).params(
"material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity? (as material.create; color: null clears the look)",
),
CommandSpec::new("material.delete", "Delete Physical Material", delete).enabled(has_user_materials).params(
"material: a material defined in this design; replace?: material for the bodies and configuration cells that use it (without it, a material in use is refused)",
),
];
/// Most materials a design defines.
const MAX_USER_MATERIALS: usize = 1000;
fn has_user_materials(s: &Session) -> std::result::Result<(), String> {
if s.doc.user_materials.is_empty() { Err("the design defines no physical materials (material.create)".into()) } else { Ok(()) }
}
/// A new material name: 1–128 characters, not a built-in material's or another design
/// material's (`except`: the one being renamed).
fn new_name(s: &Session, cmd: &str, name: &str, except: Option<&str>) -> Result<String> {
let name = name.trim();
if name.is_empty() || name.chars().count() > 128 {
return Err(bad(cmd, "`name` must have 1–128 characters"));
}
if MATERIALS.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)) {
return Err(bad(cmd, format!("`{name}` is a built-in material (material.list)")));
}
let taken = |m: &&UserMaterial| m.name.eq_ignore_ascii_case(name) && except.is_none_or(|e| !m.name.eq_ignore_ascii_case(e));
if s.doc.user_materials.iter().any(|m| taken(&m)) {
return Err(bad(cmd, format!("the design already has a material `{name}`")));
}
Ok(name.to_string())
}
fn density(cmd: &str, p: &Value) -> Result<f64> {
p.get("density_g_cm3")
.and_then(Value::as_f64)
.filter(|d| valid_density(*d))
.ok_or_else(|| bad(cmd, "`density_g_cm3` must be a number above 0 and at most 1000"))
}
/// The look `appearance` / `color` / `opacity` give: `None` when none is given, `Some(None)`
/// to clear it (`color: null`).
fn look(cmd: &str, p: &Value, material: &str) -> Result<Option<Option<Look>>> {
let opacity = match p.get("opacity") {
Some(o) => Some(o.as_f64().filter(|x| x.is_finite() && (0.0..=1.0).contains(x)).ok_or_else(|| bad(cmd, "`opacity` must be 0…1"))?),
None => None,
};
let mut l = match (str_(p, "appearance"), p.get("color")) {
(Some(_), Some(c)) if !c.is_null() => return Err(bad(cmd, "give `appearance` or `color`, not both")),
(Some(n), _) => library(n).ok_or_else(|| bad(cmd, format!("no appearance `{n}` in the library (appearance.library)")))?,
(None, Some(Value::Null)) => return Ok(Some(None)),
(None, Some(c)) => {
let mut l = Look::custom(color_param(c).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?, 1.0);
l.name = material.to_string();
l
}
(None, None) if opacity.is_some() => return Err(bad(cmd, "`opacity` needs `appearance` or `color`")),
(None, None) => return Ok(None),
};
if let Some(o) = opacity {
l.opacity = o;
}
Ok(Some(Some(l)))
}
fn material_json(m: &UserMaterial) -> Value {
json!({"name": m.name, "density_g_cm3": m.density, "appearance": m.appearance.as_ref().map(look_json), "user": true})
}
/// The index of the design material `material` names.
fn existing(s: &Session, cmd: &str, p: &Value) -> Result<usize> {
let name = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
s.doc
.user_materials
.iter()
.position(|m| m.name.eq_ignore_ascii_case(name.trim()))
.ok_or_else(|| bad(cmd, format!("the design defines no material `{name}` (built-in materials cannot be changed)")))
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "material.create";
let name = new_name(s, cmd, str_(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?, None)?;
if s.doc.user_materials.len() >= MAX_USER_MATERIALS {
return Err(bad(cmd, format!("a design defines at most {MAX_USER_MATERIALS} materials")));
}
let material = UserMaterial { density: density(cmd, p)?, appearance: look(cmd, p, &name)?.flatten(), name };
let out = material_json(&material);
s.doc_mut().user_materials.push(material);
Ok(out)
}
/// Which configuration columns hold materials.
fn material_columns(s: &Session) -> Vec<bool> {
s.doc.configs.columns.iter().map(|c| matches!(c, Column::Material(_))).collect()
}
/// Where material `name` is used: bodies, and configuration rows with a cell naming it.
fn uses(s: &Session, name: &str) -> (Vec<String>, Vec<String>) {
let bodies = s.doc.materials.iter().filter(|(_, m)| m.eq_ignore_ascii_case(name)).map(|(b, _)| b.clone()).collect();
let columns = material_columns(s);
let rows = s
.doc
.configs
.rows
.iter()
.filter(|r| r.cells.iter().zip(&columns).any(|(cell, material)| *material && cell.trim().eq_ignore_ascii_case(name)))
.map(|r| r.name.clone())
.collect();
(bodies, rows)
}
/// Point every use of material `from` (body assignments, configuration cells) at `to`
/// ("Default" removes the body assignments).
fn move_uses(s: &mut Session, from: &str, to: &str) {
let columns = material_columns(s);
let d = s.doc_mut();
let bodies: Vec<String> = d.materials.iter().filter(|(_, m)| m.eq_ignore_ascii_case(from)).map(|(b, _)| b.clone()).collect();
for b in bodies {
if to == "Default" {
d.materials.remove(&b);
} else {
d.materials.insert(b, to.to_string());
}
}
for row in &mut d.configs.rows {
for (cell, material) in row.cells.iter_mut().zip(&columns) {
if *material && cell.trim().eq_ignore_ascii_case(from) {
*cell = to.to_string();
}
}
}
}
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "material.edit";
let i = existing(s, cmd, p)?;
let old = s.doc.user_materials.get(i).cloned().ok_or_else(|| bad(cmd, "no such material"))?;
let name = match str_(p, "name") {
Some(n) => new_name(s, cmd, n, Some(&old.name))?,
None => old.name.clone(),
};
let density = if p.get("density_g_cm3").is_some() { density(cmd, p)? } else { old.density };
let appearance = match look(cmd, p, &name)? {
Some(l) => l,
// A colour named after the material keeps following its name.
None => old.appearance.clone().map(|mut l| {
if l.name == old.name {
l.name = name.clone();
}
l
}),
};
let material = UserMaterial { name: name.clone(), density, appearance };
let mut out = material_json(&material);
if let Some(m) = s.doc_mut().user_materials.get_mut(i) {
*m = material;
}
if name != old.name {
move_uses(s, &old.name, &name);
}
out["bodies"] = json!(uses(s, &name).0);
Ok(out)
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "material.delete";
let i = existing(s, cmd, p)?;
let name = s.doc.user_materials.get(i).map(|m| m.name.clone()).ok_or_else(|| bad(cmd, "no such material"))?;
let (bodies, rows) = uses(s, &name);
let replace = match str_(p, "replace") {
Some(r) => {
let r = s.doc.material_name(r).ok_or_else(|| bad(cmd, format!("unknown material `{r}` (see material.list)")))?;
if r.eq_ignore_ascii_case(&name) {
return Err(bad(cmd, "`replace` must be another material"));
}
Some(r)
}
None if bodies.is_empty() && rows.is_empty() => None,
None => {
let mut used: Vec<String> =
bodies.iter().map(|b| format!("body `{b}`")).chain(rows.iter().map(|r| format!("configuration `{r}`"))).collect();
if used.len() > 10 {
let more = used.len() - 10;
used.truncate(10);
used.push(format!("{more} more"));
}
return Err(bad(cmd, format!("`{name}` is used by {}: give `replace` (a material for them)", used.join(", "))));
}
};
if let Some(r) = &replace {
move_uses(s, &name, r);
}
s.doc_mut().user_materials.remove(i);
Ok(json!({"deleted": name, "replaced_by": replace, "bodies": bodies}))
}
+4
View File
@@ -22,10 +22,12 @@ mod face;
mod features;
mod features_more;
pub use features::auto_operation;
mod component_link;
mod config;
mod file;
mod inspect;
pub(crate) mod joints;
mod materials;
mod measure_sel;
mod mesh;
pub(crate) mod motion;
@@ -203,6 +205,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
v.extend(component::COMMANDS.iter());
v.extend(component_link::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(config::COMMANDS.iter());
@@ -214,6 +217,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(parameters::COMMANDS.iter());
v.extend(file::COMMANDS.iter());
v.extend(inspect::COMMANDS.iter());
v.extend(materials::COMMANDS.iter());
v.extend(section::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
v
+115
View File
@@ -424,3 +424,118 @@ fn instance_styles_follow_source_identity_after_collision_rename_and_pending_mov
assert_eq!(imported.bodies[0].body.color(), Some([1.0, 0.0, 0.0]));
assert!(imported.bodies[0].body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0]));
}
/// A design file path in a fresh temporary folder (removed by the caller).
fn temp_design(tag: &str, ext: &str) -> (std::path::PathBuf, String) {
let dir = std::env::temp_dir().join(format!("solidcraft-visibility-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("design.{ext}")).to_string_lossy().to_string();
(dir, path)
}
/// #531: hiding a placement and a body is saved with the design and comes back when it opens.
/// It is not an undo step (undoing a later feature leaves it hidden), but the design then has
/// unsaved changes, and showing it again is saved the same way.
#[test]
fn hidden_placements_and_bodies_are_saved_with_the_design_but_not_undone() {
let (dir, path) = temp_design("saved", "solidcraft");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":4,"width":4,"height":4,"body_name":"Base"}));
let (component, first) = created(&mut s, "Part");
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
run(&mut s, "component.activate", json!({"component":0}));
run(&mut s, "file.save", json!({"path": path}));
assert!(!s.is_dirty());
let undo = s.undo.len();
run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]}));
run(&mut s, "browser.visibility", json!({"visible":false,"items":["Base"]}));
assert_eq!(s.undo.len(), undo, "hiding is not an undo step");
assert!(s.is_dirty(), "hiding leaves the design with unsaved changes");
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1,"corner":[50,0,0],"operation":"new","body_name":"Later"}));
run(&mut s, "edit.undo", json!({}));
assert!(!s.body_visible("Base") && !s.occurrence_visible(&[second]), "undo keeps the hiding");
run(&mut s, "file.save", json!({}));
assert!(!s.is_dirty());
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
assert!(!s.body_visible("Base"), "the hidden body stays hidden");
assert!(!s.occurrence_visible(&[second]), "the hidden placement stays hidden");
assert!(s.occurrence_visible(&[first]));
assert!(s.doc.components.iter().any(|c| c.id == component));
assert!(!s.is_dirty(), "an opened design has no unsaved changes");
assert!(s.undo.is_empty());
run(&mut s, "browser.visibility", json!({"visible":true,"folder":"bodies"}));
assert!(s.is_dirty());
run(&mut s, "file.save", json!({}));
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
assert!(s.body_visible("Base") && s.occurrence_visible(&[second]), "showing again is saved too");
let _ = std::fs::remove_dir_all(&dir);
}
/// #531: designs saved before visibility was kept (no `visibility` in the file) open with
/// everything shown, and a design with nothing hidden saves exactly as before.
#[test]
fn designs_without_saved_visibility_open_unchanged() {
let (dir, path) = temp_design("legacy", "json");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":4,"width":4,"height":4,"body_name":"Base"}));
let mut legacy: Value = serde_json::from_slice(&solidcraft_io::write_design(&s.doc)).unwrap();
assert!(legacy.get("visibility").is_none(), "nothing hidden: no visibility in the file");
legacy["format"] = json!("solidcraft/15");
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
assert!(s.body_visible("Base"));
assert_eq!(s.visibility, crate::Visibility::default());
assert!(!s.is_dirty());
run(&mut s, "file.save", json!({}));
let saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert!(saved.get("visibility").is_none());
run(&mut s, "browser.visibility", json!({"visible":false,"items":["Base"]}));
run(&mut s, "file.save", json!({}));
let saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(saved["visibility"]["hidden_bodies"], json!(["Base"]));
let _ = std::fs::remove_dir_all(&dir);
}
/// Command names for linking another design are a proposal (#533); adjust them to the real API.
const INSERT_DESIGN: &str = "component.insert_design";
const UPDATE_LINKS: &str = "component.update_links";
fn saved_box_design(path: &std::path::Path, height: f64) {
let mut part = Session::default();
run(&mut part, "solid.box", json!({"length": 10, "width": 20, "height": height, "body_name": "Plate"}));
run(&mut part, "file.save", json!({"path": path}));
}
#[test]
fn a_linked_design_brings_its_bodies_in_and_follows_edits_to_the_part_file() {
let dir = std::env::temp_dir().join(format!("solidcraft-linked-design-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let part = dir.join("plate.solidcraft");
// Given a part saved in its own design: a 10 x 20 x 30 plate.
saved_box_design(&part, 30.0);
// When an assembly inserts it as a linked component, the plate's body is in the assembly.
let mut assembly = Session::default();
let inserted = assembly.execute(INSERT_DESIGN, &json!({"path": part, "translate": [50, 0, 0]}));
let inserted = inserted.unwrap_or_else(|e| panic!("{INSERT_DESIGN}: {e}"));
let occurrence = inserted["occurrence"].as_u64().unwrap_or_else(|| panic!("no occurrence in {inserted}"));
let measured = run(&mut assembly, "occurrence.properties", json!({"occurrence": occurrence, "path": [occurrence]}));
near(&measured["total"]["volume_mm3"], 6000.0);
near(&measured["total"]["center_of_mass"][0], 55.0);
// When the part is edited (30 -> 45 tall) and saved again, updating the links follows it.
saved_box_design(&part, 45.0);
run(&mut assembly, UPDATE_LINKS, json!({}));
let measured = run(&mut assembly, "occurrence.properties", json!({"occurrence": occurrence, "path": [occurrence]}));
near(&measured["total"]["volume_mm3"], 9000.0);
near(&measured["total"]["center_of_mass"][0], 55.0);
let _ = std::fs::remove_dir_all(&dir);
}
+91 -18
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use solidcraft_doc::expr::Kind;
use solidcraft_doc::sheet::{SheetBody, SheetRule};
use solidcraft_doc::sheet::{self, SheetBody, SheetRule};
use solidcraft_doc::{FeatureKind, FlangeEdge};
use solidcraft_geom::{Vec2, Vec3};
@@ -17,7 +17,8 @@ pub static COMMANDS: &[CommandSpec] = &[
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \
edge: edges: [[x,y,z] on top/bottom edges, or {point: [x,y,z], angle?, height?} for an edge with its own angle and height] + \
height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs); name?, body_name? (the new body)",
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs); name?, body_name? (the new body). \
Thickness, K-factor and bend radius come from the rule (sheet.manage_rules); passing them here is refused",
),
CommandSpec::new("sheet.hem", "Hem", hem)
.at("SHEET METAL", "CREATE")
@@ -36,20 +37,24 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's; \
gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); active?: bool; delete?: bool",
gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); \
corner_relief_shape?: notch|round|rectangular (what is cut where two bends meet at a corner of the base, `corner_relief` across; default notch); \
active?: bool; delete?: bool",
),
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
.at("SHEET METAL", "CREATE")
.icon("flat")
.noundo()
.params("body? → flat size, outline loops and cut-outs (flat coordinates), bend lines and the bend table"),
.params(
"body? → flat size, outline loops and cut-outs (flat coordinates, as point lists), cutout_shapes (the same cut-outs exactly, in the same order: {circle: {center, radius}} for a round hole, else {segments: [{line: [a, b]} | {arc: {center, radius, start_deg, sweep_deg}} | {polyline: [...]}]}), pockets (with their own cutout_shapes), bend lines and the bend table",
),
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf)
.noundo()
.params("body?, path? → DXF (mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"),
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table)
.noundo()
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates"),
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; a bend that cut-outs split has a row for each piece"),
];
fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
@@ -103,8 +108,23 @@ fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
}
}
/// Sheet values a flange takes from its rule. Passing one to `sheet.flange` is refused rather
/// than ignored, so a script never gets a part of another thickness than it asked for.
const RULE_ONLY: [&str; 8] =
["thickness", "k_factor", "bend_radius", "relief_width", "relief_depth", "corner_relief", "corner_relief_shape", "hem_gap"];
fn flange(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.flange";
let nested = p.get("base").filter(|b| b.is_object());
if let Some(key) = RULE_ONLY.iter().find(|k| p.get(**k).is_some() || nested.is_some_and(|b| b.get(**k).is_some())) {
return Err(bad(
cmd,
format!(
"`{key}` is not a sheet.flange parameter: the sheet metal rule sets it. Make a rule with \
sheet.manage_rules {{name, {key}, …}} and pass its name as `rule`"
),
));
}
let ty = match str_(p, "type") {
Some(t) => t.to_ascii_lowercase(),
None if p.get("edges").is_some() => "edge".into(),
@@ -216,7 +236,25 @@ fn fold(s: &mut Session, p: &Value) -> Result<Value> {
None => None,
};
let body = str_(p, "body").map(str::to_string);
add_feature(s, p, FeatureKind::SheetFold { sketch, curve, a, b, angle, radius, position, flip: bool_(p, "flip").unwrap_or(false), fixed, body })
let r = add_feature(
s,
p,
FeatureKind::SheetFold { sketch, curve, a, b, angle, radius, position, flip: bool_(p, "flip").unwrap_or(false), fixed, body },
)?;
// A recompute keeps a stored fold whose fixed point no longer touches the sheet (with a
// warning); a new fold refuses one, since it picks no side (#263).
if let Some(f) = fixed {
let id = r.get("feature").and_then(Value::as_u64);
let off =
id.and_then(|id| s.model.result(id)).and_then(|res| res.warning.as_deref()).is_some_and(|w| w.contains(sheet::FOLD_FIXED_OFF_SHEET));
if off {
return Err(bad(
cmd,
format!("the fixed point ({:.3}, {:.3}, {:.3}) is not on the sheet: pick a point on the face of the side that stays", f.x, f.y, f.z),
));
}
}
Ok(r)
}
fn unfold(s: &mut Session, p: &Value) -> Result<Value> {
@@ -269,6 +307,10 @@ fn rules(s: &mut Session, p: &Value) -> Result<Value> {
*slot = e;
}
}
if let Some(shape) = p.get("corner_relief_shape").filter(|v| !v.is_null()) {
let shape = shape.as_str().and_then(solidcraft_doc::sheet::ReliefShape::parse);
rule.corner_relief_shape = shape.ok_or_else(|| bad(cmd, "`corner_relief_shape` must be notch, round or rectangular"))?.name().into();
}
let (vals, _) = s.doc.param_values();
let values = s.doc.rule_values(&vals, &rule).map_err(|e| bad(cmd, e.to_string()))?;
let doc = s.doc_mut();
@@ -308,6 +350,39 @@ fn sheet_of(s: &Session, p: &Value, cmd: &str) -> Result<SheetBody> {
}
}
/// The centre and radius of a loop that is one whole circle (often two semicircles).
fn loop_circle(lp: &solidcraft_geom::Loop2) -> Option<(Vec2, f64)> {
use solidcraft_geom::Seg2;
let Some(Seg2::Arc { center, radius, sweep, .. }) = lp.segs.first() else { return None };
let total: f64 = lp.segs.iter().filter_map(|s| if let Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum();
let circular = lp.segs.iter().all(
|s| matches!(s, Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r - *radius).abs() < 1e-9 && a.signum() == sweep.signum()),
);
(circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9).then_some((*center, *radius))
}
/// A loop's exact shape for the flat pattern JSON: a circle, or its lines and arcs (other
/// curves as point lists at the flat pattern's 0.001 mm tolerance).
fn loop_shape(lp: &solidcraft_geom::Loop2) -> Value {
use solidcraft_geom::Seg2;
let pt = |v: Vec2| json!([v.x, v.y]);
if let Some((center, radius)) = loop_circle(lp) {
return json!({"circle": {"center": pt(center), "radius": radius}});
}
let segments: Vec<Value> = lp
.segs
.iter()
.map(|seg| match *seg {
Seg2::Line { a, b } => json!({"line": [pt(a), pt(b)]}),
Seg2::Arc { center, radius, start, sweep } => {
json!({"arc": {"center": pt(center), "radius": radius, "start_deg": start.to_degrees(), "sweep_deg": sweep.to_degrees()}})
}
ref other => json!({"polyline": other.polyline(1e-3).into_iter().map(pt).collect::<Vec<_>>()}),
})
.collect();
json!({"segments": segments})
}
fn flat_json(sh: &SheetBody) -> Result<Value> {
let regions = sh.flat_regions()?;
let loops: Vec<Vec<Vec2>> = regions.iter().map(|r| r.outer.polyline(1e-3)).collect();
@@ -320,6 +395,7 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
let pt = |v: &Vec2| json!([v.x, v.y]);
let outline: Vec<Value> = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect();
let holes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(|h| Value::Array(h.polyline(1e-3).iter().map(pt).collect())).collect();
let hole_shapes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(loop_shape).collect();
let bends: Vec<Value> = sh
.bends()
.iter()
@@ -341,11 +417,12 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
};
json!({"outline": p.region.outer.polyline(1e-3).iter().map(pt).collect::<Vec<_>>(),
"cutouts": p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect::<Vec<_>>()).collect::<Vec<_>>(),
"cutout_shapes": p.region.holes.iter().map(loop_shape).collect::<Vec<_>>(),
"side": side, "depth_mm": p.z1-p.z0, "z0_mm": p.z0, "z1_mm": p.z1})
})
.collect();
Ok(
json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "pockets": pockets, "bends": bends, "unfolded": sh.flat}),
json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "cutout_shapes": hole_shapes, "pockets": pockets, "bends": bends, "unfolded": sh.flat}),
)
}
@@ -419,17 +496,13 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
for (h, layer) in loops {
// A circle is often represented by two semicircles. Keep the whole analytic
// boundary as one CIRCLE rather than approximating outer boundaries with chords.
if let Some(solidcraft_geom::Seg2::Arc { center, radius, sweep, .. }) = h.segs.first() {
let total: f64 = h.segs.iter().filter_map(|s| if let solidcraft_geom::Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum();
let circular = h.segs.iter().all(|s| matches!(s, solidcraft_geom::Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r-*radius).abs() < 1e-9 && a.signum() == sweep.signum()));
if circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9 {
w(0, "CIRCLE");
w(8, &layer);
w(10, &f(center.x));
w(20, &f(center.y));
w(40, &f(*radius));
continue;
}
if let Some((center, radius)) = loop_circle(h) {
w(0, "CIRCLE");
w(8, &layer);
w(10, &f(center.x));
w(20, &f(center.y));
w(40, &f(radius));
continue;
}
for seg in &h.segs {
match *seg {
+655 -2
View File
@@ -954,6 +954,81 @@ fn tray_shrunk_by_a_parameter_unfolds_like_one_built_at_that_size() {
}
}
#[test]
fn the_bend_table_and_the_dxf_follow_what_flat_cuts_leave_of_a_bend() {
// A 200 x 100 plate with a 40 mm flange along y = 0: the bend's centre line runs at y = -pi/2
// (R 1, T 2, K 0.5: a bend allowance of pi) from x = 0 to 200.
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);
run(&mut s, "sheet.flange", json!({"edges": [[100, 0, 2]], "height": 40, "angle": 90, "radius": 1, "position": "outside"}));
// (bend number, length, start x, end x) of every row; all on the centre line.
let rows = |s: &mut Session| -> Vec<(u64, f64, f64, f64)> {
let f = flat(s);
let num = |v: &Value| v.as_f64().unwrap_or(f64::NAN);
f["bends"]
.as_array()
.cloned()
.unwrap_or_default()
.iter()
.map(|b| {
assert!((num(&b["start"][1]) + FRAC_PI_2).abs() < 1e-9 && (num(&b["end"][1]) + FRAC_PI_2).abs() < 1e-9, "{b}");
(b["bend"].as_u64().unwrap_or(0), num(&b["length"]), num(&b["start"][0]), num(&b["end"][0]))
})
.collect()
};
let close = |got: &[(u64, f64, f64, f64)], want: &[(u64, f64, f64, f64)]| {
got.len() == want.len()
&& got.iter().zip(want).all(|(g, w)| g.0 == w.0 && (g.1 - w.1).abs() < 1e-9 && (g.2 - w.2).abs() < 1e-9 && (g.3 - w.3).abs() < 1e-9)
};
// The DXF's bend lines: both ends' x of every LINE along the centre line, left to right; the
// BEND_UP layer holds nothing else.
let bend_lines = |s: &mut Session| -> Vec<(f64, f64)> {
let text = run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
let on_layer = text.lines().collect::<Vec<_>>().windows(2).filter(|w| w[0].trim() == "8" && w[1].trim() == "BEND_UP").count();
let mut found: Vec<(f64, f64)> = solidcraft_io::read_dxf(&text)
.unwrap()
.iter()
.filter_map(|g| match g {
solidcraft_io::Geom2::Line(a, b) if (a.y + FRAC_PI_2).abs() < 1e-6 && (b.y + FRAC_PI_2).abs() < 1e-6 => {
Some((a.x.min(b.x), a.x.max(b.x)))
}
_ => None,
})
.collect();
found.sort_by(|a, b| a.0.total_cmp(&b.0));
assert_eq!(found.len(), on_layer, "{text}");
found
};
assert!(close(&rows(&mut s), &[(1, 200.0, 0.0, 200.0)]), "{:?}", rows(&mut s));
assert_eq!(bend_lines(&mut s).len(), 1);
// A flat cut takes the flange's left half, its bend and a strip of the base.
run(&mut s, "sheet.unfold", json!({}));
rectangle_tool(&mut s, "Half", [-10.0, -60.0], [100.0, 10.0]);
run(&mut s, "solid.extrude", json!({"sketch": "Half", "through_all": true, "direction": "symmetric", "operation": "cut"}));
run(&mut s, "sheet.refold", json!({}));
assert!(close(&rows(&mut s), &[(1, 100.0, 100.0, 200.0)]), "{:?}", rows(&mut s));
let lines = bend_lines(&mut s);
assert!(lines.len() == 1 && (lines[0].0 - 100.0).abs() < 1e-6 && (lines[0].1 - 200.0).abs() < 1e-6, "{lines:?}");
let csv = run(&mut s, "sheet.export_bend_table", json!({}));
let text = csv["text"].as_str().unwrap_or_default();
assert_eq!((text.lines().count(), csv["bends"].as_u64()), (2, Some(1)), "{text}");
assert!(text.lines().nth(1).is_some_and(|l| l.contains(",1,90.000000000,up,1.000000000,3.141592654,100.000000000,100.000000000,")), "{text}");
// A slot across what is left splits the bend: a row and a line for each piece, both bend 1.
run(&mut s, "sheet.unfold", json!({}));
rectangle_tool(&mut s, "Slot", [140.0, -20.0], [160.0, 10.0]);
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"}));
run(&mut s, "sheet.refold", json!({}));
assert!(close(&rows(&mut s), &[(1, 40.0, 100.0, 140.0), (1, 40.0, 160.0, 200.0)]), "{:?}", rows(&mut s));
let lines = bend_lines(&mut s);
assert!(lines.len() == 2 && (lines[0].1 - 140.0).abs() < 1e-6 && (lines[1].0 - 160.0).abs() < 1e-6, "{lines:?}");
let csv = run(&mut s, "sheet.export_bend_table", json!({}));
assert_eq!((csv["text"].as_str().unwrap_or_default().lines().count(), csv["bends"].as_u64()), (3, Some(2)));
// Unfolded, the flat pattern lists the same pieces.
run(&mut s, "sheet.unfold", json!({}));
assert!(close(&rows(&mut s), &[(1, 40.0, 100.0, 140.0), (1, 40.0, 160.0, 200.0)]), "{:?}", rows(&mut s));
}
#[test]
fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() {
let mut s = Session::default();
@@ -1865,6 +1940,72 @@ fn edge_flanges_on_two_collinear_tab_ends_in_one_feature() {
assert!(rel(volume(&mut s), volume(&mut separate)) < 1e-6);
}
/// A 160 x 30 bar with a 20 x 30 leg standing past its top edge at the left end (2 mm, R 1,
/// K 0.5, so folding keeps the volume): a base flange, or a plate converted to sheet metal.
fn bar_with_leg(s: &mut Session, converted: bool) {
run(s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "active": true}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Bar"}));
run(s, "sketch.line", json!({"points": [[0, 0], [160, 0], [160, 30], [20, 30], [20, 60], [0, 60]], "closed": true}));
run(s, "sketch.finish", json!({}));
if converted {
run(s, "solid.extrude", json!({"sketch": "Bar", "distance": 2, "body_name": "Plate"}));
run(s, "sheet.convert", json!({"body": "Plate", "face": [80, 15, 2]}));
} else {
run(s, "sheet.flange", json!({"sketch": "Bar"}));
}
}
/// An edge flange takes its setback off its own edge only: the leg standing past the edge's end
/// stays, and the rule's bend relief frees the bend and the flange from it.
#[test]
fn an_edge_flange_beside_a_leg_of_the_base_trims_its_own_edge_and_relieves_its_bend() {
// Bend allowance (π/2)·(R + K·T) = π; the rule's relief is Thickness wide, half of it deep.
let (ba, width, depth) = (PI, 2.0, 1.0);
for (converted, position, setback) in [(false, "inside", 3.0), (false, "outside", 0.0), (true, "inside", 3.0)] {
let what = format!("converted {converted}, {position}");
let mut s = Session::default();
bar_with_leg(&mut s, converted);
// Where the sheet's bottom face lies.
let z0 = bbox(&mut s)["min"][2].as_f64().unwrap_or(f64::NAN);
// A 90° flange 15 high on the bar's top edge (x 20…160); the leg stands past its left end.
run(&mut s, "sheet.flange", json!({"edges": [[90, 30, z0 + 2.0]], "height": 15, "angle": 90, "position": position}));
assert!(no_errors_or_warnings(&s), "{what}: {:?}", s.model.results);
// By hand: the base loses the setback under that edge alone, and the relief beside the
// bend's end at x = 20, from `depth` before the bend line to `width` past the flange's
// far edge in the flat (BA and the 12 mm panel past the line).
let (line, panel) = (30.0 - setback, 12.0);
let relief = width * (depth + ba + panel + width);
let base = 160.0 * 30.0 + 20.0 * 30.0 - 140.0 * setback - relief;
let want = 2.0 * (base + 140.0 * (ba + panel));
assert!(rel(volume(&mut s), want) < 1e-5, "{what}: {} vs {want}", volume(&mut s));
assert!(contains(&s, [10.0, 50.0, z0 + 1.0]), "{what}: the leg is kept");
assert!(!contains(&s, [19.0, line + 0.5, z0 + 1.0]), "{what}: the relief is cut");
assert!(contains(&s, [17.0, line + 0.5, z0 + 1.0]), "{what}: the leg beside the relief stays");
assert!(contains(&s, [19.0, 50.0, z0 + 1.0]), "{what}: the leg past the relief's end stays");
// The flange's mid plane is R + T/2 = 2 past the bend line; it ends at the leg.
assert!(contains(&s, [90.0, line + 2.0, z0 + 10.0]), "{what}: the flange stands");
assert!(!contains(&s, [10.0, line + 2.0, z0 + 10.0]), "{what}: the flange ends at the leg");
let f = flat(&mut s);
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{what}: {f}");
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0), "{what}: {f}");
assert!((f["bends"][0]["length"].as_f64().unwrap_or(f64::NAN) - 140.0).abs() < 1e-9, "{what}: {f}");
run(&mut s, "sheet.unfold", json!({}));
assert!(rel(volume(&mut s), want) < 1e-9, "{what}: flat {} vs {want}", volume(&mut s));
}
}
/// A bend relief the rule makes too narrow to cut is left out, and the flange says where.
#[test]
fn a_bend_relief_left_out_is_reported_on_the_flange() {
let mut s = Session::default();
bar_with_leg(&mut s, false);
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "relief_width": "0 mm"}));
run(&mut s, "sheet.flange", json!({"edges": [[90, 30, 2]], "height": 15, "angle": 90}));
let w = s.model.results.last().and_then(|r| r.warning.clone()).unwrap_or_default();
assert!(w.contains("no bend relief at (20.000, 27.000, 0.000)") && w.contains("relief width"), "{w}");
assert!(contains(&s, [10.0, 50.0, 1.0]), "the leg is kept");
}
/// `fixed` inside a cut-out touches no material: the fold must refuse it or say which side it kept (#263).
#[test]
fn fold_fixed_point_off_the_sheet_is_not_silently_ignored() {
@@ -1969,7 +2110,6 @@ fn hole_beside_a_bend_with_a_round_cut_out_cuts_and_the_bend_stays_guarded() {
/// A fold whose stored `fixed` point ends up off the sheet after a parameter change keeps working:
/// the point still says which side stays (#370). Only a new pick off the sheet is refused (#263).
#[test]
#[ignore = "fails until #370 is fixed"]
fn fold_keeps_working_when_a_parameter_moves_the_sheet_off_its_fixed_point() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "w", "expression": "100 mm"}));
@@ -1991,7 +2131,6 @@ fn fold_keeps_working_when_a_parameter_moves_the_sheet_off_its_fixed_point() {
/// `fixed` 20 mm above a 2.5 mm sheet touches no material: refuse it or say so, like a point in a
/// cut-out (#371).
#[test]
#[ignore = "fails until #371 is fixed"]
fn fold_fixed_point_above_the_sheet_is_not_silently_accepted() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
@@ -2060,6 +2199,103 @@ fn closed_corners_reach_the_flat_pattern_and_the_dxf() {
}
}
#[test]
fn corner_reliefs_of_the_rule_reach_the_part_the_flat_pattern_and_the_dxf() {
use solidcraft_geom::Vec2;
// The tray at 72°: its bends meet at the corners of the base, s0 = 3·tan 36° in from the sheet's
// edges, and are BA = 2θ wide in the flat. A relief 8 mm across reaches r = 4 mm along both edges
// and takes, at each corner, r²·π/4 (round) or r² (rectangular) of the base and r of two bends.
let th = 72f64.to_radians();
let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0);
let corners = [(s0, s0), (120.0 - s0, s0), (120.0 - s0, 90.0 - s0), (s0, 90.0 - s0)];
let export = |s: &mut Session| run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
let mut notch = Session::default();
alu_tray(&mut notch, 72.0, json!({"corner_relief": "8 mm"}));
let v0 = volume(&mut notch);
assert_eq!(export(&mut notch).matches("0\nARC\n").count(), 0);
run(&mut notch, "sheet.unfold", json!({}));
let flat0 = volume(&mut notch);
for (shape, of_the_base) in [("round", 4.0 * PI), ("rectangular", 16.0)] {
let mut s = Session::default();
alu_tray(&mut s, 72.0, json!({"corner_relief_shape": shape, "corner_relief": "8 mm"}));
assert!(s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{shape}: {:?}", s.model.results);
let listed = run(&mut s, "sheet.rules", json!({}));
assert_eq!(listed["rules"][0]["rule"]["corner_relief_shape"], shape);
assert_eq!(listed["rules"][0]["values"]["corner_relief_shape"], shape);
// The folded part loses what the flat pattern loses (K = 0.5 keeps the volume of a bend).
let (v, want) = (volume(&mut s), 4.0 * (of_the_base + 8.0 * ba) * 2.0);
assert!((v0 - v - want).abs() < 0.1, "{shape}: {v0} - {v} vs {want}");
// The bend table: every bend is 8 mm shorter.
let f = flat(&mut s);
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 - 8.0)).abs() < 1e-6, "{shape}: {lengths:?}");
}
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{shape}");
// The DXF: the round reliefs are arcs of radius 4 about the four corners, the square ones lines.
let dxf = export(&mut s);
let arcs: Vec<(Vec2, f64)> = solidcraft_io::read_dxf(&dxf)
.unwrap_or_default()
.iter()
.filter_map(|g| if let solidcraft_io::Geom2::Arc { c, a, .. } = g { Some((*c, a.dist(*c))) } else { None })
.collect();
if shape == "round" {
assert_eq!(arcs.len(), 4, "{arcs:?}");
for (x, y) in corners {
assert!(arcs.iter().any(|(c, r)| c.dist(Vec2::new(x, y)) < 1e-6 && (r - 4.0).abs() < 1e-6), "({x}, {y}): {arcs:?}");
}
} else {
assert!(arcs.is_empty() && dxf.contains(&format!("{:.6}\n20\n{:.6}\n", s0 + 4.0, s0 + 4.0)), "{arcs:?}");
}
// 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!((flat0 - volume(&mut s) - want).abs() < 0.1, "{shape}: unfolded {flat0} - {} vs {want}", volume(&mut s));
run(&mut s, "sheet.refold", json!({}));
assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{shape}");
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, "{shape}: {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, "{shape}");
}
}
#[test]
fn the_corner_relief_shape_is_a_rule_setting_checked_and_undone() {
let mut s = Session::default();
alu_tray(&mut s, 72.0, json!({}));
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.
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!(shape(&mut s) == "notch" && volume(&mut s) == v0);
// Changing the rule changes the parts made with it; other settings leave the shape alone.
let r = run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief_shape": "Round"}));
assert_eq!((&r["rule"]["corner_relief_shape"], &r["values"]["corner_relief_shape"]), (&json!("round"), &json!("round")));
let round = volume(&mut s);
assert!(round < v0 - 200.0, "{v0} -> {round}");
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "hem_gap": "0.02 mm"}));
assert!(shape(&mut s) == "round" && volume(&mut s) == round);
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief_shape": null, "corner_relief": "6 mm"}));
assert!(shape(&mut s) == "round" && volume(&mut s) > round);
for _ in 0..3 {
run(&mut s, "edit.undo", json!({}));
}
assert!(shape(&mut s) == "notch" && volume(&mut s) == v0);
// A relief longer than the bends is left out and reported on the flange; the part is the notched one.
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief_shape": "rectangular", "corner_relief": "200 mm"}));
let w = s.model.results.last().and_then(|r| r.warning.clone()).unwrap_or_default();
assert_eq!(w.matches("no corner relief at (").count(), 4, "{w}");
assert!(w.contains("would take a whole bend away") && volume(&mut s) == v0, "{w}");
}
#[test]
fn corners_left_open_are_reported_on_the_flange() {
let mut s = Session::default();
@@ -2265,6 +2501,197 @@ fn per_edge_values_are_expressions_and_feature_inputs() {
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
}
/// The bends' lengths in the flat pattern, in the order the bends were made.
fn bend_lengths(s: &mut Session) -> Vec<f64> {
flat(s)["bends"].as_array().cloned().unwrap_or_default().iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect()
}
/// A tray on a 120 wide, 90 deep base whose top edge is `top` wide (120: a rectangle), in 2 mm
/// aluminium (R 1, K 0.5) with the flange gap `gap`: walls 20 high at `deg` in one feature and,
/// in the next feature, a return 12 high at 180° − `deg` on the far edge of each of the walls
/// `returns` (0…3: bottom, right, top, left; none: no feature), picked on the walls' inner faces,
/// so that the returns turn in over the base and lie level.
fn tray_with_returns(top: f64, deg: f64, gap: &str, returns: &[usize]) -> Session {
let mut s = Session::default();
run(
&mut s,
"sheet.manage_rules",
json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": gap, "active": true}),
);
let inset = (120.0 - top) / 2.0;
let pts = [(0.0, 0.0), (120.0, 0.0), (120.0 - inset, 90.0), (inset, 90.0)];
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.line", json!({"points": pts.map(|p| [p.0, p.1]), "closed": true}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
// A wall's inner face 20 up its outer face from the base's edge: that far in from the edge
// and that high (see the doc tests).
let th = deg.to_radians();
let (into, up) = (-20.0 * th.cos() + 2.0 * th.sin(), 20.0 * th.sin() + 2.0 * th.cos());
let pick = |k: usize, into: f64, z: f64| {
let (a, b) = (pts[k], pts[(k + 1) % 4]);
let l = (b.0 - a.0).hypot(b.1 - a.1);
json!([(a.0 + b.0) / 2.0 - (b.1 - a.1) / l * into, (a.1 + b.1) / 2.0 + (b.0 - a.0) / l * into, z])
};
let edges: Vec<Value> = (0..4).map(|k| pick(k, 0.0, 2.0)).collect();
run(&mut s, "sheet.flange", json!({"edges": edges, "height": 20, "angle": format!("{deg} deg")}));
if !returns.is_empty() {
let edges: Vec<Value> = returns.iter().map(|&k| pick(k, into, up)).collect();
run(&mut s, "sheet.flange", json!({"edges": edges, "height": 12, "angle": format!("{} deg", 180.0 - deg)}));
}
s
}
#[test]
fn returns_on_a_tray_close_at_its_corners() {
use std::f64::consts::SQRT_2;
// Walls at 90° with returns at 90°. The returns' feature used to fail here ("sew: nonmanifold
// edge contact"): each return took its wall's bend length and ran through its neighbours.
let mut s = tray_with_returns(120.0, 90.0, "0.5 mm", &[0, 1, 2, 3]);
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
// By hand (the derivation is with the doc tests; e = g/√2). The part fills 120 x 90 x 20.
let e = 0.5 / SQRT_2;
let b = bbox(&mut s);
for (k, want) in [120.0, 90.0, 20.0].into_iter().enumerate() {
let (lo, hi) = (b["min"][k].as_f64().unwrap_or(f64::NAN), b["max"][k].as_f64().unwrap_or(f64::NAN));
assert!(lo.abs() < 1e-6 && (hi - want).abs() < 1e-6, "{b}");
}
// The walls' bends are the base's trimmed edges; the returns' bends stop g/√2 short of the
// mitre x = y through (3, 3) at both ends.
let f = flat(&mut s);
let lengths = bend_lengths(&mut s);
assert_eq!(lengths.len(), 8, "{f}");
for (got, want) in lengths.iter().zip([114.0, 84.0, 114.0, 84.0, 114.0 - 2.0 * e, 84.0 - 2.0 * e, 114.0 - 2.0 * e, 84.0 - 2.0 * e]) {
assert!((got - want).abs() < 1e-6, "{lengths:?}");
}
// The flat pattern is one outline through the corners of the return's panel on the wall on
// y = 0: 9 high past two bend zones π wide and the wall's 14, mitred at 45° at both ends.
let outline = outline_points(&f);
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}");
let (y0, y1) = (-11.0 - 2.0 * PI, -20.0 - 2.0 * PI);
for (x, y) in [(3.0 + e, y0), (117.0 - e, y0), (108.0 - e, y1), (12.0 + e, y1)] {
assert!(outline.iter().any(|p| (p.0 - x).abs() < 1e-6 && (p.1 - y).abs() < 1e-6), "({x}, {y}) not in {outline:?}");
}
// It holds the base, the walls' bends and panels and the returns' bends and panels, nothing
// more or less, and is 20 + 2π wider than the base on every side.
let area = 114.0 * 84.0 + 396.0 * PI + 28.0 * (202.0 - 4.0 * e) + 2.0 * PI * (198.0 - 4.0 * e) + 18.0 * (180.0 - 4.0 * e);
assert!(rel(polygon_area(&outline), area) < 1e-9, "{} vs {area}", polygon_area(&outline));
for (k, want) in [160.0 + 4.0 * PI, 130.0 + 4.0 * PI, 2.0].into_iter().enumerate() {
assert!((f["flat_size_mm"][k].as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{}: {want}", f["flat_size_mm"]);
}
// Folded, the part holds that material (K = 0.5 keeps the volume).
let v = volume(&mut s);
assert!(rel(v, area * 2.0) < 1e-3, "{v} vs {}", area * 2.0);
// The DXF: the outline in straight lines, and eight 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() + 8, "{} outline points", outline.len());
// Unfold and refold keep it.
run(&mut s, "sheet.unfold", json!({}));
assert!(same_points(&outline_points(&flat(&mut s)), &outline));
run(&mut s, "sheet.refold", json!({}));
assert!(rel(volume(&mut s), v) < 1e-9);
// Reopened, it is the same part.
let mut again = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!(no_errors_or_warnings(&again), "{:?}", again.model.results);
assert!(rel(volume(&mut again), v) < 1e-9);
assert!(same_points(&outline_points(&flat(&mut again)), &outline));
}
#[test]
fn returns_on_leaning_walls_stay_inside_the_walls() {
// A trapezoid (corners of 77.5° and 102.5°) with walls at 110° and level returns at 70°. Each
// return used to run its wall's bend length from where the wall's far edge started, out
// through the neighbouring wall: the part reached x = 120.588 where its walls end at 117.587.
let mut s = tray_with_returns(80.0, 110.0, "0.5 mm", &[0, 1, 2, 3]);
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
// The returns lie inside the walls and no higher than their tips: the part's box is the
// walls' own.
let (with, without) = (bbox(&mut s), bbox(&mut tray_with_returns(80.0, 110.0, "0.5 mm", &[])));
for end in ["min", "max"] {
for k in 0..3 {
let (a, b) = (with[end][k].as_f64().unwrap_or(f64::NAN), without[end][k].as_f64().unwrap_or(f64::NAN));
assert!((a - b).abs() < 1e-6, "{with} vs {without}");
}
}
// By hand (the doc tests): a return's panel starts v = 3·sin 110° + (20 − 3·tan 35°)·cos 70°
// in from its wall's edge, and at a base corner of angle α its bend stops
// x_b = (g/2 + v·cos(α/2)) / sin(α/2) along the edge from the corner.
let th = 110f64.to_radians();
let v = 3.0 * th.sin() - (20.0 - 3.0 * 35f64.to_radians().tan()) * th.cos();
let x_b = |alpha: f64| (0.25 + v * (alpha / 2.0).cos()) / (alpha / 2.0).sin();
let (acute, side) = ((90.0f64 / 20.0).atan(), 20f64.hypot(90.0));
let want = [120.0 - 2.0 * x_b(acute), side - x_b(acute) - x_b(PI - acute), 80.0 - 2.0 * x_b(PI - acute), side - x_b(acute) - x_b(PI - acute)];
let lengths = bend_lengths(&mut s);
assert_eq!(lengths.len(), 8, "{lengths:?}");
for (got, want) in lengths[4..].iter().zip(want) {
assert!((got - want).abs() < 1e-6, "{lengths:?} vs {want:?}");
}
// One outline in the flat, and the folded part holds its material.
let f = flat(&mut s);
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}");
let (outline, vol) = (outline_points(&f), volume(&mut s));
assert!(rel(vol, polygon_area(&outline) * 2.0) < 1e-3, "{vol} vs {}", polygon_area(&outline) * 2.0);
run(&mut s, "sheet.unfold", json!({}));
assert!(same_points(&outline_points(&flat(&mut s)), &outline));
run(&mut s, "sheet.refold", json!({}));
assert!(rel(volume(&mut s), vol) < 1e-9);
}
#[test]
fn a_return_alone_on_a_leaning_wall_keeps_out_of_the_walls_beside_it() {
use std::f64::consts::SQRT_2;
// The tray with walls at 110° and a level return on the wall on y = 0 only. It used to take
// that wall's bend length (120 − 2·3·tan 55° = 111.431) from where the wall's far edge starts
// and came out through the wall on x = 120: the part reached x = 120.504 where its walls end
// at 118.716.
let mut s = tray_with_returns(120.0, 110.0, "0.5 mm", &[0]);
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
let (with, without) = (bbox(&mut s), bbox(&mut tray_with_returns(120.0, 110.0, "0.5 mm", &[])));
for end in ["min", "max"] {
for k in 0..3 {
let (a, b) = (with[end][k].as_f64().unwrap_or(f64::NAN), without[end][k].as_f64().unwrap_or(f64::NAN));
assert!((a - b).abs() < 1e-6, "{with} vs {without}");
}
}
// By hand (the doc tests): the walls beside it lean in over it and come nearest at its top,
// 20·sin 110° up, where their inner faces are 2/sin 110° + 20·cos 70° in from the base's edges.
// The return stops square g/√2 short of them at both ends: 120 − 2·9.322312 = 101.355376.
let th = 110f64.to_radians();
let want = 120.0 - 2.0 * (0.5 / SQRT_2 + 2.0 / th.sin() - 20.0 * th.cos());
let lengths = bend_lengths(&mut s);
assert!(lengths.len() == 5 && (lengths[4] - want).abs() < 1e-6 && (want - 101.355376).abs() < 1e-6, "{lengths:?} vs {want}");
// One outline in the flat, and the folded part holds its material.
let f = flat(&mut s);
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}");
let (outline, vol) = (outline_points(&f), volume(&mut s));
assert!(rel(vol, polygon_area(&outline) * 2.0) < 1e-3, "{vol} vs {}", polygon_area(&outline) * 2.0);
}
#[test]
fn returns_follow_the_rules_gap_and_stay_open_with_a_warning_when_it_is_zero() {
use std::f64::consts::SQRT_2;
// The mitre keeps the rule's gap: at 90° the returns' bends are 114 − 2·g/√2 and 84 − 2·g/√2.
let mut s = tray_with_returns(120.0, 90.0, "0.5 mm", &[0, 1, 2, 3]);
for g in [0.5, 1.0, 0.2] {
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "gap": format!("{g} mm")}));
assert!(no_errors_or_warnings(&s), "{g}: {:?}", s.model.results);
let lengths = bend_lengths(&mut s);
assert_eq!(lengths.len(), 8, "{g}: {lengths:?}");
for (got, want) in lengths[4..].iter().zip([114.0, 84.0, 114.0, 84.0]) {
assert!((got - (want - 2.0 * g / SQRT_2)).abs() < 1e-6, "{g}: {lengths:?}");
}
}
// A gap of zero can't be kept: the walls' corners and the returns' corners are left open,
// each feature with its warning, and each return spans its wall's far edge (here the wall's
// bend, 120 − 2·3·tan 55°).
let mut s = tray_with_returns(120.0, 110.0, "0 mm", &[0, 1, 2, 3]);
let warnings: Vec<String> = s.model.results.iter().filter_map(|r| r.warning.clone()).collect();
assert!(s.model.results.iter().all(|r| r.error.is_none()) && warnings.len() == 2, "{:?}", s.model.results);
assert!(warnings.iter().all(|w| w.matches("left open").count() == 4), "{warnings:?}");
let lengths = bend_lengths(&mut s);
assert!((lengths[4] - lengths[0]).abs() < 1e-9 && (lengths[0] - (120.0 - 6.0 * 55f64.to_radians().tan())).abs() < 1e-6, "{lengths:?}");
}
#[test]
fn bad_per_edge_input_is_refused_and_plain_edges_stay_plain() {
let mut s = Session::default();
@@ -2370,3 +2797,229 @@ fn designs_without_per_edge_values_are_read_and_written_as_before() {
design["features"][flange]["edges"][3] = json!({"point": [0.0, 45.0, 2.0], "twist": "3 deg"});
assert!(solidcraft_doc::Document::from_json(&design.to_string()).is_err());
}
/// Names of the hole's side faces (curved, smaller than the bend faces) in `Body1`.
fn hole_face_names(s: &mut Session) -> Vec<String> {
let faces = run(s, "model.faces", json!({"body": "Body1"}));
let faces = faces["faces"].as_array().cloned().unwrap_or_default();
faces
.iter()
.filter(|f| f["plane_normal"].is_null() && f["area"].as_f64().is_some_and(|a| a < 100.0))
.map(|f| f["name"].as_str().unwrap_or_default().to_string())
.collect()
}
#[test]
fn a_hole_on_a_folded_panel_keeps_the_name_of_its_sketch_circle() {
// A Ø10 hole at x = 20 on a 100 x 60 plate; the fold at x = 40 stands that (smaller) side up.
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
let circle = run(&mut s, "sketch.circle.center", json!({"center": [20, 30], "radius": 5}));
let curve = circle["curves"][0].as_str().unwrap_or("c1").to_string();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "operation": "cut", "direction": "symmetric"}));
let side = format!(":side:{curve}#");
let flat = hole_face_names(&mut s);
assert_eq!(flat.len(), 2, "{flat:?}");
assert!(flat.iter().all(|n| n.contains(&side)), "before the fold: {flat:?}");
fold_line(&mut s);
run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"}));
assert!(bbox(&mut s)["max"][2].as_f64().unwrap_or(0.0) > 20.0, "the hole's panel stands up");
let folded = hole_face_names(&mut s);
assert_eq!(folded.len(), 2, "{folded:?}");
assert!(folded.iter().all(|n| n.contains(&side)), "after the fold the hole is still circle {curve}: {folded:?} (before: {flat:?})");
}
#[test]
fn a_box_cut_straight_through_a_flat_sheet_is_a_cutout_like_the_extrude_cut() {
// A 10 x 10 box perpendicular to the sheet and through its whole thickness removes exactly
// what a sketch rectangle extruded through all removes, which is accepted today.
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "solid.box", json!({"corner": [20, 20, -1], "length": 10, "width": 10, "height": 5, "operation": "cut"}));
let mut extruded = Session::default();
base(&mut extruded, 100.0, 60.0);
rectangle_tool(&mut extruded, "Hole", [20.0, 20.0], [30.0, 30.0]);
run(&mut extruded, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"}));
for session in [&mut s, &mut extruded] {
assert!((volume(session) - 14750.0).abs() < 1e-6);
assert_eq!(flat(session)["cutouts"].as_array().map(Vec::len), Some(1));
}
run(&mut s, "sheet.unfold", json!({}));
run(&mut s, "sheet.refold", json!({}));
assert!((volume(&mut s) - 14750.0).abs() < 1e-6);
// A cylinder along the sheet normal is a round hole, exact in the flat pattern and DXF.
run(&mut s, "solid.cylinder", json!({"base": [70, 30, -1], "radius": 5, "height": 5, "operation": "cut"}));
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(2));
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
assert!(dxf.contains("CIRCLE"), "the hole stays a circle");
assert!((volume(&mut s) - (14750.0 - PI * 25.0 * 2.5)).abs() < 0.5);
// A box that only dents the top face is a pocket of constant depth.
run(&mut s, "solid.box", json!({"corner": [50, 10, 1.5], "length": 10, "width": 10, "height": 5, "operation": "cut"}));
let f = flat(&mut s);
assert_eq!(f["pockets"].as_array().map(Vec::len), Some(1));
assert_eq!(f["pockets"][0]["depth_mm"], 1.0);
// A box across a folded bend, or a cylinder lying in the sheet, is still 3D machining
// and still refused atomically.
let mut tilted = Session::default();
base(&mut tilted, 100.0, 60.0);
run(&mut tilted, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
let before = tilted.doc.to_json();
let error = tilted
.execute("solid.box", &json!({"corner": [40, -10, -5], "length": 20, "width": 20, "height": 40, "operation": "cut"}))
.unwrap_err()
.to_string();
assert!(error.contains("bend") || error.contains("preserv"), "{error}");
assert_eq!(tilted.doc.to_json(), before);
let error = s
.execute("solid.cylinder", &json!({"base": [10, 50, 1.25], "axis": "X", "radius": 1, "height": 20, "operation": "cut"}))
.unwrap_err()
.to_string();
assert!(error.contains("preserv"), "{error}");
}
#[test]
fn flat_pattern_reports_a_round_hole_as_a_circle() {
// The DXF export already writes this hole as a CIRCLE; the JSON flat pattern should say
// "circle, centre (50, 30), radius 1.6" too, not only a sampled polyline: any object in the
// cut-out's shape that has a `center` and a `radius` counts.
fn circles(v: &Value, out: &mut Vec<([f64; 2], f64)>) {
match v {
Value::Object(m) => {
let center = m.get("center").and_then(Value::as_array).and_then(|c| Some([c.first()?.as_f64()?, c.get(1)?.as_f64()?]));
if let (Some(center), Some(radius)) = (center, m.get("radius").and_then(Value::as_f64)) {
out.push((center, radius));
}
m.values().for_each(|v| circles(v, out));
}
Value::Array(a) => a.iter().for_each(|v| circles(v, out)),
_ => {}
}
}
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
run(&mut s, "sketch.circle.center", json!({"center": [50, 30], "radius": 1.6}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"}));
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
assert!(dxf.contains("CIRCLE"), "the DXF keeps the circle");
let f = flat(&mut s);
let cutouts = f["cutouts"].as_array().cloned().unwrap_or_default();
assert_eq!(cutouts.len(), 1);
// The point list stays for existing readers; `cutout_shapes` carries the exact shape.
assert!(cutouts[0].as_array().is_some_and(|p| p.len() > 8));
let shapes = f["cutout_shapes"].as_array().cloned().unwrap_or_default();
assert_eq!(shapes.len(), 1);
let mut found = Vec::new();
circles(&shapes[0], &mut found);
let shown: String = shapes[0].to_string().chars().take(120).collect();
assert!(
found.iter().any(|(c, r)| (c[0] - 50.0).abs() < 1e-9 && (c[1] - 30.0).abs() < 1e-9 && (r - 1.6).abs() < 1e-9),
"the round hole is not reported as a circle: {shown} ..."
);
// A slot is its two lines and two half-circle arcs, exactly.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "sketch.slot.overall", json!({"p0": [10, 10], "p1": [30, 10], "width": 4}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"}));
let f = flat(&mut s);
let shapes = f["cutout_shapes"].as_array().cloned().unwrap_or_default();
assert_eq!(shapes.len(), f["cutouts"].as_array().map_or(0, Vec::len));
let slot = shapes.iter().find(|s| s.get("segments").is_some()).cloned().unwrap_or_default();
let segments = slot["segments"].as_array().cloned().unwrap_or_default();
assert_eq!(segments.iter().filter(|s| s.get("line").is_some()).count(), 2, "{slot}");
let arcs: Vec<&Value> = segments.iter().filter_map(|s| s.get("arc")).collect();
assert_eq!(arcs.len(), 2, "{slot}");
assert!(
arcs.iter()
.all(|a| (a["radius"].as_f64().unwrap_or(0.0) - 2.0).abs() < 1e-9 && (a["sweep_deg"].as_f64().unwrap_or(0.0).abs() - 180.0).abs() < 1e-6)
);
}
#[test]
fn a_flange_asked_for_0_8_mm_is_0_8_mm_or_refused_never_silently_2_5_mm() {
// `thickness` on sheet.flange may become an inline override of the rule, or be refused as
// an unknown parameter; either is fine. Building the default 2.5 mm sheet silently is not.
let mut s = Session::default();
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.finish", json!({}));
match s.execute("sheet.flange", &json!({"sketch": "Base", "thickness": 0.8})) {
Ok(_) => {
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(f64::NAN) - 0.8).abs() < 1e-9);
assert!((volume(&mut s) - 100.0 * 60.0 * 0.8).abs() < 1e-6, "built at {} mm3", volume(&mut s));
}
Err(e) => assert!(e.to_string().contains("thickness"), "{e}"),
}
}
/// A new fold still refuses a `fixed` point in a cut-out or past the outline, leaving the design
/// untouched (#263); a stored one that drifts off the sheet keeps its side with a warning (#370).
#[test]
fn fold_refuses_a_new_fixed_point_off_the_sheet_but_warns_on_recompute() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "w", "expression": "100 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Blank"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "w"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Blank"}));
rectangle_tool(&mut s, "Window", [10.0, 15.0], [30.0, 45.0]);
run(&mut s, "solid.extrude", json!({"sketch": "Window", "through_all": true, "direction": "symmetric", "operation": "cut"}));
let before = s.doc.to_json();
let undo = s.undo.len();
for fixed in [json!([20, 30, 2.5]), json!([150, 30, 2.5])] {
let e = s.execute("sheet.fold", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": fixed})).unwrap_err().to_string();
assert!(e.contains("fixed") && e.contains("not on the sheet"), "{fixed}: {e}");
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.undo.len(), undo);
}
run(&mut s, "sheet.fold", json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": [90, 30, 2.5], "name": "Fold1"}));
assert!(run(&mut s, "timeline.inspect", json!({"feature": "Fold1"}))["warning"].is_null());
run(&mut s, "parameters.change", json!({"name": "w", "expression": "80 mm"}));
let fold = run(&mut s, "timeline.inspect", json!({"feature": "Fold1"}));
assert!(fold["error"].is_null(), "{fold}");
assert!(fold["warning"].as_str().is_some_and(|w| w.contains("fixed point is not on the sheet") && w.contains("stays")), "{fold}");
}
/// Points away from the sheet are refused atomically; points on any face of it, on a flange's
/// leg, or a hair off a face still pick the side that stays (#371).
#[test]
fn fold_fixed_point_must_touch_the_sheet_but_any_face_will_do() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
let before = s.doc.to_json();
let undo = s.undo.len();
for fixed in [json!([90, 30, 20]), json!([90, 30, -10])] {
let e = s.execute("sheet.fold", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": fixed})).unwrap_err().to_string();
assert!(e.contains("fixed") && e.contains("not on the sheet"), "{fixed}: {e}");
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.undo.len(), undo);
}
// The left (smaller) side stays: the right part stands up.
for fixed in [json!([10, 30, 2.5]), json!([10, 30, 0]), json!([0, 30, 1.25]), json!([10, 30, 2.500001])] {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.fold", json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": fixed, "name": "Fold1"}));
let bb = bbox(&mut s);
assert!(bb["min"][0].as_f64().unwrap_or(f64::NAN).abs() < 1e-6 && bb["max"][0].as_f64().unwrap_or(f64::NAN) < 50.0, "{fixed}: {bb}");
assert!(run(&mut s, "timeline.inspect", json!({"feature": "Fold1"}))["warning"].is_null(), "{fixed}");
}
// A point on an edge flange's leg (far above the base panel's slab) is on the sheet.
let mut f = Session::default();
run(&mut f, "parameters.change", json!({"name": "h", "expression": "20 mm"}));
base(&mut f, 100.0, 60.0);
run(&mut f, "sheet.flange", json!({"edges": [[100, 30, 2.5]], "height": "h"}));
let bb = bbox(&mut f);
let x = bb["max"][0].as_f64().unwrap_or(f64::NAN);
run(&mut f, "sheet.fold", json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": [x, 30, 15], "name": "Fold1"}));
assert!(run(&mut f, "timeline.inspect", json!({"feature": "Fold1"}))["warning"].is_null());
// A shorter leg leaves the stored point in the air: the fold recomputes, keeps its side and warns.
run(&mut f, "parameters.change", json!({"name": "h", "expression": "10 mm"}));
let fold = run(&mut f, "timeline.inspect", json!({"feature": "Fold1"}));
assert!(fold["error"].is_null(), "{fold}");
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");
}
+2 -2
View File
@@ -294,7 +294,7 @@ pub(super) fn edit_solved<T>(
let unsolved = sk.clone();
#[cfg(test)]
solve_counts::record(Analysis::Full);
let rep = doc.solve_sketch(&vals, &mut sk, &stored)?;
let rep = doc.solve_sketch(&vals, &mut sk, &stored, None)?;
Some(settle(strict, rep, &mut sk, unsolved)?)
}
};
@@ -336,7 +336,7 @@ pub(super) fn counted_solve(
pub(super) fn solve_current(sk: &mut Sketch, doc: &solidcraft_doc::Document) -> Result<SolveReport> {
let stored = sk.driving_values();
let (values, _) = doc.param_values();
Ok(doc.solve_sketch(&values, sk, &stored)?)
Ok(doc.solve_sketch(&values, sk, &stored, None)?)
}
/// Solves run on this thread by [`counted_solve`] and the edit's own solve: (all of them, those
+21 -8
View File
@@ -75,19 +75,24 @@ pub static COMMANDS: &[CommandSpec] = &[
.enabled(in_sketch)
.params(concat!("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them", created_doc!())),
CommandSpec::new("sketch.centerline", "Centerline", centerline)
.at("SKETCH", "CREATE")
.icon("centerline")
.enabled(in_sketch)
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!(
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg",
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg, font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans)",
created_doc!()
)),
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text)
.enabled(in_sketch)
.params(concat!("link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg", created_doc!())),
.params(concat!(
"link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg, font?: family or file path (empty: the built-in font)",
created_doc!()
)),
];
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> Result<LinkSource> {
let (t0, at0, h0, a0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0));
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option<String>)>) -> Result<LinkSource> {
let (t0, at0, h0, a0, f0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0, None));
let text = str_(p, "text").map(str::to_string).unwrap_or(t0);
if text.trim().is_empty() {
return Err(bad(cmd, "`text` must not be empty"));
@@ -101,7 +106,15 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> R
return Err(bad(cmd, "`height` must be positive"));
}
let angle = num(p, "angle").map(f64::to_radians).unwrap_or(a0);
Ok(LinkSource::Text { text, at, height, angle })
let font = match p.get("font") {
None | Some(Value::Null) => f0,
Some(Value::String(f)) if f.trim().is_empty() => None,
Some(Value::String(f)) => Some(f.trim().to_string()),
Some(_) => return Err(bad(cmd, "`font` must be a font family name or a font file path")),
};
// An unknown font or an unreadable file is an error now, not a lost link later.
solidcraft_sketch::font_data(font.as_deref()).map_err(|e| bad(cmd, e.to_string()))?;
Ok(LinkSource::Text { text, at, height, angle, font })
}
fn text(s: &mut Session, p: &Value) -> Result<Value> {
@@ -131,12 +144,12 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, "give `link` or `entity`")),
};
let old = match sk.link(&l).map(|x| (x.kind, x.source.clone())) {
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle })) => (text, at, height, angle),
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle, font })) => (text, at, height, angle, font),
_ => return Err(bad(cmd, format!("`{l}` is not text"))),
};
let src = text_source(p, cmd, Some(old))?;
let LinkSource::Text { text, at, height, angle } = &src else { return Err(bad(cmd, "text")) };
let geom = solidcraft_sketch::text_geometry(text, *at, *height, *angle)?;
let LinkSource::Text { text, at, height, angle, font } = &src else { return Err(bad(cmd, "text")) };
let geom = solidcraft_sketch::text_geometry_in(font.as_deref(), text, *at, *height, *angle)?;
sk.set_link_source(&l, src.clone());
sk.update_link(&l, &geom)?;
let curves = ids_of(&sk, &sk.link_curves(&l));
+4 -3
View File
@@ -75,9 +75,10 @@ fn explicit_rebind_preserves_geometry_dimensions_context_and_one_undo() {
#[test]
fn unsupported_face_has_explicit_fixed_alternative_and_no_live_projection() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
// The tops of two joined boxes merge into one face that no single construction owns.
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[10,0,0],"operation":"join"}));
// A top half Offset Face raised: no construction certificate and no exact history name yet.
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Base"}));
run(&mut s, "solid.split_face", json!({"faces":[[10,15,20]],"plane":{"origin":[20,0,0],"normal":[1,0,0]}}));
run(&mut s, "solid.offset_face", json!({"faces":[[10,15,20]],"distance":5}));
let mut pick = top_pick(&s, "Base");
let before = s.doc.to_json();
let error = s.execute("sketch.create", &json!({"plane":pick})).unwrap_err();
+2 -1
View File
@@ -135,7 +135,8 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
if let Geom2::Text { text, at, height, angle } = t {
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
if super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, LinkSource::Text { text, at, height, angle }).is_ok() {
if super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, LinkSource::Text { text, at, height, angle, font: None }).is_ok()
{
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
n_text += 1;
@@ -429,6 +429,113 @@ fn text_makes_profiles_that_extrude_and_edit() {
assert!(s.execute("sketch.text", &json!({"text": "", "at": [0, 0]})).is_err());
}
/// Volumes (mm³) of the bodies in the design, smallest first.
fn body_volumes(s: &mut Session) -> Vec<f64> {
let m = run(s, "inspect.measure", json!({}));
let mut v: Vec<f64> = m["bodies"].as_array().unwrap().iter().map(|b| b["volume_mm3"].as_f64().unwrap()).collect();
v.sort_by(f64::total_cmp);
v
}
#[test]
fn text_extrudes_with_its_counters_and_beside_other_curves() {
// Letter areas in mm² at a capital height of 10 mm, summed from the Liberation Sans contours (shoelace
// formula, quadratic pieces flattened finely), each outer contour minus its counters:
// A 39.2159 - 9.0647 B 68.9283 - 12.9336 - 16.6277 8 56.8394 - 10.6451 - 13.7347 I 13.5557
// Extruded 2 mm, so the volumes are twice that. Faceted walls: within 0.02 mm³.
let near = |got: &[f64], want: &[f64]| {
assert_eq!(got.len(), want.len(), "{got:?} against {want:?}");
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.02), "{got:?} against {want:?}");
};
// Letters with counters, alone: a letter is one body with its counters as holes. (A counter used to be
// extruded as a body of its own, or the extrude failed with "profile has no area".)
for (text, want) in
[("AB", vec![2.0 * (39.2159 - 9.0647), 2.0 * (68.9283 - 12.9336 - 16.6277)]), ("8", vec![2.0 * (56.8394 - 10.6451 - 13.7347)])]
{
let mut s = new_sketch();
run(&mut s, "sketch.text", json!({"text": text, "at": [0, 0], "height": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2}));
near(&body_volumes(&mut s), &want);
}
// Text beside a drawn rectangle is extruded with it (it used to be left out): the 30 x 20 x 2 plate and an "I".
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}));
run(&mut s, "sketch.text", json!({"text": "I", "at": [40, 0], "height": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2}));
near(&body_volumes(&mut s), &[2.0 * 13.5557, 1200.0]);
// A counter can still be picked on its own, by a point inside it: the inner contour of an O, 47.4584 mm².
let mut s = new_sketch();
run(&mut s, "sketch.text", json!({"text": "O", "at": [0, 0], "height": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2, "profiles": [{"point": [5.65, 5.0]}]}));
near(&body_volumes(&mut s), &[2.0 * 47.4584]);
}
#[test]
fn text_embossed_or_debossed_leaves_its_counters_alone() {
// A 40 x 30 x 5 plate (6000 mm³) with 10 mm tall text raised from, or cut 1 mm into, its top. Only the letters
// change the volume, by their area in mm² (see above) x 1 mm; the counters of the O and of A and B stay as
// they are. (Emboss and deboss used to fail with "boolean Cut: no result" or "boolean Union: no result",
// because each counter was a tool body of its own, filling the hole of its letter.)
let (o, ab) = (83.3133 - 47.4584, (39.2159 - 9.0647) + (68.9283 - 12.9336 - 16.6277));
for (text, mode, change) in [("O", "deboss", -o), ("O", "emboss", o), ("AB", "deboss", -ab)] {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5, "body_name": "Plate"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Lid"}));
run(&mut s, "sketch.create", json!({"plane": "Lid", "name": "Text"}));
run(&mut s, "sketch.text", json!({"text": text, "at": [10, 10], "height": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch": "Text", "depth": 1, "mode": mode}));
let v = body_volumes(&mut s);
assert!(v.len() == 1 && (v[0] - (6000.0 + change)).abs() < 0.02, "{text} {mode}: {v:?} against {}", 6000.0 + change);
}
}
#[test]
fn text_takes_a_font_and_edit_text_keeps_or_changes_it() {
// The repository's second font is BIZ UDPGothic. Its "I" is a polygon of two 606 x 145 bars and a 188 x 1369
// stem in font units, with a capital height of 1567 units, so at 10 mm its area is
// (2 x 606 x 145 + 188 x 1369) x (10 / 1567)^2 = 17.6385 mm². The built-in Liberation Sans "I" is 13.5557 mm².
let font = concat!(env!("CARGO_MANIFEST_DIR"), "/../../apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf");
let near = |s: &Session, want: f64| {
let a = profiles(s);
assert!(a.len() == 1 && (a[0] - want).abs() < 1e-3, "{a:?} against {want}");
};
let mut s = new_sketch();
let r = run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10}));
near(&s, 13.5557);
let link = r["link"].as_str().unwrap().to_string();
// A font by file path changes the outlines; the same edit with the built-in family's name changes them back.
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
near(&s, 17.6385);
run(&mut s, "sketch.edit_text", json!({"link": link, "font": "Liberation Sans"}));
near(&s, 13.5557);
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
// An edit that does not name a font keeps it (the height scales the area by 4), and an empty name is the built-in font.
run(&mut s, "sketch.edit_text", json!({"link": link, "height": 20}));
near(&s, 4.0 * 17.6385);
run(&mut s, "sketch.edit_text", json!({"link": link, "font": ""}));
near(&s, 4.0 * 13.5557);
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
// The font is kept in the design: after saving and reopening, the text is still in it.
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
let source = reopened.doc.sketch(s.active_sketch.unwrap()).unwrap().link(&link).unwrap().source.clone();
assert!(matches!(&source, solidcraft_sketch::LinkSource::Text { font: Some(f), .. } if f == font), "{source:?}");
// A new text takes the font too. Unknown fonts and values that are not names are errors.
let mut s = new_sketch();
run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10, "font": font}));
near(&s, 17.6385);
let e = s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": "No Such Family 12345"})).unwrap_err().to_string();
assert!(e.contains("unknown font `No Such Family 12345`"), "{e}");
assert!(s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": 12})).is_err());
assert!(s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": "C:/no/such/dir/font.ttf"})).is_err());
near(&s, 17.6385);
}
#[test]
fn blend_curve_joins_tangentially() {
let mut s = new_sketch();
+24 -3
View File
@@ -166,8 +166,14 @@ fn owner_frame(s: &Session, doc: &Document, comp: u64) -> solidcraft_doc::Mat {
if comp == s.active_component { s.active_component_frame() } else { doc.component_transform(comp) }
}
/// Re-resolve the links of a sketch being edited against the model before it, so edits see
/// current reference geometry. Returns warnings.
/// Re-resolve the links of a sketch being edited (`sk`, a copy of the stored sketch `id`)
/// against the model before it, so edits see current reference geometry. Returns warnings.
///
/// When a link moved, the rest of the sketch follows it as the evaluation solves it, keeping
/// the stored sides and turns, so the edit goes on from the sketch the model shows. Left where
/// it was stored beside a line that has moved past it, a dimensioned point would give the
/// edit's solve the other side to keep. A sketch that does not solve that way keeps its stored
/// geometry, with the links moved.
pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> Vec<String> {
if sk.links.is_empty() {
return Vec::new();
@@ -183,10 +189,21 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) ->
return vec!["lost face attachment; reattach explicitly".into()];
};
let Ok(plane) = sketch_plane(&doc, id, &st) else { return Vec::new() };
match attachment {
let warnings = match attachment {
Some(anchor) => solidcraft_doc::project::refresh_face_anchor_links(&doc, &vals, &st, &plane, sk, anchor),
None => solidcraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk),
};
if let Ok(stored) = doc.sketch(id)
&& sk.positions_in(stored).is_some()
{
let mut solved = sk.clone();
let follows = solved.refresh_offsets().is_ok()
&& doc.solve_sketch(&vals, &mut solved, &stored.driving_values(), Some(stored)).is_ok_and(|report| report.keeps_orientation());
if follows {
*sk = solved;
}
}
warnings
}
/// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id.
@@ -547,6 +564,10 @@ pub(super) fn auto_project_face(s: &mut Session, id: u64, _at: Vec3) -> Result<O
#[path = "sketch_project_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "sketch_project_side_tests.rs"]
mod side_tests;
#[cfg(test)]
#[path = "sketch_model_plane_tests.rs"]
mod model_plane_tests;
@@ -0,0 +1,180 @@
//! A dimension from linked geometry keeps its side when the link's source moves: a point held
//! 5 from a projected line stays on the side it was drawn on wherever a parameter takes the
//! line, also past the place the point is stored at.
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// Sketch `A`: the horizontal line `rail`, 100 long, at the height the parameter `height`
/// (50) gives it.
fn rail_sketch(s: &mut Session) {
run(s, "parameters.add", json!({"name": "height", "expression": "50 mm"}));
run(s, "sketch.create", json!({"plane": "XY", "name": "A"}));
run(s, "sketch.line", json!({"points": [[0, 50], [100, 50]], "ids": ["rail"]}));
run(s, "sketch.constraint.horizontal_vertical", json!({"line": "rail", "mode": "horizontal"}));
run(s, "sketch.constraint.horizontal_vertical", json!({"points": ["origin", "rail.start"], "mode": "vertical"}));
run(s, "sketch.dimension", json!({"entities": ["origin", "rail.start"], "value": "height"}));
run(s, "sketch.dimension", json!({"entities": ["rail"], "value": "100 mm"}));
run(s, "sketch.finish", json!({}));
}
/// Sketch `S` (left open): `rail` brought in by `command`, and the point `p` at (50, `y`),
/// 50 from the origin horizontally. Returns the linked line.
fn point_by_the_rail(s: &mut Session, command: &str, y: f64) -> String {
rail_sketch(s);
run(s, "sketch.create", json!({"plane": "XY", "name": "S"}));
let r = run(s, command, json!({"refs": [{"sketch": "A", "curve": "rail"}]}));
let rail = r["links"][0]["curves"][0].as_str().unwrap().to_string();
run(s, "sketch.point", json!({"point": [50, y], "id": "p"}));
run(s, "sketch.dimension", json!({"entities": ["origin", "p"], "type": "horizontal", "value": "50 mm"}));
rail
}
/// `p` held 5 from `line`: the dimension's parameter.
fn five_from(s: &mut Session, line: &str) -> String {
let r = run(s, "sketch.dimension", json!({"entities": ["p", line], "type": "distance", "value": "5 mm"}));
r["param"].as_str().unwrap().to_string()
}
fn set_height(s: &mut Session, height: f64) {
run(s, "parameters.change", json!({"name": "height", "expression": format!("{height} mm")}));
}
/// Where the model has `p` of sketch `S`.
fn p(s: &Session) -> Vec2 {
let st = s.model.state();
let sk = &st.sketch(s.doc.find_feature("S").unwrap().id).unwrap().sketch;
sk.point(sk.resolve_point("p").unwrap()).unwrap()
}
/// `p` is at (50, `y`), and the sketch has nothing to report.
#[track_caller]
fn assert_p(s: &Session, y: f64, when: &str) {
assert!(p(s).dist(Vec2::new(50.0, y)) < 1e-9, "{when}: {:?}", p(s));
let feature = s.model.result(s.doc.find_feature("S").unwrap().id).unwrap();
assert_eq!((&feature.error, &feature.warning), (&None, &None), "{when}");
}
/// A line of sketch `A` whose height a parameter drives, projected into sketch `S`, whose point
/// is dimensioned 5 from the projection.
#[test]
fn a_distance_from_a_projected_line_keeps_its_side_when_the_source_moves_past() {
let mut s = Session::default();
let rail = point_by_the_rail(&mut s, "sketch.project", 45.0);
five_from(&mut s, &rail);
run(&mut s, "sketch.finish", json!({}));
assert_p(&s, 45.0, "drawn");
// The source drops below the point's stored place: the point stays below the projection.
for height in [20.0, 50.0, 6.0, 400.0] {
set_height(&mut s, height);
assert_p(&s, height - 5.0, &format!("height {height}"));
}
// Editing the sketch afterwards refreshes the projection first; the side is the one from before.
set_height(&mut s, 20.0);
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
run(&mut s, "sketch.point", json!({"point": [80, 80]}));
run(&mut s, "sketch.finish", json!({}));
assert_p(&s, 15.0, "after an edit");
}
/// The same from the other side: the point above the line, the source rising past it and
/// coming back; and with the line brought in by Include 3D Geometry.
#[test]
fn a_point_above_the_line_stays_above_when_the_source_rises_past_and_returns() {
for command in ["sketch.project", "sketch.include_3d"] {
let mut s = Session::default();
let rail = point_by_the_rail(&mut s, command, 55.0);
five_from(&mut s, &rail);
run(&mut s, "sketch.finish", json!({}));
for height in [80.0, 400.0, 50.0, 6.0, 50.0] {
set_height(&mut s, height);
assert_p(&s, height + 5.0, &format!("{command}, height {height}"));
}
}
}
/// Model geometry in a sketch on XZ (whose y runs down the model's Z): the top of a box whose
/// height a parameter drives, as a projected edge, as an edge of a projected face and as a
/// section of the body. The point is 5 below the top, inside the box.
#[test]
fn a_distance_from_model_geometry_keeps_its_side_when_the_model_moves_past() {
// A projected edge is found again by the point picked on it, so it follows small steps
// only; a face (by its name) and a body follow any.
let small: &[f64] = &[42.0, 50.0, 58.0, 50.0, 44.0];
let large: &[f64] = &[20.0, 50.0, 6.0, 400.0];
let cases = [
("sketch.project", json!({"edge": [50, 0, 50]}), 0.0, small),
("sketch.project", json!({"face": [50, 0, 25]}), 0.0, large),
("sketch.intersect", json!({"body": "Body1"}), -20.0, large),
];
for (command, link, front, heights) in cases {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "height", "expression": "50 mm"}));
run(&mut s, "solid.box", json!({"corner": [0, front, 0], "length": 100, "width": 40, "height": "height"}));
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "S"}));
run(&mut s, command, json!({"refs": [link]}));
let curves = run(&mut s, "sketch.inspect", json!({}))["curves"].clone();
let high = |c: &Value, end: &str| (c[end][1].as_f64().unwrap() + 50.0).abs() < 1e-6;
let top = curves.as_array().unwrap().iter().find(|c| high(c, "start_at") && high(c, "end_at")).unwrap()["id"].as_str().unwrap().to_string();
run(&mut s, "sketch.point", json!({"point": [50, -45], "id": "p"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p"], "type": "horizontal", "value": "50 mm"}));
five_from(&mut s, &top);
run(&mut s, "sketch.finish", json!({}));
// (An edge found again by its picked point says so in a warning.)
for height in heights {
set_height(&mut s, *height);
assert!(p(&s).dist(Vec2::new(50.0, 5.0 - height)) < 1e-9, "{command} {link}, height {height}: {:?}", p(&s));
}
}
}
/// Another projection added after the source moved stores the sketch without solving it: the
/// sketch it stores is the one the model shows.
#[test]
fn a_projection_added_after_the_source_moved_leaves_the_side() {
let mut s = Session::default();
let rail = point_by_the_rail(&mut s, "sketch.project", 45.0);
five_from(&mut s, &rail);
run(&mut s, "sketch.finish", json!({}));
set_height(&mut s, 20.0);
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
run(&mut s, "sketch.project", json!({"refs": ["origin"]}));
run(&mut s, "sketch.finish", json!({}));
assert_p(&s, 15.0, "after the projection");
set_height(&mut s, 50.0);
assert_p(&s, 45.0, "back at 50");
}
/// The side is still the user's to choose, by where the point is when the dimension is added:
/// also when the source has moved past the point since it was drawn. That side is kept from
/// then on, and through an edit of the dimension's value.
#[test]
fn a_dimension_added_after_the_source_moved_takes_the_side_its_point_is_on() {
let mut s = Session::default();
let rail = point_by_the_rail(&mut s, "sketch.project", 45.0);
run(&mut s, "sketch.finish", json!({}));
// Drawn below the line; the line drops below it.
set_height(&mut s, 20.0);
assert_p(&s, 45.0, "no distance yet");
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
let distance = five_from(&mut s, &rail);
run(&mut s, "sketch.finish", json!({}));
assert_p(&s, 25.0, "dimensioned above the line");
for height in [50.0, 6.0, 400.0, 20.0] {
set_height(&mut s, height);
assert_p(&s, height + 5.0, &format!("height {height}"));
}
run(&mut s, "parameters.change", json!({"name": distance, "expression": "8 mm"}));
assert_p(&s, 28.0, "8 from the line");
set_height(&mut s, 6.0);
assert_p(&s, 14.0, "8 from the line, height 6");
}
@@ -0,0 +1,86 @@
//! `solid.move` with `copy: true`: the original body stays where it was, a new body is
//! pushed at the transform, and an older design file (no `copy` key at all) still loads.
use super::*;
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap()
}
fn cube20(s: &mut Session, name: &str) {
run(s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "body_name": name}));
}
fn volume(body: &solidcraft_doc::ModelBody) -> f64 {
solidcraft_kernel::measure(&body.body).unwrap().volume
}
#[test]
fn copy_keeps_the_original_and_adds_a_transformed_body() {
let mut s = Session::default();
cube20(&mut s, "Cube");
run(&mut s, "solid.move", json!({"bodies": ["Cube"], "translate": [30, 0, 0], "copy": true}));
let state = s.model.state();
assert_eq!(state.bodies.len(), 2, "a copy move must add one body, not replace the original");
let original = state.body("Cube").unwrap();
let original_bounds = original.mesh().bounds();
assert!(original_bounds.min.dist(Vec3::ZERO) < 1e-6, "the original body must stay at its own position: {original_bounds:?}");
assert!(original_bounds.max.dist(Vec3::new(20.0, 20.0, 20.0)) < 1e-6, "{original_bounds:?}");
let copy = state.bodies.iter().find(|b| b.name != "Cube").unwrap();
let copy_bounds = copy.mesh().bounds();
assert!(copy_bounds.min.dist(Vec3::new(30.0, 0.0, 0.0)) < 1e-6, "the copy must land at the translated position: {copy_bounds:?}");
assert!(copy_bounds.max.dist(Vec3::new(50.0, 20.0, 20.0)) < 1e-6, "{copy_bounds:?}");
let original_volume = volume(original);
let copy_volume = volume(copy);
assert!((original_volume - 8000.0).abs() < 1e-3, "original volume: {original_volume}");
assert!((copy_volume - 8000.0).abs() < 1e-3, "copy volume: {copy_volume}");
assert!((original_volume - copy_volume).abs() < 1e-6, "the copy must have the same mass properties as the source");
let total: f64 = state.bodies.iter().map(volume).sum();
assert!((total - 16000.0).abs() < 1e-3, "total volume across both bodies: {total}");
}
#[test]
fn without_copy_the_move_still_replaces_the_body_in_place() {
let mut s = Session::default();
cube20(&mut s, "Cube");
run(&mut s, "solid.move", json!({"bodies": ["Cube"], "translate": [30, 0, 0]}));
let state = s.model.state();
assert_eq!(state.bodies.len(), 1, "a plain move must not create a second body");
let moved = state.body("Cube").unwrap();
let bounds = moved.mesh().bounds();
assert!(bounds.min.dist(Vec3::new(30.0, 0.0, 0.0)) < 1e-6, "{bounds:?}");
}
#[test]
fn an_older_design_with_no_copy_key_at_all_still_loads_and_moves_in_place() {
let mut s = Session::default();
cube20(&mut s, "Cube");
run(&mut s, "solid.move", json!({"bodies": ["Cube"], "translate": [5, 0, 0]}));
let saved = s.doc.to_json();
assert!(!saved.contains("\"copy\""), "a move without the flag must serialize with no `copy` key, as an older file would read: {saved}");
// Simulate a design saved before this feature existed: strip the key even if present, then
// reopen through the same loader a real design file goes through.
let mut old: Value = serde_json::from_str(&saved).unwrap();
let features = old.get_mut("features").and_then(Value::as_array_mut).expect("features array");
let mut touched = false;
for f in features.iter_mut() {
if f.get("type").and_then(Value::as_str) == Some("move")
&& let Some(obj) = f.as_object_mut()
{
obj.remove("copy");
touched = true;
}
}
assert!(touched, "the move feature must be present in the saved design");
let migrated = solidcraft_doc::Document::from_json(&old.to_string()).unwrap();
let reopened = Session::new(migrated);
let state = reopened.model.state();
assert_eq!(state.bodies.len(), 1, "a pre-copy design must still move the one body in place, never duplicate it");
let moved = state.body("Cube").unwrap();
assert!(moved.mesh().bounds().min.dist(Vec3::new(5.0, 0.0, 0.0)) < 1e-6);
}
+5 -12
View File
@@ -67,20 +67,13 @@ fn sketch_and_offset_plane_on_the_top_face_of_a_face_sketched_extrude() {
assert!((plane.origin.z - 20.).abs() < 1e-9 && (plane.normal().z - 1.).abs() < 1e-9, "{case}: {plane:?}");
s.execute("construct.plane.offset", &json!({"base": {"face": top}, "offset": 5, "name": "Above"}))
.unwrap_or_else(|e| panic!("{case}: {e}"));
// The second extrude's flat side is an exact source too, unless a full-face
// Join merged it with the first extrude's side: that face belongs to neither,
// so it offers the explicit fixed plane with a readable reason.
// The second extrude's flat side attaches too, also where a full-face Join
// merged it with the first extrude's side (#491): that face is known by its
// exact history name.
if !circle {
let side = if full { [40., 20., 15.] } else { [30., 20., 15.] };
let params = json!({"plane": {"face": side}, "name": "Side"});
if full && operation == "join" {
let error = s.execute("sketch.create", &params).unwrap_err().to_string();
assert!(error.contains("merged") && error.contains("fixed:true"), "{case}: {error}");
run(&mut s, "sketch.create", json!({"plane": {"face": side, "fixed": true}, "name": "Side"}));
} else {
s.execute("sketch.create", &params).unwrap_or_else(|e| panic!("{case} side: {e}"));
assert!((healthy(&s, "Side").normal().x - 1.).abs() < 1e-9, "{case}");
}
s.execute("sketch.create", &json!({"plane": {"face": side}, "name": "Side"})).unwrap_or_else(|e| panic!("{case} side: {e}"));
assert!((healthy(&s, "Side").normal().x - 1.).abs() < 1e-9, "{case}");
run(&mut s, "sketch.finish", json!({}));
}
s.refresh();
+153
View File
@@ -0,0 +1,153 @@
//! What the application draws of a large sheet with small bends and holes, and that what
//! reads the same mesh (bounds, picks, inside tests) answers as before.
use std::f64::consts::{FRAC_PI_2, TAU};
use serde_json::{Value, json};
use solidcraft_geom::{Mesh, Vec3};
use solidcraft_kernel::{DISPLAY_ANGLE, FaceSurface, face_surface};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
const HOLES: [[f64; 2]; 4] = [[30.0, 20.0], [1500.0, 20.0], [2970.0, 980.0], [30.0, 980.0]];
/// A 3000 x 1000 x 2 sheet with bend radius 1, four holes of diameter 4 and a 40 mm flange on
/// each long side.
fn large_sheet() -> Session {
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [3000, 1000]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Holes"}));
for c in HOLES {
run(&mut s, "sketch.circle.center", json!({"center": c, "diameter": 4}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Holes", "distance": 10, "through_all": true, "operation": "cut"}));
run(&mut s, "sheet.flange", json!({"edges": [[1500, 0, 2], [1500, 1000, 2]], "height": 40}));
s
}
/// The widest angle between neighbouring lines of vertices around a cylinder face, and how
/// many such steps the face has.
fn steps_around(m: &Mesh, face: usize, origin: Vec3, axis: Vec3) -> (usize, f64) {
let u = axis.any_perp();
let v = axis.cross(u);
let mut a: Vec<f64> = m
.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, f)| **f as usize == face)
.flat_map(|(t, _)| t.iter())
.map(|k| m.positions[*k as usize] - origin)
.map(|d| d.dot(v).atan2(d.dot(u)))
.collect();
a.sort_by(f64::total_cmp);
a.dedup_by(|x, y| (*x - *y).abs() < 1e-7);
// Leave out the widest gap: the part of the turn the face does not cover.
let n = a.len();
let mut gaps: Vec<f64> = (0..n).map(|i| (a[(i + 1) % n] - a[i]).rem_euclid(TAU)).collect();
gaps.sort_by(f64::total_cmp);
gaps.pop();
(gaps.len(), gaps.last().copied().unwrap_or(0.0))
}
#[test]
fn a_large_sheet_shows_its_small_bends_and_holes_round() {
let s = large_sheet();
let st = s.world_state();
let b = &st.bodies[0];
let m = b.mesh();
assert!(b.body.size() * 1e-3 > 3.0, "the chord tolerance is more than any radius here");
let (mut bends, mut walls) = (0, 0);
for face in 0..b.body.face_count() {
let Some(FaceSurface::Cylinder { origin, axis, radius }) = face_surface(&b.body, face) else { continue };
let (steps, widest) = steps_around(&m, face, origin, axis);
assert!(widest <= DISPLAY_ANGLE * 1.001, "radius {radius}: a facet spans {} degrees", widest.to_degrees());
if (radius - 2.0).abs() < 1e-6 {
// Half of a hole's wall.
walls += 1;
assert!(steps >= (TAU / 2.0 / DISPLAY_ANGLE).ceil() as usize, "{steps} facets around half a hole");
} else {
// A bend's inner (1 mm) or outer (3 mm) side: a quarter turn.
bends += 1;
assert!((radius - 1.0).abs() < 1e-6 || (radius - 3.0).abs() < 1e-6, "radius {radius}");
assert!(steps >= (FRAC_PI_2 / DISPLAY_ANGLE).ceil() as usize, "radius {radius}: {steps} facets across the bend");
}
}
assert_eq!((bends, walls), (4, 8));
// The edges the viewport draws: every hole's rim is round, on both sides of the sheet.
for [x, y] in HOLES {
for z in [0.0, 2.0] {
let on = |p: &Vec3| (p.z - z).abs() < 1e-6 && ((p.x - x).hypot(p.y - y) - 2.0).abs() < 1e-6;
let rim: Vec<&Vec<Vec3>> = m.edges.iter().filter(|e| e.iter().all(on)).collect();
let segments: usize = rim.iter().map(|e| e.len() - 1).sum();
assert!(segments >= (TAU / DISPLAY_ANGLE).ceil() as usize, "{segments} segments around the rim at {x}, {y}, {z}");
let widest = rim.iter().flat_map(|e| e.windows(2)).map(|w| 2.0 * (w[0].dist(w[1]) / 4.0).asin()).fold(0.0, f64::max);
assert!(widest <= DISPLAY_ANGLE * 1.001, "a rim segment spans {} degrees", widest.to_degrees());
}
}
// And the scene the renderers take draws those same polylines.
let scene = crate::view::scene(&s, &crate::view::home_camera(&s));
let drawn: usize =
scene.lines.iter().filter(|l| l.points.iter().all(|p| ((p.x - 30.0).hypot(p.y - 20.0) - 2.0).abs() < 1e-6)).map(|l| l.points.len() - 1).sum();
assert!(drawn >= 2 * (TAU / DISPLAY_ANGLE).ceil() as usize, "{drawn} rim segments in the scene");
}
#[test]
fn what_reads_the_display_mesh_of_a_large_sheet_answers_as_before() {
let s = large_sheet();
let st = s.world_state();
let b = &st.bodies[0];
let m = b.mesh();
let coarse = b.body.display_mesh((b.body.size() * 1e-3).max(1e-3)).unwrap();
// Bounds: the sheet's box is set by flat faces, so the rounder bends move nothing.
let (now, before) = (m.bounds(), coarse.bounds());
assert!(now.min.dist(before.min) < 1e-9 && now.max.dist(before.max) < 1e-9, "{now:?} {before:?}");
let exact = solidcraft_kernel::measure(&b.body).unwrap().bbox;
assert!(now.min.dist(exact.min) < 1e-6 && now.max.dist(exact.max) < 1e-6, "{now:?} {exact:?}");
// The cost: the sheet takes 2212 triangles against the chord mesh's 92 when measured, a factor of
// 24; what grows is the number of round faces and the facets across each, not the size of the body.
assert!(
m.triangles.len() <= 30 * coarse.triangles.len() && m.triangles.len() < 4000,
"{} triangles, {} at the chord tolerance",
m.triangles.len(),
coarse.triangles.len()
);
// The same faces and edges, in the same order, between the same faces, with the same ends.
assert_eq!(m.edge_faces, coarse.edge_faces);
assert_eq!(m.tri_face.iter().max(), coarse.tri_face.iter().max());
assert_eq!(m.edges.len(), coarse.edges.len());
for (fine, rough) in m.edges.iter().zip(&coarse.edges) {
assert!(fine[0].dist(rough[0]) < 1e-9 && fine[fine.len() - 1].dist(rough[rough.len() - 1]) < 1e-9);
}
// Picks: a point on a round face (on the true cylinder, which a flat strip across a bend
// misses by up to 0.9 mm) is found on that face, as is one in the middle of a flat face.
let mut round = 0;
for face in 0..b.body.face_count() {
let Some(FaceSurface::Cylinder { origin, axis, radius }) = face_surface(&b.body, face) else { continue };
let corners: Vec<Vec3> =
m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == face).flat_map(|(t, _)| t.map(|k| m.positions[k as usize])).collect();
let middle = corners.iter().fold(Vec3::ZERO, |a, p| a + *p) / corners.len() as f64;
// The middle of the face's corners, pushed out onto the cylinder.
let along = origin + axis * (middle - origin).dot(axis);
let p = along + (middle - along).normalized().unwrap() * radius;
assert_eq!(solidcraft_doc::appearance::face_index_at(b, p), Some(face), "radius {radius} at {p:?}");
round += 1;
}
assert_eq!(round, 12);
let top = solidcraft_doc::appearance::face_index_at(b, Vec3::new(1500.0, 500.0, 2.0)).unwrap();
assert!(matches!(face_surface(&b.body, top), Some(FaceSurface::Plane { normal, .. }) if normal.dist(Vec3::Z) < 1e-9));
// Inside and outside: in the sheet, in a flange's wall, in a hole and above the sheet.
assert!(m.contains(Vec3::new(1500.0, 500.0, 1.0)) && m.contains(Vec3::new(1500.0, now.min.y + 1.0, now.max.z - 10.0)));
assert!(!m.contains(Vec3::new(30.0, 20.0, 1.0)) && !m.contains(Vec3::new(1500.0, 500.0, 3.0)));
}
+34
View File
@@ -252,3 +252,37 @@ fn root_sketch_on_a_moved_components_face() {
let pts = sketch_world_points(&mut s, "Top");
assert!(pts.iter().any(|p| near(*p, Vec3::new(-30.0, 40.0, 25.0))), "{pts:?}");
}
/// (#558) With the root active, construction geometry, a hole and Press Pull picked on a moved
/// component land on it where it is shown, and so do picks on a pasted instance.
#[test]
fn root_picks_on_a_moved_component_build_where_it_is_shown() {
let mut s = moved_part();
run(&mut s, "component.activate", json!({"component": "root"}));
// The block spans world x 50…80, y 0…20, z 0…10.
run(&mut s, "construct.axis.edge", json!({"edge": {"edge": [65, 0, 0]}, "name": "FrontEdge"}));
run(
&mut s,
"construct.plane.three_points",
json!({"a": {"vertex": [50, 0, 10]}, "b": {"vertex": [80, 0, 10]}, "c": {"vertex": [50, 20, 10]}, "name": "Lid"}),
);
let lid = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Lid").unwrap().2;
assert!((lid.origin.z - 10.0).abs() < 1e-6 && lid.normal().z.abs() > 1.0 - 1e-9, "{lid:?}");
let st = s.model.state();
let axis = st.construct.iter().find(|c| c.name == "FrontEdge").map(|c| c.geom.clone()).unwrap();
let solidcraft_doc::construct::ConstructGeom::Axis { origin, dir } = axis else { panic!("{axis:?}") };
assert!(origin.y.abs() < 1e-6 && origin.z.abs() < 1e-6 && dir.x.abs() > 1.0 - 1e-9, "{origin:?} {dir:?}");
let v0 = volume(&mut s);
run(&mut s, "solid.hole", json!({"position": [60, 10, 10], "diameter": 4, "depth": 5}));
let v1 = volume(&mut s);
assert!(v1 < v0 - 1.0, "the hole cut the moved block: {v0} {v1}");
run(&mut s, "solid.press_pull", json!({"face": [80, 10, 5], "distance": 2}));
assert!(volume(&mut s) > v1 + 1.0, "press pull grew the moved block");
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
// A pasted instance 40 mm further along y.
run(&mut s, "occurrence.copy", json!({"component": "Part", "translate": [0, 40, 0]}));
run(&mut s, "construct.plane.along_path", json!({"path": {"edge": [65, 40, 0]}, "t": 0.5, "name": "Across"}));
let across = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Across").unwrap().2;
assert!((across.origin.y - 40.0).abs() < 1e-6, "{across:?}");
}
+89
View File
@@ -0,0 +1,89 @@
//! A hole placed on a face follows that face along the hole's axis, never sideways: when the face
//! moves the hole moves with it, and when the face no longer reaches the place the hole stays
//! where it was put and says so instead of sliding to the face's nearest point.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn change(s: &mut Session, name: &str, expression: &str) {
run(s, "parameters.change", json!({"name": name, "expression": expression}));
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
}
/// Whether the model holds material at (x, y, z).
fn solid_at(s: &Session, x: f64, y: f64, z: f64) -> bool {
s.model.state().bodies.iter().any(|b| b.body.contains(Vec3::new(x, y, z)))
}
/// What the hole feature says (its error and its warning); empty when it is silent.
fn said(s: &Session, feature: &str) -> String {
let id = s.doc.find_feature(feature).unwrap().id;
let r = s.model.result(id).unwrap();
format!("{}{}", r.error.clone().unwrap_or_default(), r.warning.clone().unwrap_or_default())
}
#[test]
fn a_hole_the_face_no_longer_reaches_stays_where_it_was_placed_and_says_so() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "length", "expression": "100 mm"}));
run(&mut s, "solid.box", json!({"length": "length", "width": 50, "height": 20, "name": "Plate"}));
run(&mut s, "solid.hole", json!({"position": [80, 25, 20], "diameter": "6 mm", "name": "Hole"}));
// The bore is at x = 80, 3 mm either way.
let drilled_at_80 = |s: &Session| !solid_at(s, 80.0, 25.0, 10.0) && solid_at(s, 76.0, 25.0, 10.0) && solid_at(s, 84.0, 25.0, 10.0);
assert!(drilled_at_80(&s));
// Longer: the place is still on the top face, and the hole stays at x = 80.
change(&mut s, "length", "150 mm");
assert_eq!(said(&s, "Hole"), "");
assert!(drilled_at_80(&s));
// Shorter than the place: nothing is drilled at the new end, and the hole says why. A hole
// moved to the face's nearest point would cut half a hole there (59 717 mm³ left).
change(&mut s, "length", "60 mm");
let left = volume(&mut s);
assert!((left - 60_000.0).abs() < 1.0, "half a hole was cut at the plate's end: {left} mm³ of 60000");
let msg = said(&s, "Hole");
assert!(msg.contains("no longer on face") && msg.contains("[80, 25, 20]"), "{msg}");
// Long again: the hole is back where it was put, with no message.
change(&mut s, "length", "100 mm");
assert_eq!(said(&s, "Hole"), "");
assert!(drilled_at_80(&s));
}
#[test]
fn a_slanted_hole_stays_on_its_axis_when_its_face_moves() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "height", "expression": "20 mm"}));
run(&mut s, "solid.box", json!({"length": 60, "width": 30, "height": "height", "name": "Block"}));
// Entering the top face at x = 20, running on in x as it goes down: 1 across for every 2 down.
run(&mut s, "solid.hole", json!({"position": [20, 15, 20], "direction": [1, 0, -2], "diameter": "2 mm", "name": "Hole"}));
// The axis is the line through the place along the direction, whatever the height of the block:
// x = 20 + (20 - z) / 2. At mid-height the bore is empty on it and solid 3 mm to either side
// (the bore is about 2.2 mm wide there).
let on_axis = |s: &Session, h: f64| {
let (x, z) = (20.0 + (20.0 - h / 2.0) / 2.0, h / 2.0);
assert!(!solid_at(s, x, 15.0, z), "block height {h}: the bore should pass through x = {x} at z = {z}");
assert!(solid_at(s, x - 3.0, 15.0, z) && solid_at(s, x + 3.0, 15.0, z), "block height {h}: material 3 mm beside the axis");
// (The kernel may note a boolean retry for a slanted bore; the hole's place is on its face.)
assert!(!said(s, "Hole").contains("no longer on face"), "block height {h}: {}", said(s, "Hole"));
};
on_axis(&s, 20.0);
// Taller: the top face moves up. Moved to the face's nearest point the hole would slide
// sideways onto another axis (5 mm further along x at mid-height); along its axis it enters
// higher up the same line.
change(&mut s, "height", "30 mm");
on_axis(&s, 30.0);
// Lower: the same line enters further along.
change(&mut s, "height", "12 mm");
on_axis(&s, 12.0);
}
+34 -20
View File
@@ -27,6 +27,7 @@ use solidcraft_geom::Vec3;
pub use cmd::{CommandInfo, CommandSpec, auto_operation, command_specs, find_command};
pub use solidcraft_doc as doc;
pub use solidcraft_doc::Visibility;
pub use solidcraft_geom as geom;
pub use solidcraft_io as io;
pub use solidcraft_kernel as kernel;
@@ -217,8 +218,15 @@ pub struct Session {
pub section: Option<(Vec3, Vec3)>,
/// Surface analysis shading the model (zebra, draft, curvature map).
pub analysis: Option<SurfaceAnalysis>,
/// What is hidden or shown in the view (`browser.visibility`).
/// What is hidden or shown in the view (`browser.visibility`): saved with the design, not an
/// undo step. A design brings its saved visibility when it opens (`Session::new`).
pub visibility: Visibility,
/// The camera `view.apply` asked the view to use (view state, not saved). The desktop app
/// moves its viewport there and takes it; headless renders keep using it until a standard
/// view is chosen.
pub camera: Option<render::Camera>,
/// The visibility the design file holds (what `is_dirty` compares with).
saved_visibility: Visibility,
/// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of
/// commands are undone however long the undo history is.
pub(crate) checkpoints: Vec<Checkpoint>,
@@ -252,22 +260,6 @@ pub(crate) struct Checkpoint {
pub redo: Vec<Snapshot>,
}
/// Items hidden or shown one by one (view state, not part of the design and not an undo step).
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Visibility {
/// Bodies by name.
pub hidden_bodies: Vec<String>,
/// Exact occurrence placements hidden with all descendants.
pub hidden_occurrences: Vec<Vec<u64>>,
/// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction geometry, by name.
pub hidden_origin: Vec<String>,
/// Sketches by feature id.
pub hidden_sketches: Vec<u64>,
/// Finished sketches shown although a feature uses them.
pub shown_sketches: Vec<u64>,
}
/// A way of shading the model's faces to judge their shape (view state, not part of the design).
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -297,7 +289,9 @@ impl Default for Session {
}
impl Session {
pub fn new(doc: Document) -> Self {
pub fn new(mut doc: Document) -> Self {
// The session keeps what is hidden (not an undo step); the design file carries it.
let visibility = std::mem::take(&mut doc.visibility);
let doc = Arc::new(doc);
let mut model = Model::new();
model.evaluate(&doc);
@@ -324,18 +318,32 @@ impl Session {
analysis: None,
checkpoints: Vec::new(),
next_checkpoint: 1,
visibility: Visibility::default(),
saved_visibility: visibility.clone(),
visibility,
camera: None,
#[cfg(test)]
evaluations: 0,
crash_journal: false,
}
}
/// The design has changes not in its file: in the document, or in what is hidden.
pub fn is_dirty(&self) -> bool {
!Arc::ptr_eq(&self.doc, &self.saved) && *self.doc != *self.saved
(!Arc::ptr_eq(&self.doc, &self.saved) && *self.doc != *self.saved) || self.visibility != self.saved_visibility
}
pub fn mark_saved(&mut self) {
self.saved = self.doc.clone();
self.saved_visibility = self.visibility.clone();
}
/// The design as a file holds it: the document with what is hidden in it.
pub fn design(&self) -> std::borrow::Cow<'_, Document> {
if self.visibility.is_empty() {
return std::borrow::Cow::Borrowed(&*self.doc);
}
let mut doc = (*self.doc).clone();
doc.visibility = self.visibility.clone();
std::borrow::Cow::Owned(doc)
}
/// The design has changes not in its file (a recovered design).
pub fn mark_unsaved(&mut self) {
@@ -667,6 +675,8 @@ impl Session {
checkpoints: Vec::new(),
next_checkpoint: 1,
visibility: self.visibility.clone(),
camera: self.camera,
saved_visibility: self.saved_visibility.clone(),
#[cfg(test)]
evaluations: 0,
crash_journal: false,
@@ -793,8 +803,12 @@ mod construct_tests;
#[cfg(test)]
mod delete_face_tests;
#[cfg(test)]
mod display_mesh_tests;
#[cfg(test)]
mod extent_tests;
#[cfg(test)]
mod hole_face_tests;
#[cfg(test)]
mod naming_history_tests;
#[cfg(test)]
mod pattern_suppress_tests;
+12 -8
View File
@@ -35,10 +35,13 @@ pub fn default_dir() -> Option<PathBuf> {
env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share")))?
};
let (dir, legacy) = dirs_under(&root, cfg!(windows) || cfg!(target_os = "macos"));
// Compatibility shim (docs/compatibility.md): autosaves made before the rename are copied
// to the new folder on first launch; the old folder is left as it was.
if let Err(e) = solidcraft_io::adopt_legacy_dir(&dir, &legacy) {
log::warn!("recovery: could not copy {} to {}: {e}", legacy.display(), dir.display());
// Compatibility shim (docs/compatibility.md): autosaves made before the rename (or, in the
// Flatpak sandbox, under the old app id) are copied to the new folder on first launch; the
// old folder is left as it was.
for old in solidcraft_io::legacy_flatpak_path(&dir).into_iter().chain([legacy]) {
if let Err(e) = solidcraft_io::adopt_legacy_dir(&dir, &old) {
log::warn!("recovery: could not copy {} to {}: {e}", old.display(), dir.display());
}
}
Some(dir)
}
@@ -143,7 +146,8 @@ pub struct Autosaver {
/// Autosave this often while there are unsaved changes.
pub interval: Duration,
lock: Option<File>,
last: Option<Arc<Document>>,
/// The design and what was hidden at the last autosave.
last: Option<(Arc<Document>, crate::Visibility)>,
last_at: Instant,
has_entry: bool,
}
@@ -189,17 +193,17 @@ impl Autosaver {
crate::crash::rebased(s, s.path.as_deref().map(Path::new), false);
return Ok(false);
}
if self.last.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.doc) || **l == *s.doc) {
if self.last.as_ref().is_some_and(|(l, v)| (Arc::ptr_eq(l, &s.doc) || **l == *s.doc) && *v == s.visibility) {
return Ok(false);
}
let io = |e: std::io::Error| EngineError::Other(format!("autosave: {e}"));
let bytes = solidcraft_io::write_design_container(&s.doc)?;
let bytes = solidcraft_io::write_design_container(&s.design())?;
solidcraft_io::write_atomic(&file(&self.dir, &self.id, solidcraft_io::DESIGN_EXT), &bytes, false).map_err(io)?;
let entry =
Entry { id: self.id.clone(), path: s.path.clone(), name: s.doc.name.clone(), saved_at: unix_now(), features: s.doc.features.len() };
let meta = serde_json::to_vec_pretty(&entry).map_err(|e| EngineError::Other(e.to_string()))?;
solidcraft_io::write_atomic(&file(&self.dir, &self.id, "json"), &meta, false).map_err(io)?;
self.last = Some(s.doc.clone());
self.last = Some((s.doc.clone(), s.visibility.clone()));
self.has_entry = true;
// Crash replays start from this autosave from here.
crate::crash::rebased(s, Some(&file(&self.dir, &self.id, solidcraft_io::DESIGN_EXT)), true);
+181 -8
View File
@@ -96,6 +96,20 @@ fn join_extrude_from_a_chamfered_top_face() {
}
}
#[test]
fn a_body_cut_in_pieces_twice_gets_pieces_of_their_own_names() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 5, "height": 5, "body_name": "Bar"}));
for x in [10, 25] {
run(&mut s, "solid.box", json!({"corner": [x, -1, -1], "length": 2, "width": 7, "height": 7, "body_name": "Gap"}));
run(&mut s, "solid.combine", json!({"target": "Bar", "tools": ["Gap"], "operation": "cut"}));
}
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
let unique: std::collections::BTreeSet<&String> = names.iter().collect();
assert_eq!(unique.len(), 3, "{names:?}");
assert!(names.contains(&"Bar (2)".to_string()), "{names:?}");
}
/// A join that bridges two bodies makes them one body, not an overlap of the first and the second.
#[test]
fn join_bridging_two_bodies_merges_them() {
@@ -363,6 +377,51 @@ fn revolve_a_spherical_shell_off_the_origin() {
}
}
#[test]
fn extrude_of_adjacent_profiles_has_whole_edges() {
// A holder from two regions of one sketch (a disc and the ring around it, both cut off by a
// chord): the chord used to stay split where the inner circle met it, leaving vertices right
// on the surface of a handle of that radius, and cutting the handle out of the holder failed.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Holder"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 40}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 35}));
let x = (40.0f64 * 40.0 - 33.0 * 33.0).sqrt();
run(&mut s, "sketch.line", json!({"points": [[-x, -33], [x, -33]]}));
run(&mut s, "sketch.finish", json!({}));
let si = run(&mut s, "sketch.inspect", json!({"sketch": "Holder"}));
let below: Vec<Value> = si["profiles"]
.as_array()
.unwrap()
.iter()
.filter(|p| (500.0..4900.0).contains(&p["area_mm2"].as_f64().unwrap()))
.map(|p| p["index"].clone())
.collect();
assert_eq!(below.len(), 2, "{si}");
run(&mut s, "solid.extrude", json!({"sketch": "Holder", "profiles": below, "distance": 23, "body_name": "Holder"}));
let topo = run(&mut s, "model.faces", json!({"body": "Holder"}));
assert_eq!(topo["faces"].as_array().unwrap().len(), 5, "a cut-off cylinder: {topo}");
let lo = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["bodies"][0]["bbox"]["min"][1].as_f64().unwrap();
run(&mut s, "solid.move", json!({"bodies": ["Holder"], "translate": [0, -5.0 - lo, 0]}));
// The handle: r35 on the Y axis with a bevelled end.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Handle"}));
for pts in [
[[0.0, 0.0], [0.0, 230.0]],
[[0.0, 0.0], [25.0, 0.0]],
[[25.0, 0.0], [35.0, 11.5]],
[[35.0, 11.5], [35.0, 230.0]],
[[35.0, 230.0], [0.0, 230.0]],
] {
run(&mut s, "sketch.line", json!({ "points": pts }));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.revolve", json!({"sketch": "Handle", "profiles": [0], "axis": "Y", "body_name": "Handle"}));
let before = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["total"]["volume_mm3"].as_f64().unwrap();
run(&mut s, "solid.combine", json!({"target": "Holder", "tools": ["Handle"], "operation": "cut", "keep_tools": true}));
let after = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(after > 1000.0 && after < before * 0.5, "{before} -> {after}");
}
#[test]
fn sketch_tools_and_constraints() {
let mut s = Session::default();
@@ -771,12 +830,16 @@ fn step_open_then_model_on_it() {
run(&mut s, "solid.hole", json!({"position": at(28.0, 15.0, 20.0), "diameter": 6}));
want -= PI * 9.0 * 20.0;
assert!(rel(volume(&mut s), want) < 5e-4, "{} vs {want}", volume(&mut s));
// Modified imported faces no longer have an immutable construction certificate.
// The public attachment refuses them; the user can explicitly choose a fixed plane.
let before_fixed = s.doc.to_json();
assert!(s.execute("sketch.create", &json!({"plane":{"face":at(10.0,15.0,20.0)}})).is_err());
assert_eq!(s.doc.to_json(), before_fixed);
run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0), "fixed": true}}));
// Modified imported faces no longer have an immutable construction certificate; the top
// attaches by its exact history name instead.
let top = run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0)}}))["sketch"].as_u64().unwrap();
assert!(matches!(
s.placed_document().sketch_face_attachment(top, &s.model),
doc::FaceAttachmentStatus::Healthy {
target: doc::config::ConfigFaceTarget { certificate: Some(doc::config::FaceCertificate::Lineage), .. },
..
}
));
let c = [x0 + 10.0, y0 + 15.0];
// Sketch coordinates on the face are relative to the model origin projected onto it.
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [c[0] - 4.0, c[1] - 4.0], "p1": [c[0] + 4.0, c[1] + 4.0]}));
@@ -2747,7 +2810,6 @@ fn a_pattern_repeats_a_fillet_on_the_edges_it_found() {
/// A copy added by raising the count after a rename takes the next number, not the freed one (#372).
#[test]
#[ignore = "fails until #372 is fixed"]
fn raising_a_pattern_count_after_a_rename_numbers_the_new_copy_after_the_others() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
@@ -2780,7 +2842,6 @@ fn a_profile_added_later_is_named_after_body_name() {
/// The reply names the bodies the feature created, apart from the ones already there (#374).
#[test]
#[ignore = "fails until #374 is fixed"]
fn extrude_reply_lists_the_bodies_it_created() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [0, 50, 0], "operation": "new_body", "body_name": "Base"}));
@@ -2848,6 +2909,33 @@ fn fillet_fit_reduce_uses_the_largest_radius_that_fits() {
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "reduce", "type": "chord"})).is_err());
}
#[test]
fn fillet_fit_reduce_keeps_the_radius_where_it_fits() {
// A 2 mm fin on a block: its top edge can't take 4, a corner of the block far from it can.
// Only the fin's edge is reduced; the reply gives each edge's radius.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.box", json!({"length": 2, "width": 30, "height": 10, "corner": [19, 0, 20], "operation": "join"}));
assert_eq!(s.model.state().bodies.len(), 1);
let edges = json!([[21, 15, 30], [40, 0, 10]]);
let out = run(&mut s, "solid.fillet", json!({"edges": edges, "radius": 4, "fit": "reduce"}));
let fin = out["radius_applied"].as_f64().unwrap();
assert!(fin < 4.0, "{out}");
let each: Vec<(Vec<f64>, f64)> = out["radii_applied"]
.as_array()
.unwrap()
.iter()
.map(|x| (x["edge"].as_array().unwrap().iter().filter_map(Value::as_f64).collect(), x["radius"].as_f64().unwrap()))
.collect();
assert_eq!(each, [(vec![21.0, 15.0, 30.0], fin), (vec![40.0, 0.0, 10.0], 4.0)], "{out}");
let removed = (1.0 - PI / 4.0) * (fin * fin * 30.0 + 16.0 * 20.0);
let want = 40.0 * 30.0 * 20.0 + 2.0 * 30.0 * 10.0 - removed;
assert!(rel(volume(&mut s), want) < 2e-4, "{} vs {want}", volume(&mut s));
let id = out["feature"].as_u64().unwrap();
let warning = s.model.result(id).and_then(|r| r.warning.clone()).unwrap_or_default();
assert!(warning.contains("on 1 of 2 edges"), "{warning}");
}
#[test]
fn a_pattern_of_a_reduced_fillet_rounds_each_copy_alike() {
// #293: a 6 mm wide post on a plate, its four vertical edges at 3.5 reduced to 3, patterned
@@ -2861,3 +2949,88 @@ fn a_pattern_of_a_reduced_fillet_rounds_each_copy_alike() {
let expect = 60.0 * 20.0 * 2.0 + 3.0 * (840.0 - (4.0 - PI) * 9.0 * 10.0);
assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s));
}
/// A taper on a profile of lines and an arc (#546). The D profile (back 9.2, sides 7.4, a
/// half-round front R 4.6 tangent to them) grows by d = z · tan 5° with mitred corners:
/// A(d) = A₀ + P·d + (2 + π/2)·d², integrated over the 10 mm height.
#[test]
fn tapered_extrude_of_a_profile_mixing_lines_and_an_arc() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "D"}));
run(&mut s, "sketch.line", json!({"points": [[0, 4.6], [-7.4, 4.6], [-7.4, -4.6], [0, -4.6]], "closed": false}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [0, -4.6], "end": [0, 4.6]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "D", "distance": 10, "taper": "5 deg"}));
let (t, h) = (5f64.to_radians().tan(), 10.0);
let (a0, p, k) = (7.4 * 9.2 + PI * 4.6 * 4.6 / 2.0, 2.0 * 7.4 + 9.2 + PI * 4.6, 2.0 + PI / 2.0);
let expect = a0 * h + p * t * h * h / 2.0 + k * t * t * h.powi(3) / 3.0;
assert!(rel(volume(&mut s), expect) < 1e-3, "{} vs {expect}", volume(&mut s));
}
/// A feature that joins into a body creates none, and its reply says so; a primitive's reply
/// names the body it made (#374).
#[test]
fn feature_replies_list_only_the_bodies_they_created() {
let mut s = Session::default();
let r = run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Base"}));
assert_eq!(r["created_bodies"], json!(["Base"]), "{r}");
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Boss"}));
run(&mut s, "sketch.circle.center", json!({"center": [5, 5], "radius": 2}));
run(&mut s, "sketch.finish", json!({}));
let r = run(&mut s, "solid.extrude", json!({"sketch": "Boss", "distance": "15", "operation": "join"}));
assert_eq!(r["created_bodies"], json!([]), "{r}");
assert_eq!(r["bodies"], json!(["Base"]), "{r}");
}
/// A feature reply also says which existing bodies it changed and which are gone, so a client
/// that skips the full body list (MCP `batch` with `verbose: false`, #530) still sees every effect.
#[test]
fn feature_replies_list_the_bodies_they_changed_and_removed() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new", "body_name": "B"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0], "operation": "new", "body_name": "C"}));
// A pocket in A changes A only.
let r = run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 10, "corner": [3, 3, 5], "operation": "cut", "targets": ["A"]}));
assert_eq!((&r["created_bodies"], &r["changed_bodies"], &r["removed_bodies"]), (&json!([]), &json!(["A"]), &json!([])), "{r}");
// Joining C into B changes B and removes C.
let r = run(&mut s, "solid.combine", json!({"target": "B", "tools": ["C"], "operation": "join"}));
assert_eq!((&r["changed_bodies"], &r["removed_bodies"]), (&json!(["B"]), &json!(["C"])), "{r}");
// A new body changes nothing else.
let r = run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [0, 50, 0], "operation": "new", "body_name": "D"}));
assert_eq!((&r["created_bodies"], &r["changed_bodies"], &r["removed_bodies"]), (&json!(["D"]), &json!([]), &json!([])), "{r}");
}
/// Drafting a cylinder's side face 5° from the base plane makes a cone frustum (#547): radius 10
/// at the base, 10 − 20 · tan 5° at the top.
#[test]
fn draft_on_a_cylinder_side_makes_a_frustum() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 20}));
run(&mut s, "solid.draft", json!({"faces": [[10, 0, 10]], "angle": "5 deg", "neutral": "XY"}));
let (big, h) = (10.0, 20.0);
let small = big - h * 5f64.to_radians().tan();
let expect = PI * h / 3.0 * (big * big + big * small + small * small);
assert!(rel(volume(&mut s), expect) < 1e-3, "{} vs {expect}", volume(&mut s));
}
/// The copies added after a rename keep their numbers through undo/redo, save/reopen and a
/// further count change; lowering the count drops the last copies (#372).
#[test]
fn pattern_copies_added_after_a_rename_keep_their_numbers() {
let mut s = Session::default();
let names = |s: &Session| -> Vec<String> { s.model.state().bodies.iter().map(|b| b.name.clone()).collect() };
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20, "name": "Pat"}));
run(&mut s, "body.rename", json!({"body": "X (2)", "name": "Second"}));
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "5"}}}));
assert_eq!(names(&s), ["X", "Second", "X (3)", "X (4)", "X (5)"]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&s), ["X", "Second", "X (3)"]);
run(&mut s, "edit.redo", json!({}));
assert_eq!(names(&s), ["X", "Second", "X (3)", "X (4)", "X (5)"]);
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(names(&reopened), ["X", "Second", "X (3)", "X (4)", "X (5)"]);
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "2"}}}));
assert_eq!(names(&s), ["X", "Second"]);
}
+35
View File
@@ -67,3 +67,38 @@ fn modelled_internal_threads_m3_to_m20() {
assert!((got - want).abs() < want * 0.03, "M{d}: removed {got}, ISO profile {want}");
}
}
/// Issue 602: rounding the edges of a hex boss on a threaded shaft froze the app. The thread's
/// thousands of facets were healed pair by pair, once per feature after it, and the fillet's
/// preview then compared each of them with the whole old body.
#[test]
fn fillet_beside_a_modelled_thread_finishes() {
let mut s = Session::default();
for (id, p) in [
("sketch.create", json!({"plane": "XY"})),
("sketch.circle.center", json!({"center": [0, 0], "diameter": 5})),
("sketch.finish", json!({})),
("solid.extrude", json!({"distance": 10, "body_name": "Screw"})),
("sketch.create", json!({"plane": {"face": [0, 0, 10]}})),
("sketch.polygon.circumscribed", json!({"center": [0, 0], "radius": 2, "sides": 6})),
("sketch.finish", json!({})),
("solid.extrude", json!({"distance": 3, "profiles": [{"point": [0, 0]}], "operation": "join", "targets": ["Screw"]})),
("solid.thread", json!({"face": [2.5, 0, 5], "designation": "M5", "length": 4, "modeled": true})),
] {
run(&mut s, id, p);
}
let faces_before = s.model.state().body("Screw").map(|b| b.body.face_count()).unwrap_or(0);
assert!(faces_before > 1000, "the thread is faceted: {faces_before} faces");
let volume_before = volume(&mut s, "Screw");
let top: Vec<Value> = (0..6)
.map(|i| {
let a = (30.0 + 60.0 * i as f64).to_radians();
json!([2.0 * a.cos(), 2.0 * a.sin(), 13])
})
.collect();
let started = std::time::Instant::now();
run(&mut s, "solid.fillet", json!({"edges": top, "radius": 0.3}));
assert!(started.elapsed().as_secs() < 30, "the fillet took {:?}", started.elapsed());
let volume_after = volume(&mut s, "Screw");
assert!(volume_after < volume_before && volume_after > volume_before * 0.99, "{volume_before} -> {volume_after}");
}
+1 -1
View File
@@ -25,7 +25,7 @@ fn sampler_keeps_style_precedence_hidden_references_text_and_disjoint_parts() {
let text = sk
.add_link(
LinkKind::Text,
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0. },
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0., font: None },
&[LinkGeom::Line(Vec2::new(0., 7.), Vec2::new(2., 7.))],
)
.unwrap();
+409
View File
@@ -0,0 +1,409 @@
//! SolidCraft-owned portable B-rep data, independent of a modelling backend.
//! Millimetres, radians, right-handed Z-up. See docs/native-brep.md for semantics.
//! Validation checks structure and resource budgets, not geometric equivalence or trust.
use serde::{Deserialize, Serialize};
pub mod envelope;
pub const VERSION: u32 = 2;
pub const MAX_ITEMS: usize = 100_000;
const MAX_DEPTH: usize = 16;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Brep {
pub version: u32,
pub shells: Vec<Shell>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Shell {
pub vertices: Vec<[f64; 3]>,
pub edges: Vec<Edge>,
pub faces: Vec<Face>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Edge {
pub vertices: [u32; 2],
pub curve: Curve,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Face {
pub loops: Vec<Vec<EdgeUse>>,
pub surface: Surface,
pub forward: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeUse {
pub edge: u32,
pub forward: bool,
}
/// Right-handed orthonormal frame; z = x_axis cross y_axis.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Frame {
pub origin: [f64; 3],
pub x_axis: [f64; 3],
pub y_axis: [f64; 3],
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Curve {
Line { start: [f64; 3], end: [f64; 3] },
CircleArc { frame: Frame, radius: f64, angle: [f64; 2] },
EllipseArc { frame: Frame, radii: [f64; 2], angle: [f64; 2] },
Polyline { points: Vec<[f64; 3]> },
Spline { knots: Vec<f64>, points: Vec<[f64; 3]> },
RationalSpline { knots: Vec<f64>, homogeneous: Vec<[f64; 4]> },
Intersection { knots: Vec<f64>, points: Vec<[f64; 3]>, fitting_tolerance: f64, faces: Option<[u32; 2]> },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Surface {
Plane { origin: [f64; 3], u_axis: [f64; 3], v_axis: [f64; 3] },
Cylinder { frame: Frame, radius: f64, angle: [f64; 2], height: [f64; 2] },
Cone { frame: Frame, radius_at_origin: f64, half_angle: f64, angle: [f64; 2], height: [f64; 2] },
Sphere { frame: Frame, radius: f64, longitude: [f64; 2], latitude: [f64; 2] },
Torus { frame: Frame, major_radius: f64, minor_radius: f64, longitude: [f64; 2], meridian: [f64; 2] },
Spline { u_knots: Vec<f64>, v_knots: Vec<f64>, points: Vec<Vec<[f64; 3]>> },
RationalSpline { u_knots: Vec<f64>, v_knots: Vec<f64>, homogeneous: Vec<Vec<[f64; 4]>> },
Revolution { profile: Box<Curve>, origin: [f64; 3], axis: [f64; 3], angle: [f64; 2] },
}
#[derive(Clone, Debug, thiserror::Error)]
#[error("portable B-rep: {0}")]
pub struct Invalid(pub &'static str);
type Result<T> = std::result::Result<T, Invalid>;
fn require(condition: bool, message: &'static str) -> Result<()> {
if condition { Ok(()) } else { Err(Invalid(message)) }
}
struct Budget(usize);
impl Budget {
fn add(&mut self, n: usize) -> Result<()> {
self.0 = self.0.checked_add(n).ok_or(Invalid("item budget"))?;
require(self.0 <= MAX_ITEMS, "item budget")
}
}
fn numbers(values: impl IntoIterator<Item = f64>) -> Result<()> {
require(values.into_iter().all(|v| v.is_finite() && v.abs() <= 1e12), "non-finite or excessive coordinate")
}
fn points<const N: usize>(values: &[[f64; N]], budget: &mut Budget) -> Result<()> {
budget.add(values.len())?;
numbers(values.iter().flatten().copied())
}
fn knots(values: &[f64], count: usize, budget: &mut Budget) -> Result<()> {
budget.add(values.len())?;
numbers(values.iter().copied())?;
let degree = values.len().checked_sub(count.saturating_add(1)).ok_or(Invalid("spline degree"))?;
require((2..=4096).contains(&count) && (1..=16).contains(&degree) && degree < count, "spline degree or size")?;
require(values.windows(2).all(|w| w.first() <= w.get(1)), "unordered knots")?;
require(values.get(degree) < values.get(count), "empty spline domain")?;
// No knot may have multiplicity greater than degree + 1.
let mut run = 1;
for pair in values.windows(2) {
run = if pair.first() == pair.get(1) { run + 1 } else { 1 };
require(run <= degree + 1, "knot multiplicity")?;
}
Ok(())
}
fn weights(values: &[[f64; 4]]) -> Result<()> {
require(values.iter().all(|p| p.get(3).is_some_and(|w| *w >= 1e-12)), "non-positive rational weight")
}
fn grid<const N: usize>(u: &[f64], v: &[f64], rows: &[Vec<[f64; N]>], budget: &mut Budget) -> Result<()> {
let columns = rows.first().map_or(0, Vec::len);
knots(u, rows.len(), budget)?;
knots(v, columns, budget)?;
for row in rows {
require(row.len() == columns, "ragged surface")?;
points(row, budget)?;
}
Ok(())
}
fn vector(p: [f64; 3]) -> crate::Vec3 {
crate::Vec3::new(p[0], p[1], p[2])
}
fn frame(f: &Frame, budget: &mut Budget) -> Result<()> {
points(&[f.origin, f.x_axis, f.y_axis], budget)?;
let (x, y) = (vector(f.x_axis), vector(f.y_axis));
require((x.len() - 1.).abs() <= 1e-8 && (y.len() - 1.).abs() <= 1e-8 && x.dot(y).abs() <= 1e-8, "nonorthonormal frame")
}
fn range(r: &[f64; 2]) -> Result<()> {
numbers(*r)?;
require(r[0] < r[1], "empty parameter range")
}
fn angle(r: &[f64; 2]) -> Result<()> {
range(r)?;
require(r[1] - r[0] <= std::f64::consts::TAU + 1e-12, "angular range exceeds one turn")
}
fn radius(r: f64) -> Result<()> {
require(r.is_finite() && (1e-12..=1e12).contains(&r), "radius")
}
fn curve(c: &Curve, depth: usize, budget: &mut Budget) -> Result<()> {
require(depth <= MAX_DEPTH, "geometry depth")?;
budget.add(1)?;
match c {
Curve::Line { start, end } => {
points(&[*start, *end], budget)?;
require(start != end, "collapsed line")
}
Curve::CircleArc { frame: f, radius: r, angle: a } => {
frame(f, budget)?;
radius(*r)?;
angle(a)
}
Curve::EllipseArc { frame: f, radii, angle: a } => {
frame(f, budget)?;
for r in radii {
radius(*r)?;
}
angle(a)
}
Curve::Polyline { points: p } => {
require(p.len() >= 2, "empty polyline")?;
points(p, budget)?;
require(p.windows(2).all(|p| p.first() != p.get(1)), "collapsed polyline segment")
}
Curve::Spline { knots: k, points: p } => {
knots(k, p.len(), budget)?;
points(p, budget)
}
Curve::RationalSpline { knots: k, homogeneous: p } => {
knots(k, p.len(), budget)?;
points(p, budget)?;
weights(p)
}
Curve::Intersection { knots: k, points: p, fitting_tolerance, .. } => {
require((1e-12..=1.0).contains(fitting_tolerance), "intersection fitting tolerance")?;
knots(k, p.len(), budget)?;
points(p, budget)
}
}
}
fn surface(s: &Surface, depth: usize, budget: &mut Budget) -> Result<()> {
require(depth <= MAX_DEPTH, "geometry depth")?;
budget.add(1)?;
match s {
Surface::Plane { origin, u_axis, v_axis } => {
points(&[*origin, *u_axis, *v_axis], budget)?;
require(vector(*u_axis).cross(vector(*v_axis)).len() > 1e-12, "degenerate plane")
}
Surface::Cylinder { frame: f, radius: r, angle: a, height } => {
frame(f, budget)?;
radius(*r)?;
angle(a)?;
range(height)
}
Surface::Cone { frame: f, radius_at_origin, half_angle, angle: a, height } => {
frame(f, budget)?;
numbers([*radius_at_origin, *half_angle])?;
require(*radius_at_origin >= 0. && *half_angle > 0. && *half_angle < std::f64::consts::FRAC_PI_2, "cone dimensions")?;
angle(a)?;
range(height)?;
require(height.iter().all(|h| radius_at_origin + h * half_angle.tan() >= 0.), "negative cone radius")
}
Surface::Sphere { frame: f, radius: r, longitude, latitude } => {
frame(f, budget)?;
radius(*r)?;
angle(longitude)?;
range(latitude)?;
require(latitude[0] >= -std::f64::consts::FRAC_PI_2 && latitude[1] <= std::f64::consts::FRAC_PI_2, "sphere latitude")
}
Surface::Torus { frame: f, major_radius, minor_radius, longitude, meridian } => {
frame(f, budget)?;
radius(*major_radius)?;
radius(*minor_radius)?;
require(major_radius > minor_radius, "ring torus radii")?;
angle(longitude)?;
angle(meridian)
}
Surface::Spline { u_knots, v_knots, points: p } => grid(u_knots, v_knots, p, budget),
Surface::RationalSpline { u_knots, v_knots, homogeneous: p } => {
grid(u_knots, v_knots, p, budget)?;
for row in p {
weights(row)?;
}
Ok(())
}
Surface::Revolution { profile, origin, axis, angle: a } => {
points(&[*origin, *axis], budget)?;
require((vector(*axis).len() - 1.0).abs() <= 1e-8, "revolution axis is not unit length")?;
angle(a)?;
curve(profile, depth + 1, budget)
}
}
}
impl Brep {
/// Validate portable structure without calling a kernel or evaluating geometry.
/// Does not certify watertightness, surface coincidence, or authoritative state.
pub fn validate(&self) -> Result<()> {
require(self.version == VERSION, "unsupported version")?;
require(!self.shells.is_empty() && self.shells.len() <= 1024, "shell count")?;
let mut budget = Budget(0);
for shell in &self.shells {
points(&shell.vertices, &mut budget)?;
budget.add(shell.edges.len())?;
budget.add(shell.faces.len())?;
require(!shell.faces.is_empty(), "empty shell")?;
for edge in &shell.edges {
require(edge.vertices.iter().all(|&v| (v as usize) < shell.vertices.len()), "edge vertices")?;
curve(&edge.curve, 0, &mut budget)?;
if let Curve::Intersection { faces: Some(hints), .. } = &edge.curve {
require(hints.iter().all(|&i| (i as usize) < shell.faces.len()), "intersection face hint")?;
}
}
for face in &shell.faces {
require(!face.loops.is_empty(), "face loops")?;
surface(&face.surface, 0, &mut budget)?;
for wire in &face.loops {
require(!wire.is_empty(), "empty wire")?;
budget.add(wire.len())?;
let mut first = None;
let mut last = None;
for use_ in wire {
let edge = shell.edges.get(use_.edge as usize).ok_or(Invalid("edge reference"))?;
let [a, b] = edge.vertices;
let (start, end) = if use_.forward { (a, b) } else { (b, a) };
require(last.is_none_or(|v| v == start), "disconnected wire")?;
first.get_or_insert(start);
last = Some(end);
}
require(first == last, "open wire")?;
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn triangle() -> Brep {
let vertices = vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]];
let edges = (0..3)
.map(|i| Edge { vertices: [i as u32, ((i + 1) % 3) as u32], curve: Curve::Line { start: vertices[i], end: vertices[(i + 1) % 3] } })
.collect();
Brep {
version: VERSION,
shells: vec![Shell {
vertices,
edges,
faces: vec![Face {
loops: vec![(0..3).map(|edge| EdgeUse { edge, forward: true }).collect()],
surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] },
forward: true,
}],
}],
}
}
#[test]
fn portable_open_face_round_trip_without_a_kernel() {
let body = triangle();
body.validate().unwrap();
let bytes = serde_json::to_vec(&body).unwrap();
let restored: Brep = serde_json::from_slice(&bytes).unwrap();
restored.validate().unwrap();
assert_eq!(body, restored);
assert!(!String::from_utf8(bytes).unwrap().contains("truck"));
}
#[test]
fn topology_references_and_wire_orientation_are_checked() {
let mut b = triangle();
b.shells[0].faces[0].loops[0][0].edge = u32::MAX;
assert!(b.validate().is_err());
let mut b = triangle();
b.shells[0].faces[0].loops[0][1].forward = false;
assert!(b.validate().is_err());
let mut b = triangle();
b.shells[0].edges[0].vertices[0] = u32::MAX;
assert!(b.validate().is_err());
let mut b = triangle();
b.version += 1;
assert!(b.validate().is_err());
let mut b = triangle();
b.shells[0].vertices[0][0] = f64::NAN;
assert!(b.validate().is_err());
}
#[test]
fn closed_edge_can_share_its_endpoint_vertex() {
let mut body = triangle();
body.shells[0].vertices.truncate(1);
body.shells[0].edges =
vec![Edge { vertices: [0, 0], curve: Curve::Polyline { points: vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 0.]] } }];
body.shells[0].faces[0].loops = vec![vec![EdgeUse { edge: 0, forward: true }]];
body.validate().unwrap();
}
#[test]
fn spline_domains_weights_and_nesting_are_bounded() {
let mut budget = Budget(0);
assert!(knots(&[0., 0., 1., 1.], 2, &mut budget).is_ok());
for bad in [vec![0., 0., 0., 0.], vec![0., 1., 0., 1.], vec![0., 0., f64::INFINITY, 1.]] {
assert!(knots(&bad, 2, &mut Budget(0)).is_err());
}
let c = Curve::RationalSpline { knots: vec![0., 0., 1., 1.], homogeneous: vec![[0., 0., 0., 0.], [1., 0., 0., 1.]] };
assert!(curve(&c, 0, &mut Budget(0)).is_err());
let p = Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [2., 0., 0.] };
assert!(surface(&p, 0, &mut Budget(0)).is_err());
assert!(curve(&Curve::Line { start: [0.; 3], end: [1.; 3] }, MAX_DEPTH + 1, &mut Budget(0)).is_err());
assert!(Budget(MAX_ITEMS).add(1).is_err());
}
#[test]
fn analytic_records_have_independent_frames_and_domains() {
use std::f64::consts::{FRAC_PI_2, TAU};
let f = Frame { origin: [1., 2., 3.], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] };
let surfaces = [
Surface::Cylinder { frame: f.clone(), radius: 2., angle: [0., TAU], height: [-1., 3.] },
Surface::Cone { frame: f.clone(), radius_at_origin: 2., half_angle: 0.2, angle: [0., TAU], height: [0., 3.] },
Surface::Sphere { frame: f.clone(), radius: 2., longitude: [0., TAU], latitude: [-FRAC_PI_2, FRAC_PI_2] },
Surface::Torus { frame: f.clone(), major_radius: 4., minor_radius: 1., longitude: [0., TAU], meridian: [0., TAU] },
Surface::Revolution {
profile: Box::new(Curve::Line { start: [2., 0., 0.], end: [2., 0., 3.] }),
origin: [0.; 3],
axis: [0., 0., 1.],
angle: [0., TAU],
},
];
for s in surfaces {
surface(&s, 0, &mut Budget(0)).unwrap();
let encoded = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<Surface>(&encoded).unwrap(), s);
assert!(!encoded.contains("columns") && !encoded.contains("forward"));
}
for c in [
Curve::CircleArc { frame: f.clone(), radius: 2., angle: [0., FRAC_PI_2] },
Curve::EllipseArc { frame: f.clone(), radii: [2., 3.], angle: [0., TAU] },
Curve::Intersection { knots: vec![0., 0., 1., 1.], points: vec![[0.; 3], [1.; 3]], fitting_tolerance: 1e-6, faces: None },
] {
curve(&c, 0, &mut Budget(0)).unwrap();
let encoded = serde_json::to_string(&c).unwrap();
assert_eq!(serde_json::from_str::<Curve>(&encoded).unwrap(), c);
assert!(!encoded.contains("leader") && !encoded.contains("surfaces"));
}
let mut bad = f;
bad.y_axis = bad.x_axis;
assert!(frame(&bad, &mut Budget(0)).is_err());
assert!(angle(&[0., TAU + 0.1]).is_err());
assert!(range(&[2., 1.]).is_err());
assert!(radius(f64::NAN).is_err());
let mut b = triangle();
b.shells[0].edges[0].curve =
Curve::Intersection { knots: vec![0., 0., 1., 1.], points: vec![[0.; 3], [1., 0., 0.]], fitting_tolerance: 1e-6, faces: Some([0, 1]) };
assert!(b.validate().is_err());
}
}
+118
View File
@@ -0,0 +1,118 @@
//! Stable version-one body metadata records. Runtime model types never define this wire contract.
use serde::{Deserialize, Serialize};
pub const VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Metadata {
pub version: u32,
pub color: Option<[f32; 3]>,
pub paint: Option<Paint>,
pub boundaries: Option<Vec<Vec<[f64; 3]>>>,
pub face_sources: Option<Vec<Option<FaceSource>>>,
pub history: Option<History>,
pub preserve_boundaries: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Plane {
pub origin: [f64; 3],
pub x_axis: [f64; 3],
pub y_axis: [f64; 3],
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Segment {
Line { start: [f64; 2], end: [f64; 2] },
Arc { center: [f64; 2], radius: f64, start: f64, sweep: f64 },
Cubic { points: [[f64; 2]; 4] },
Conic { points: [[f64; 2]; 3], middle_weight: f64 },
Bezier { points: Vec<[f64; 2]> },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Region {
pub outer: Vec<Segment>,
pub holes: Vec<Vec<Segment>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FaceSource {
ExtrusionSide { segment: Segment, plane: Option<Plane>, coalesced: bool },
CertifiedExtrusionSide { producer: u64, sketch: u64, curve: String, plane: Option<Plane> },
ExtrusionCap { high: bool, region: Region, plane: Plane },
CertifiedExtrusionCap { producer: u64, sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>>, plane: Plane },
CylinderCap { producer: u64, high: bool, plane: Plane },
BoxFace { producer: Option<u64>, axis: u8, high: bool, plane: Plane },
PlanarShell { producer: Option<u64>, source: Box<FaceSource>, inner: bool, plane: Plane },
Imported { item: u64, face: u64, payload_sha256: Option<String>, producer: Option<u64> },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Paint {
pub opacity: f32,
pub faces: Vec<FacePaint>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FacePaint {
pub face: u32,
pub color: [f32; 3],
pub opacity: f32,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct History {
pub operands: u32,
pub faces: Vec<FaceHistory>,
pub edges: Vec<EdgeHistory>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FaceHistory {
pub origins: Vec<FaceOrigin>,
pub change: Change,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeHistory {
pub faces: Vec<u32>,
pub origins: Vec<EdgeOrigin>,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FaceOrigin {
Face { operand: u32, face: u32 },
Profile { segment: u32 },
Cap { end: bool },
Edge { operand: u32, edge: u32 },
Offset { operand: u32, face: u32 },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum EdgeOrigin {
Edge { operand: u32, edge: u32 },
Meeting { sides: [Vec<FaceOrigin>; 2] },
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Change {
Kept,
Modified,
Split,
Merged,
Generated,
}
+2 -1
View File
@@ -3,11 +3,12 @@
//! (B-spline basis and NURBS surfaces in [`bspline`], one-surface fills of closed loops in
//! [`fill`], T-splines in [`tspline`]). Units are millimetres.
//!
//! This crate has no knowledge of sketches, documents or the B-rep kernel; every other crate
//! This crate has no knowledge of sketches, documents or a modelling backend; every other crate
//! builds on it.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod brep;
pub mod bspline;
pub mod env;
pub mod fill;
+114
View File
@@ -76,6 +76,40 @@ pub struct Mesh {
pub edge_faces: Vec<Vec<u32>>,
}
fn boxes_meet(a: &Aabb3, b: &Aabb3) -> bool {
a.min.x <= b.max.x && b.min.x <= a.max.x && a.min.y <= b.max.y && b.min.y <= a.max.y && a.min.z <= b.max.z && b.min.z <= a.max.z
}
/// Two triangles that pass through each other (see [`Mesh::crosses`]).
fn triangles_cross(t: &[Vec3; 3], u: &[Vec3; 3], tol: f64) -> bool {
// Signed distances of one triangle's corners from the other's plane, when they lie more
// than `tol` to both sides of it.
let sides = |tri: &[Vec3; 3], plane: &[Vec3; 3]| -> Option<(Vec3, [f64; 3])> {
let n = (plane[1] - plane[0]).cross(plane[2] - plane[0]).normalized()?;
let d = [n.dot(tri[0] - plane[0]), n.dot(tri[1] - plane[0]), n.dot(tri[2] - plane[0])];
let (lo, hi) = d.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
(lo < -tol && hi > tol).then_some((n, d))
};
let (Some((nu, dt)), Some((nt, du))) = (sides(t, u), sides(u, t)) else { return false };
let Some(line) = nt.cross(nu).normalized() else { return false };
// Where a triangle's edges pass through the other's plane, measured along the line the
// two planes share.
let reach = |tri: &[Vec3; 3], d: &[f64; 3]| -> (f64, f64) {
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for (i, j) in [(0, 1), (1, 2), (2, 0)] {
let (p, q, dp, dq) = (tri[i], tri[j], d[i], d[j]);
if (dp < 0.0) != (dq < 0.0) && dp != dq {
let at = line.dot(p + (q - p) * (dp / (dp - dq)));
lo = lo.min(at);
hi = hi.max(at);
}
}
(lo, hi)
};
let ((t0, t1), (u0, u1)) = (reach(t, &dt), reach(u, &du));
t1.min(u1) - t0.max(u0) > tol
}
/// Mass properties of a closed mesh.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct MeshMeasure {
@@ -281,6 +315,86 @@ impl Mesh {
dirs.iter().filter(|d| self.ray_crossings(p, **d) % 2 == 1).count() >= 2
}
/// Whether the surfaces of two meshes pass through each other by more than `tol`: some
/// triangle of one has corners more than `tol` to both sides of a triangle of the other,
/// and the reverse, and they share a stretch of the line where their planes meet longer
/// than `tol`. Faces that only touch, lie on each other or stop on the other surface don't
/// count, nor do two facetings of one curved surface that are within `tol` of it. Bodies
/// can still overlap without crossing: one inside the other, or sharing a slab between
/// faces that are parallel or in one plane.
pub fn crosses(&self, o: &Mesh, tol: f64) -> bool {
if !tol.is_finite() {
return false;
}
let tol = tol.max(0.0);
let (ba, bb) = (self.bounds(), o.bounds());
let region = Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) };
if region.is_empty() {
return false;
}
let near = |m: &Mesh| -> Vec<([Vec3; 3], Aabb3)> {
m.triangles
.iter()
.filter_map(|t| {
let t = m.tri(t)?;
let mut bx = Aabb3::EMPTY;
t.iter().for_each(|p| bx.add(*p));
(!bx.is_empty() && boxes_meet(&bx, &region)).then_some((t, bx))
})
.collect()
};
let (ta, tb) = (near(self), near(o));
if ta.is_empty() || tb.is_empty() {
return false;
}
// A grid over the shared box holds the other mesh's triangles.
let n = ((tb.len() as f64).cbrt().ceil() as usize).clamp(1, 24);
let size = region.size();
let cell = |v: f64, lo: f64, s: f64| -> usize { if s > 0.0 { (((v - lo) / s * n as f64).floor().max(0.0) as usize).min(n - 1) } else { 0 } };
let span = |bx: &Aabb3| -> [(usize, usize); 3] {
[
(cell(bx.min.x, region.min.x, size.x), cell(bx.max.x, region.min.x, size.x)),
(cell(bx.min.y, region.min.y, size.y), cell(bx.max.y, region.min.y, size.y)),
(cell(bx.min.z, region.min.z, size.z), cell(bx.max.z, region.min.z, size.z)),
]
};
let mut cells: Vec<Vec<u32>> = vec![Vec::new(); n * n * n];
for (i, (_, bx)) in tb.iter().enumerate() {
let [x, y, z] = span(bx);
for ix in x.0..=x.1 {
for iy in y.0..=y.1 {
for iz in z.0..=z.1 {
if let Some(c) = cells.get_mut((ix * n + iy) * n + iz) {
c.push(i as u32);
}
}
}
}
}
// Per triangle of `o`: the last triangle of `self` tested against it, plus one.
let mut seen = vec![0usize; tb.len()];
for (i, (t, bx)) in ta.iter().enumerate() {
let [x, y, z] = span(bx);
for ix in x.0..=x.1 {
for iy in y.0..=y.1 {
for iz in z.0..=z.1 {
for j in cells.get((ix * n + iy) * n + iz).into_iter().flatten() {
let (Some(mark), Some((u, ub))) = (seen.get_mut(*j as usize), tb.get(*j as usize)) else { continue };
if *mark == i + 1 {
continue;
}
*mark = i + 1;
if boxes_meet(bx, ub) && triangles_cross(t, u, tol) {
return true;
}
}
}
}
}
}
false
}
/// Closest intersection of a ray with the mesh: (distance along `dir`, triangle index).
pub fn raycast(&self, o: Vec3, dir: Vec3) -> Option<(f64, usize)> {
let mut best: Option<(f64, usize)> = None;
+52
View File
@@ -233,3 +233,55 @@ fn split_at_and_closest_param() {
assert!(l.end().dist(q) < 1e-12 && r.start().dist(q) < 1e-12);
assert!((quarter.closest_param(Vec2::new(20.0, 20.0)) - 0.5).abs() < 1e-9);
}
/// An axis-aligned box as a closed mesh, with `n` x `n` quads on each side.
fn box_mesh(min: Vec3, max: Vec3, n: usize) -> Mesh {
let mut m = Mesh::default();
let s = max - min;
let at = |c: [f64; 3]| min + Vec3::new(s.x * c[0], s.y * c[1], s.z * c[2]);
for axis in 0..3 {
for side in [0.0, 1.0] {
for i in 0..n {
for j in 0..n {
let corner = |a: usize, b: usize| {
let mut c = [0.0; 3];
c[axis] = side;
c[(axis + 1) % 3] = a as f64 / n as f64;
c[(axis + 2) % 3] = b as f64 / n as f64;
at(c)
};
let k = m.positions.len() as u32;
m.positions.extend([corner(i, j), corner(i + 1, j), corner(i + 1, j + 1), corner(i, j + 1)]);
m.triangles.extend(if side > 0.0 { [[k, k + 1, k + 2], [k, k + 2, k + 3]] } else { [[k, k + 2, k + 1], [k, k + 3, k + 2]] });
}
}
}
}
m
}
#[test]
fn meshes_cross_only_where_their_surfaces_pass_through_each_other() {
let cube = |x: f64, y: f64, n: usize| box_mesh(Vec3::new(x, y, 0.0), Vec3::new(x + 10.0, y + 10.0, 10.0), n);
let a = cube(0.0, 0.0, 1);
// A corner 0.15 deep in the other cube (0.02 % of its volume), and a deep overlap.
for (x, y, n) in [(9.85, 3.0, 1), (9.85, 3.0, 7), (9.85, 9.85, 2), (5.0, 5.0, 3)] {
assert!(a.crosses(&cube(x, y, n), 0.01), "{x} {y} {n}");
assert!(cube(x, y, n).crosses(&a, 0.01), "{x} {y} {n}");
}
// Apart, touching on a face or an edge, and the same body.
for (x, y, n) in [(10.5, 0.0, 1), (10.0, 0.0, 1), (10.0, 3.0, 5), (10.0, 10.0, 2), (0.0, 0.0, 1), (0.0, 0.0, 4)] {
assert!(!a.crosses(&cube(x, y, n), 0.01), "{x} {y} {n}");
}
// One inside the other, standing on a face, and sharing a face plane from inside.
let inner = box_mesh(Vec3::new(2.0, 2.0, 2.0), Vec3::new(8.0, 8.0, 8.0), 2);
let on_top = box_mesh(Vec3::new(2.0, 2.0, 10.0), Vec3::new(8.0, 8.0, 14.0), 2);
let flush = box_mesh(Vec3::new(2.0, 2.0, 4.0), Vec3::new(8.0, 8.0, 10.0), 2);
assert!(!a.crosses(&inner, 0.01) && !inner.crosses(&a, 0.01));
assert!(!a.crosses(&on_top, 0.01) && !a.crosses(&flush, 0.01));
// A slab overlap between faces that are parallel or in one plane has no crossing.
assert!(!a.crosses(&cube(9.85, 0.0, 1), 0.01));
// Passing through by less than the tolerance doesn't count.
assert!(!a.crosses(&cube(9.995, 3.0, 1), 0.01));
assert!(!a.crosses(&cube(9.85, 3.0, 1), f64::NAN));
}
+286 -7
View File
@@ -2,6 +2,12 @@
//! evaluated cache is trusted here. Entries are never extracted to filesystem paths.
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use std::sync::Arc;
mod native_cache;
mod thumbnail;
pub use native_cache::{DesignGeometry, read_design_geometry};
pub use thumbnail::{THUMBNAIL_SIZE, read_design_thumbnail, read_design_thumbnail_file};
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
@@ -96,6 +102,29 @@ pub fn write_design_container_with_compression(doc: &Document, compression: Desi
/// Optional display cache failure never prevents saving the authoritative design.
pub fn write_design_container_with_preview(doc: &Document, compression: DesignCompression, preview: Option<&DesignPreview>) -> Result<Vec<u8>> {
write_design_container_with_cache(doc, compression, preview, None)
}
/// Save an optional warm native cache without making document saving depend on it.
/// Display meshes remain separately opt-in; no evaluation or meshing happens here.
pub fn write_design_container_with_cache(
doc: &Document,
compression: DesignCompression,
preview: Option<&DesignPreview>,
geometry: Option<&[DesignGeometry]>,
) -> Result<Vec<u8>> {
write_design_container_with_thumbnail(doc, compression, preview, geometry, None)
}
/// Include a bounded portable PNG picture when one is already available.
/// Unsupported headers or oversized pictures are omitted without changing the design.
pub fn write_design_container_with_thumbnail(
doc: &Document,
compression: DesignCompression,
preview: Option<&DesignPreview>,
geometry: Option<&[DesignGeometry]>,
thumbnail: Option<&[u8]>,
) -> Result<Vec<u8>> {
let mut writer = PackageWriter { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0 };
let mut features = Vec::with_capacity(doc.features.len());
for feature in &doc.features {
@@ -143,6 +172,19 @@ pub fn write_design_container_with_preview(doc: &Document, compression: DesignCo
let metadata = serde_json::to_vec(&metadata).map_err(invalid)?;
writer.part("document.json", &metadata, true)?;
writer.part("container.json", &serde_json::to_vec(&json!({"format": VERSION})).map_err(invalid)?, false)?;
if let Some((manifest, data)) = geometry.and_then(|g| native_cache::encode_geometry(g, &metadata).ok())
&& writer.names.len().saturating_add(2) <= MAX_ENTRIES
&& writer
.expanded
.saturating_add(manifest.len())
.saturating_add(data.len())
.saturating_add(writer.names.len().saturating_mul(256))
.saturating_add(65_536)
<= MAX_DESIGN_BYTES
{
writer.part("cache/key.json", &manifest, false)?;
writer.part("cache/brep.bin", &data, compression == DesignCompression::Compact)?;
}
if let Some((manifest, data)) = preview.and_then(|p| encode_preview(p, &metadata).ok())
&& writer.names.len().saturating_add(2) <= MAX_ENTRIES
&& writer
@@ -156,6 +198,9 @@ pub fn write_design_container_with_preview(doc: &Document, compression: DesignCo
writer.part("cache/preview.json", &manifest, false)?;
writer.part("cache/preview.bin", &data, false)?;
}
if let Some(png) = thumbnail {
thumbnail::write(&mut writer, png, &metadata)?;
}
let bytes = writer.zip.finish().map_err(invalid)?.into_inner();
if bytes.len() > MAX_DESIGN_BYTES {
return Err(invalid("compressed file too large"));
@@ -223,6 +268,7 @@ struct PackageReader<R: Read + Seek> {
zip: zip::ZipArchive<R>,
blobs: BTreeMap<String, Vec<u8>>,
hydrated: usize,
document: Option<Arc<[u8]>>,
}
impl<R: Read + Seek> PackageReader<R> {
@@ -274,7 +320,15 @@ impl<R: Read + Seek> PackageReader<R> {
return Err(invalid("expanded package too large"));
}
}
Ok(Self { zip, blobs: BTreeMap::new(), hydrated: 0 })
Ok(Self { zip, blobs: BTreeMap::new(), hydrated: 0, document: None })
}
// Bind provisional caches and authoritative reconstruction to identical metadata,
// even if another process overwrites the open file in place. Blobs remain hash-checked.
fn document(&mut self, limit: usize) -> Result<Arc<[u8]>> {
if self.document.is_none() {
self.document = Some(self.limited_part("document.json", limit)?.into());
}
self.document.as_ref().filter(|d| d.len() <= limit).cloned().ok_or_else(|| invalid("metadata budget"))
}
fn part(&mut self, name: &str) -> Result<Vec<u8>> {
self.limited_part(name, MAX_DESIGN_BYTES)
@@ -363,7 +417,7 @@ fn read_preview<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<DesignP
{
return Err(invalid("incompatible preview"));
}
if manifest.get("document").and_then(Value::as_str) != Some(digest(&reader.part("document.json")?).as_str()) {
if manifest.get("document").and_then(Value::as_str) != Some(digest(&reader.document(16 << 20)?).as_str()) {
return Err(invalid("stale preview"));
}
let data = reader.limited_part("cache/preview.bin", MAX_PREVIEW_BYTES)?;
@@ -404,12 +458,13 @@ fn read_preview<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<DesignP
Ok(preview)
}
/// A bounded container reader over an immutable, seekable source.
/// A bounded container reader over a seekable source.
///
/// Opening inspects the directory, not the source blobs. `preview` reads only the
/// optional display entries and document metadata; `read_document` then resolves
/// and validates authoritative blobs. Keep the source unchanged for this reader's
/// lifetime. This type does not turn untrusted cached data into editable geometry.
/// and validates authoritative blobs against the same retained metadata snapshot.
/// Concurrent source changes either retain that document or fail validation.
/// This type does not turn untrusted cached data into editable geometry.
pub struct DesignContainerReader<R: Read + Seek> {
reader: PackageReader<R>,
}
@@ -422,7 +477,20 @@ impl<R: Read + Seek> DesignContainerReader<R> {
/// Optional display-only data. Corruption or incompatibility is a cache miss.
pub fn preview(&mut self) -> Option<DesignPreview> {
read_preview(&mut self.reader).ok()
// An explicitly saved mesh avoids tessellation. Native cache is the default
// fallback, generating a bounded provisional mesh without source imports.
read_preview(&mut self.reader).ok().or_else(|| native_cache::geometry_preview(self.geometry()?).ok())
}
/// Portable PNG picture only; no source blobs or geometry are evaluated.
pub fn thumbnail(&mut self) -> Option<Vec<u8>> {
thumbnail::read(&mut self.reader).ok()
}
/// Native geometry is provisional, never a substitute for rebuilding the document.
pub fn geometry(&mut self) -> Option<Vec<DesignGeometry>> {
let document = self.reader.document(16 << 20).ok()?;
native_cache::read_geometry_with(&mut self.reader, &document).ok()
}
/// Consume the reader, resolving every required blob before returning a document.
@@ -435,12 +503,58 @@ pub(super) fn read(bytes: &[u8]) -> Result<Document> {
DesignContainerReader::new(Cursor::new(bytes))?.read_document()
}
/// Open from disk (or browser VFS), publishing only provisional display before
/// authoritative blob hydration. Desktop keeps one seekable file handle instead
/// of buffering the complete archive. JSON continues through the normal migrations.
pub fn read_design_file(path: &str, notify: Option<impl FnOnce(DesignPreview)>) -> Result<Document> {
#[cfg(not(target_arch = "wasm32"))]
let mut source = {
let metadata = std::fs::metadata(path).map_err(invalid)?;
if !metadata.is_file() {
return Err(invalid("not a regular file"));
}
if metadata.len() > MAX_DESIGN_BYTES as u64 {
return Err(invalid("file too large"));
}
let file = std::fs::File::open(path).map_err(invalid)?;
if !file.metadata().map_err(invalid)?.is_file() {
return Err(invalid("not a regular file"));
}
file
};
#[cfg(target_arch = "wasm32")]
let mut source = Cursor::new(crate::vfs::read(path, MAX_DESIGN_BYTES).map_err(invalid)?);
let size = source.seek(SeekFrom::End(0)).map_err(invalid)?;
if size > MAX_DESIGN_BYTES as u64 {
return Err(invalid("file too large"));
}
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
let mut prefix = [0u8; 4];
if size >= 4 {
source.read_exact(&mut prefix).map_err(invalid)?;
}
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
if is_design_container(&prefix) {
let mut reader = DesignContainerReader::new(source)?;
if let Some(notify) = notify
&& let Some(preview) = reader.preview()
{
notify(preview);
}
reader.read_document()
} else {
let mut bytes = Vec::new();
source.take(MAX_DESIGN_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
crate::read_design(&bytes)
}
}
fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Document> {
let version: Value = serde_json::from_slice(&reader.part("container.json")?).map_err(invalid)?;
if !matches!(version.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
return Err(invalid("unsupported container version"));
}
let bytes = reader.part("document.json")?;
let bytes = reader.document(MAX_DESIGN_BYTES)?;
let metadata: Value = serde_json::from_slice(&bytes).map_err(invalid)?;
let text = std::str::from_utf8(&bytes).map_err(invalid)?;
// Migrations and all document validation run on the small parametric metadata.
@@ -565,6 +679,124 @@ mod tests {
assert!(DesignContainerReader::new(Cursor::new(vec![0u8; 1])).is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn file_reader_refuses_nonfiles_before_notifying_preview() {
let directory = std::env::temp_dir();
let result = read_design_file(directory.to_str().unwrap(), Some(|_| panic!("non-file supplied a preview")));
assert!(result.is_err_and(|e| e.to_string().contains("not a regular file")));
}
fn native_geometry() -> Vec<DesignGeometry> {
vec![DesignGeometry {
name: "Cube".into(),
body: solidcraft_kernel::box_solid(solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::new(10., 20., 30.)).unwrap(),
}]
}
#[test]
fn native_cache_round_trip_and_preview_defer_original_sources() {
let mut doc = Document::new("Native cache");
let step = "ISO-10303-21;".repeat(200_000);
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone(), components: Vec::new() }, None).unwrap();
let bodies = native_geometry();
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&bodies)).unwrap();
let entries = parts(&bytes);
assert!(entries.contains_key("cache/brep.bin"));
assert!(!entries.contains_key("cache/preview.bin"));
assert_eq!(read_design_geometry(&bytes).unwrap()[0].body.face_count(), bodies[0].body.face_count());
assert_eq!(bytes, write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&bodies)).unwrap());
let count = std::rc::Rc::new(std::cell::Cell::new(0));
let mut reader = DesignContainerReader::new(CountingReader { source: Cursor::new(bytes), count: count.clone() }).unwrap();
assert!(!reader.preview().unwrap().meshes.is_empty());
assert!(count.get() < 256 * 1024, "read {} bytes before native display", count.get());
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
assert!(count.get() >= step.len());
}
#[test]
fn native_cache_mismatch_is_only_a_cache_miss() {
let doc = document();
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&native_geometry())).unwrap();
let original = parts(&bytes);
for key in ["format", "producer", "app", "document", "payload"] {
let mut entries = original.clone();
let mut manifest: Value = serde_json::from_slice(&entries["cache/key.json"]).unwrap();
manifest[key] = json!("unsupported-or-stale");
entries.insert("cache/key.json".into(), serde_json::to_vec(&manifest).unwrap());
let bytes = package(&entries);
assert!(read_design_geometry(&bytes).is_none());
assert!(read_design_preview(&bytes).is_none());
assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json());
}
let mut entries = original;
entries.get_mut("cache/brep.bin").unwrap()[0] ^= 1;
let bytes = package(&entries);
assert!(read_design_geometry(&bytes).is_none());
assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json());
}
#[test]
fn metadata_snapshot_survives_in_place_changes_after_preview() {
use std::{cell::RefCell, rc::Rc};
struct Shared {
bytes: Rc<RefCell<Vec<u8>>>,
position: u64,
}
impl Read for Shared {
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
let bytes = self.bytes.borrow();
let mut cursor = Cursor::new(bytes.as_slice());
cursor.set_position(self.position);
let n = cursor.read(out)?;
self.position = cursor.position();
Ok(n)
}
}
impl Seek for Shared {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
let bytes = self.bytes.borrow();
let mut cursor = Cursor::new(bytes.as_slice());
cursor.set_position(self.position);
self.position = cursor.seek(pos)?;
Ok(self.position)
}
}
let doc = document();
let preview = DesignPreview { meshes: vec![mesh()] };
let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
let offset = zip::ZipArchive::new(Cursor::new(&bytes)).unwrap().by_name("document.json").unwrap().data_start() as usize;
let bytes = Rc::new(RefCell::new(bytes));
let mut reader = DesignContainerReader::new(Shared { bytes: bytes.clone(), position: 0 }).unwrap();
assert!(reader.preview().is_some());
bytes.borrow_mut()[offset] ^= 0xff;
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
assert!(crate::read_design(&bytes.borrow()).is_err());
}
#[test]
fn native_cache_prefixes_and_mutations_never_panic() {
let bytes = write_design_container_with_cache(&document(), DesignCompression::Fast, None, Some(&native_geometry())).unwrap();
for n in (0..bytes.len()).step_by((bytes.len() / 128).max(1)) {
assert!(
std::panic::catch_unwind(|| {
let _ = read_design_preview(&bytes[..n]);
let _ = read_design_geometry(&bytes[..n]);
})
.is_ok()
);
let mut changed = bytes.clone();
changed[n] ^= 0xff;
assert!(
std::panic::catch_unwind(|| {
let _ = read_design_preview(&changed);
let _ = read_design_geometry(&changed);
})
.is_ok()
);
}
}
fn mesh() -> MeshData {
MeshData {
name: "Precise mesh".into(),
@@ -627,6 +859,53 @@ mod tests {
zip.finish().unwrap().into_inner()
}
#[test]
fn portable_thumbnail_is_keyed_bounded_and_does_not_read_sources() {
// IO validates an opaque PNG header/hash; UI tests exercise real PNG decoding.
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
png.extend(240u32.to_be_bytes());
png.extend(150u32.to_be_bytes());
let mut doc = Document::from_json(&document().to_json()).unwrap();
if let FeatureKind::Import { step, .. } = &mut doc.features[0].kind {
*step = "source data".repeat(250_000);
}
let bytes = write_design_container_with_thumbnail(&doc, DesignCompression::Fast, None, None, Some(&png)).unwrap();
let count = std::rc::Rc::new(std::cell::Cell::new(0));
let mut reader = DesignContainerReader::new(CountingReader { source: Cursor::new(bytes.clone()), count: count.clone() }).unwrap();
assert_eq!(reader.thumbnail(), Some(png.clone()));
assert!(count.get() < 256 * 1024);
assert_eq!(reader.read_document().unwrap(), doc);
let original = parts(&bytes);
for field in ["format", "app", "document", "payload"] {
let mut entries = original.clone();
let mut key: Value = serde_json::from_slice(&entries["cache/thumbnail.json"]).unwrap();
key[field] = json!("mismatch");
entries.insert("cache/thumbnail.json".into(), serde_json::to_vec(&key).unwrap());
let changed = package(&entries);
assert!(read_design_thumbnail(&changed).is_none());
assert_eq!(crate::read_design(&changed).unwrap(), doc);
}
let mut entries = original.clone();
let source = entries.keys().find(|k| k.ends_with(".step")).unwrap().clone();
entries.insert(source, b"corrupt source".to_vec());
let changed = package(&entries);
assert_eq!(read_design_thumbnail(&changed), Some(png));
assert!(crate::read_design(&changed).is_err());
for n in [0, 1, 8, bytes.len() / 2, bytes.len() - 1] {
assert!(read_design_thumbnail(&bytes[..n]).is_none());
}
for i in (0..bytes.len()).step_by((bytes.len() / 128).max(1)) {
let mut changed = bytes.clone();
changed[i] ^= 0xff;
assert!(std::panic::catch_unwind(|| read_design_thumbnail(&changed)).is_ok());
}
for invalid in [vec![], vec![0; 300_000]] {
let bytes = write_design_container_with_thumbnail(&doc, DesignCompression::Fast, None, None, Some(&invalid)).unwrap();
assert!(read_design_thumbnail(&bytes).is_none());
assert_eq!(crate::read_design(&bytes).unwrap(), doc);
}
}
#[test]
fn optional_preview_is_bounded_keyed_and_never_authoritative() {
let doc = Document::from_json(&document().to_json()).unwrap();
+128
View File
@@ -0,0 +1,128 @@
//! Optional native cache. Its SolidCraft schema is independent of the producing kernel.
use super::*;
const PRODUCER: &str = "solidcraft-truck-adapter/2";
/// Provisional evaluated geometry. Never an editable document state.
#[derive(Clone)]
pub struct DesignGeometry {
pub name: String,
pub body: solidcraft_kernel::Body,
}
pub(super) fn encode_geometry(bodies: &[DesignGeometry], document: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
if bodies.is_empty() || bodies.len() > 4096 {
return Err(invalid("native body count"));
}
let mut data = Vec::new();
let mut entries = Vec::new();
let mut faces = 0usize;
for entry in bodies {
faces = faces.saturating_add(entry.body.face_count());
if faces > 100_000 || entry.name.len() > 1024 {
return Err(invalid("native geometry budget"));
}
let bytes = solidcraft_kernel::encode_native_body(&entry.body).map_err(invalid)?;
let offset = data.len();
if offset.saturating_add(bytes.len()) > 64 << 20 {
return Err(invalid("native cache size"));
}
entries.push(json!({"name":entry.name,"offset":offset,"length":bytes.len()}));
data.extend(bytes);
}
let manifest = serde_json::to_vec(&json!({
"format":"solidcraft-brep-cache/1", "producer":PRODUCER,
"app":env!("CARGO_PKG_VERSION"), "document":digest(document), "payload":digest(&data), "bodies":entries
}))
.map_err(invalid)?;
if manifest.len() > 1 << 20 {
return Err(invalid("native manifest size"));
}
Ok((manifest, data))
}
/// Read only metadata and the native cache. Original blobs are not read/decompressed
/// until the authoritative document reader needs them for background reconstruction.
/// Any optional geometry failure is a miss, never a document error.
pub fn read_design_geometry(bytes: &[u8]) -> Option<Vec<DesignGeometry>> {
if bytes.len() > MAX_DESIGN_BYTES || !is_design_container(bytes) {
return None;
}
DesignContainerReader::new(Cursor::new(bytes)).ok()?.geometry()
}
pub(super) fn read_geometry_with<R: Read + Seek>(reader: &mut PackageReader<R>, document: &[u8]) -> Result<Vec<DesignGeometry>> {
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
if container.get("format").and_then(Value::as_str) != Some(VERSION) {
return Err(invalid("container version"));
}
let manifest: Value = serde_json::from_slice(&reader.limited_part("cache/key.json", 1 << 20)?).map_err(invalid)?;
if manifest.get("format").and_then(Value::as_str) != Some("solidcraft-brep-cache/1")
|| manifest.get("producer").and_then(Value::as_str) != Some(PRODUCER)
|| manifest.get("app").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
{
return Err(invalid("native cache version"));
}
if manifest.get("document").and_then(Value::as_str) != Some(digest(document).as_str()) {
return Err(invalid("native document key"));
}
// Validate the small parametric metadata before showing a provisional model.
Document::from_json(std::str::from_utf8(document).map_err(invalid)?)?;
let data = reader.limited_part("cache/brep.bin", 64 << 20)?;
if manifest.get("payload").and_then(Value::as_str) != Some(digest(&data).as_str()) {
return Err(invalid("native payload key"));
}
let entries =
manifest.get("bodies").and_then(Value::as_array).filter(|b| !b.is_empty() && b.len() <= 4096).ok_or_else(|| invalid("native bodies"))?;
let mut bodies = Vec::new();
let mut end = 0usize;
let mut faces = 0usize;
for entry in entries {
let n = |key| entry.get(key).and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or_else(|| invalid("native slice"));
let offset = n("offset")?;
let length = n("length")?;
if offset != end {
return Err(invalid("overlapping native bodies"));
}
end = end.checked_add(length).ok_or_else(|| invalid("native length overflow"))?;
let name = entry.get("name").and_then(Value::as_str).filter(|n| n.len() <= 1024).ok_or_else(|| invalid("native body name"))?.to_owned();
let body = solidcraft_kernel::decode_native_body(data.get(offset..end).ok_or_else(|| invalid("truncated native body"))?).map_err(invalid)?;
faces = faces.saturating_add(body.face_count());
if faces > 100_000 {
return Err(invalid("native face budget"));
}
bodies.push(DesignGeometry { name, body });
}
if end != data.len() {
return Err(invalid("trailing native data"));
}
Ok(bodies)
}
pub(super) fn geometry_preview(bodies: Vec<DesignGeometry>) -> Result<DesignPreview> {
let mut preview = DesignPreview::default();
let mut triangles = 0usize;
let mut display_bytes = 0usize;
let faces: usize = bodies.iter().map(|b| b.body.face_count()).sum();
let detail = if faces > 20_000 {
1
} else if faces > 4_000 {
4
} else {
8
};
let mut work = 1_000_000usize;
for entry in bodies {
let mesh = solidcraft_kernel::native_preview_mesh(&entry.body, detail, &mut work).map_err(invalid)?;
triangles = triangles.saturating_add(mesh.triangles.len());
display_bytes = display_bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(12));
if triangles > 1_000_000 || display_bytes > 128 << 20 {
return Err(invalid("regenerated display budget"));
}
let displayed = MeshData {
name: entry.name,
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
triangles: mesh.triangles.iter().flatten().copied().collect(),
color: entry.body.color(),
};
validate_mesh(&displayed)?;
preview.meshes.push(displayed);
}
Ok(preview)
}
+77
View File
@@ -0,0 +1,77 @@
//! Portable, disposable PNG pictures. Reading never resolves source blobs.
use super::*;
pub const THUMBNAIL_SIZE: [usize; 2] = [240, 150];
const MAX_BYTES: usize = 256 << 10;
const FORMAT: &str = "solidcraft-thumbnail/1";
fn valid_header(bytes: &[u8]) -> bool {
bytes.len() <= MAX_BYTES
&& bytes.get(..8) == Some(b"\x89PNG\r\n\x1a\n")
&& bytes.get(8..16) == Some(b"\0\0\0\rIHDR")
&& bytes.get(16..20) == Some((THUMBNAIL_SIZE[0] as u32).to_be_bytes().as_slice())
&& bytes.get(20..24) == Some((THUMBNAIL_SIZE[1] as u32).to_be_bytes().as_slice())
}
pub(super) fn write(writer: &mut PackageWriter, png: &[u8], document: &[u8]) -> Result<()> {
if !valid_header(png) {
return Ok(());
}
let key = serde_json::to_vec(&json!({"format": FORMAT, "app": env!("CARGO_PKG_VERSION"), "document": digest(document), "payload": digest(png)}))
.map_err(invalid)?;
if writer.names.len().saturating_add(2) > MAX_ENTRIES
|| writer
.expanded
.saturating_add(key.len())
.saturating_add(png.len())
.saturating_add(writer.names.len().saturating_mul(256))
.saturating_add(65_536)
> MAX_DESIGN_BYTES
{
return Ok(());
}
writer.part("cache/thumbnail.json", &key, false)?;
writer.part("cache/thumbnail.png", png, false)
}
pub(super) fn read<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Vec<u8>> {
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
if container.get("format").and_then(Value::as_str) != Some(VERSION) {
return Err(invalid("thumbnail container version"));
}
let key: Value = serde_json::from_slice(&reader.limited_part("cache/thumbnail.json", 4096)?).map_err(invalid)?;
let document = reader.document(16 << 20)?;
if key.get("format").and_then(Value::as_str) != Some(FORMAT)
|| key.get("app").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
|| key.get("document").and_then(Value::as_str) != Some(digest(&document).as_str())
{
return Err(invalid("thumbnail key"));
}
let png = reader.limited_part("cache/thumbnail.png", MAX_BYTES)?;
if !valid_header(&png) || key.get("payload").and_then(Value::as_str) != Some(digest(&png).as_str()) {
return Err(invalid("thumbnail payload"));
}
// Consumers must also use a bounded PNG decoder; the header is not a decode.
Ok(png)
}
/// Optional PNG bytes, bounded and keyed to this exact document. Consumers must
/// decode with dimension/allocation limits; invalid pictures are cache misses.
pub fn read_design_thumbnail(bytes: &[u8]) -> Option<Vec<u8>> {
DesignContainerReader::new(Cursor::new(bytes)).ok()?.thumbnail()
}
/// Read a portable picture without importing or reading the source blobs.
pub fn read_design_thumbnail_file(path: &str) -> Option<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
let source = {
let metadata = std::fs::metadata(path).ok()?;
if !metadata.is_file() || metadata.len() > MAX_DESIGN_BYTES as u64 {
return None;
}
let source = std::fs::File::open(path).ok()?;
let metadata = source.metadata().ok()?;
if !metadata.is_file() || metadata.len() > MAX_DESIGN_BYTES as u64 {
return None;
}
source
};
#[cfg(target_arch = "wasm32")]
let source = Cursor::new(crate::vfs::read(path, MAX_DESIGN_BYTES).ok()?);
DesignContainerReader::new(source).ok()?.thumbnail()
}
+36 -1
View File
@@ -3,7 +3,12 @@
//! the legacy folder is copied over. The legacy folder is never changed or deleted, so an older
//! release installed side by side keeps working.
use std::path::Path;
use std::path::{Path, PathBuf};
/// The Linux app id (Flatpak, desktop entry, AppStream, Wayland `app_id`).
pub const APP_ID: &str = "ai.storyteller.solidcraft";
/// The app id of releases before 0.8 (compatibility shim, docs/compatibility.md).
pub const LEGACY_APP_ID: &str = "ai.storyteller.solvecraft";
/// Most files and nesting copied from a legacy folder (it holds preferences, autosaves and
/// small caches; anything bigger is not ours).
@@ -22,6 +27,25 @@ pub fn adopt_legacy_dir(current: &Path, legacy: &Path) -> std::io::Result<bool>
Ok(true)
}
/// In the Flatpak sandbox, the same `path` under the pre-rename app's data folder: Flatpak keeps
/// each app's XDG folders under `~/.var/app/<app id>`, so after the app id changed, settings and
/// autosaves of earlier Flatpak releases sit under the old id. `None` outside the sandbox or for
/// paths outside our own `~/.var/app` folder. Flatpak moves that folder itself when an install
/// follows the end-of-life rebase; this covers installs made next to the old app, which reach it
/// read-only through the manifest's `--filesystem` grant.
pub fn legacy_flatpak_path(path: &Path) -> Option<PathBuf> {
flatpak_twin(path, &std::env::var_os("FLATPAK_ID")?, Path::new(&std::env::var_os("HOME")?))
}
fn flatpak_twin(path: &Path, flatpak_id: &std::ffi::OsStr, home: &Path) -> Option<PathBuf> {
if flatpak_id != APP_ID {
return None;
}
let apps = home.join(".var").join("app");
let rel = path.strip_prefix(apps.join(APP_ID)).ok()?;
Some(apps.join(LEGACY_APP_ID).join(rel))
}
fn copy_tree(from: &Path, to: &Path, depth: usize, budget: &mut usize) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
if depth >= MAX_DEPTH {
@@ -75,6 +99,17 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn flatpak_twin_maps_our_sandbox_data_to_the_old_app_id() {
let home = Path::new("/home/u");
let ours = home.join(".var/app/ai.storyteller.solidcraft/data/solidcraft");
let id = std::ffi::OsStr::new(APP_ID);
assert_eq!(flatpak_twin(&ours, id, home), Some(home.join(".var/app/ai.storyteller.solvecraft/data/solidcraft")));
// Outside our sandbox folder, or in another app's sandbox, there is nothing to map.
assert_eq!(flatpak_twin(&home.join(".local/share/solidcraft"), id, home), None);
assert_eq!(flatpak_twin(&ours, std::ffi::OsStr::new("org.example.Other"), home), None);
}
#[test]
fn nothing_to_adopt() {
let d = scratch("none");
+5 -3
View File
@@ -10,8 +10,10 @@ mod design;
mod design_metadata;
mod dxf;
pub use design::{
DesignCompression, DesignContainerReader, DesignPreview, is_design_container, read_design_preview, write_design_container,
write_design_container_with_compression, write_design_container_with_preview,
DesignCompression, DesignContainerReader, DesignGeometry, DesignPreview, THUMBNAIL_SIZE, is_design_container, read_design_file,
read_design_geometry, read_design_preview, read_design_thumbnail, read_design_thumbnail_file, write_design_container,
write_design_container_with_cache, write_design_container_with_compression, write_design_container_with_preview,
write_design_container_with_thumbnail,
};
mod legacy_dir;
pub mod safe_write;
@@ -21,7 +23,7 @@ mod threemf;
pub mod vfs;
pub use dxf::{read_dxf, write_dxf};
pub use legacy_dir::adopt_legacy_dir;
pub use legacy_dir::{APP_ID, LEGACY_APP_ID, adopt_legacy_dir, legacy_flatpak_path};
pub use safe_write::{backup_path, write_atomic};
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
pub use svg::read_svg;
+4 -1
View File
@@ -114,7 +114,10 @@ fn attrs(e: &BytesStart) -> Vec<(String, String)> {
.flatten()
.map(|a| {
let k = String::from_utf8_lossy(a.key.local_name().as_ref()).to_string();
let v = a.unescape_value().map(|v| v.to_string()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
let v = a
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.map(|v| v.to_string())
.unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
(k, v)
})
.collect()
+4 -1
View File
@@ -319,7 +319,10 @@ fn attrs(e: &BytesStart) -> HashMap<String, String> {
let mut m = HashMap::new();
for a in e.attributes().flatten() {
let k = String::from_utf8_lossy(a.key.local_name().as_ref()).to_string();
let v = a.unescape_value().map(|v| v.to_string()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
let v = a
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.map(|v| v.to_string())
.unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
m.insert(k, v);
}
m
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
solidcraft-geom = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
truck-modeling = { workspace = true }
+515 -43
View File
@@ -198,6 +198,124 @@ pub(crate) fn extension_fits(face: &mt::Face, p: Vec3, q: Vec3, tol: f64) -> boo
inside
}
/// Parameters along `x + λ d` where the line meets `surf`, within `reach` of `x` (Newton on
/// (u, v, λ) from several starts; nearest first).
fn line_surface_hits(surf: &mt::Surface, x: Vec3, d: Vec3, reach: f64, tol: f64) -> Vec<f64> {
use mt::{ParametricSurface, SearchNearestParameter};
let mut out: Vec<f64> = Vec::new();
for k in [0i32, 1, -1, 2, -2, 4, -4] {
let mut l = reach * f64::from(k) / 4.0;
let Some((mut u, mut v)) = surf.search_nearest_parameter(p3(x + d * l), None, 100) else { continue };
for _ in 0..60 {
let f = from_p3(surf.subs(u, v)) - (x + d * l);
if !f.is_finite() {
break;
}
if f.len() <= tol {
if l.abs() <= reach && !out.iter().any(|o| (o - l).abs() <= tol * 10.0) {
out.push(l);
}
break;
}
let (su, sv) = (surf.uder(u, v), surf.vder(u, v));
let m = [[su.x, sv.x, -d.x], [su.y, sv.y, -d.y], [su.z, sv.z, -d.z]];
let Some([du, dv, dl]) = crate::polyhedron::solve3_pub(m, [-f.x, -f.y, -f.z]) else { break };
if ![du, dv, dl].iter().all(|z| z.is_finite()) {
break;
}
// Damp long steps: the start may be far from the root.
let step = (Vec3::new(su.x, su.y, su.z) * du + Vec3::new(sv.x, sv.y, sv.z) * dv).len().max(dl.abs());
let k = if step > reach { reach / step } else { 1.0 };
u += du * k;
v += dv * k;
l += dl * k;
}
}
out.sort_by(|a, b| a.abs().total_cmp(&b.abs()));
out
}
/// A circle through the curve of `e` (centre, axis, radius), when it is one.
fn edge_circle(e: &mt::Edge, tol: f64) -> Option<(Vec3, Vec3, f64)> {
use mt::{BoundedCurve, ParametricCurve};
let c = e.curve();
let (lo, hi) = c.range_tuple();
let at = |f: f64| from_p3(c.subs(lo + (hi - lo) * f));
let (a, b, m) = (at(0.0), at(1.0), at(0.5));
let (u, w) = (b - a, m - a);
let n = u.cross(w);
let n2 = n.len2();
if !(n2 > 1e-30) {
return None;
}
// Circumcentre of a, b, m.
let centre = a + (n.cross(u) * w.len2() + w.cross(n) * u.len2()) / (2.0 * n2);
let radius = centre.dist(a);
let axis = n.normalized()?;
let ok = (1..16).all(|i| {
let q = at(f64::from(i) / 16.0);
(q.dist(centre) - radius).abs() <= tol && (q - centre).dot(axis).abs() <= tol
});
(ok && radius.is_finite()).then_some((centre, axis, radius))
}
/// Where `q` lies on the edge's curve: its parameter, if within `tol` of the curve and inside
/// the edge's range.
pub(crate) fn on_edge(e: &mt::Edge, q: Vec3, tol: f64) -> Option<f64> {
use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter};
let c = e.curve();
let (lo, hi) = c.range_tuple();
let hint =
(0..=32).map(|i| lo + (hi - lo) * f64::from(i) / 32.0).min_by(|x, y| from_p3(c.subs(*x)).dist(q).total_cmp(&from_p3(c.subs(*y)).dist(q)))?;
let t = c.search_nearest_parameter(p3(q), hint, 100)?;
let margin = (hi - lo) * 1e-9;
(t.is_finite() && t >= lo - margin && t <= hi + margin && from_p3(c.subs(t)).dist(q) <= tol).then_some(t.clamp(lo, hi))
}
/// Whether `q` lies on the edge's curve continued past its ends (a line or a circle).
pub(crate) fn on_extension(e: &mt::Edge, q: Vec3, tol: f64) -> bool {
match e.curve() {
mt::Curve::Line(_) => {
let (a, b) = (vtx(e.absolute_front()), vtx(e.absolute_back()));
(b - a).normalized().is_some_and(|u| (q - a - u * (q - a).dot(u)).len() <= tol)
}
_ => edge_circle(e, tol).is_some_and(|(c, n, r)| ((q - c).dot(n)).abs() <= tol && ((q - c - n * (q - c).dot(n)).len() - r).abs() <= tol),
}
}
/// The edge `e` with its end at `v` moved to `nv`, along its own curve: shortened (any curve),
/// or continued past `v` (a line or a circle). In the stored orientation of `e`.
pub(crate) fn retarget(e: &mt::Edge, v: &mt::Vertex, nv: &mt::Vertex, tol: f64) -> Result<mt::Edge> {
let (f, bk) = (e.absolute_front(), e.absolute_back());
if f == bk {
return Err(unsupported("a closed edge where the blend ends"));
}
if matches!(e.curve(), mt::Curve::Line(_)) {
return Ok(if f == v { builder::line(nv, bk) } else { builder::line(f, nv) });
}
use mt::Cut;
let q = vtx(nv);
if let Some(t) = on_edge(e, q, tol) {
use mt::BoundedCurve;
let mut c = e.curve();
// The piece kept must not shrink to nothing.
let (lo, hi) = c.range_tuple();
let margin = (hi - lo) * 1e-6;
if (f == v && t >= hi - margin) || (f != v && t <= lo + margin) {
return Err(too_large("the blend is larger than the neighbouring faces"));
}
let rest = c.cut(t);
let (piece, a, b) = if f == v { (rest, nv, bk) } else { (c, f, nv) };
return mt::Edge::try_new(a, b, piece).map_err(|err| KernelError::Failed(format!("blend: corner edge: {err}")));
}
// Continued past v: the arc from the new end through v to the far end.
if edge_circle(e, tol).is_some() && on_extension(e, q, tol) {
let through = p3(vtx(v));
return Ok(if f == v { builder::circle_arc(nv, bk, through) } else { builder::circle_arc(f, nv, through) });
}
Err(too_large("the blend is larger than the neighbouring faces"))
}
#[allow(clippy::too_many_arguments)]
fn blend_geometry(
solid: &Solid,
@@ -237,6 +355,10 @@ fn blend_geometry(
planes: Vec<(Vec3, Vec3)>,
ca: Option<mt::Vertex>,
cb: Option<mt::Vertex>,
/// Points of the end's section on a curved end face, from A to B.
cap: Option<Vec<Vec3>>,
/// How far along the edge the section reaches from the end vertex.
reach: f64,
}
let mut ends: Vec<End> = Vec::new();
for (v, p) in [(&v0, p0), (&v1, p1)] {
@@ -249,10 +371,98 @@ fn blend_geometry(
};
let (ga, gb) = match others[..] {
[g] => (g, g),
[x, y] if shape == Shape::Round && shared(i1, x).is_some() && shared(i2, y).is_some() => (x, y),
[x, y] if shape == Shape::Round && shared(i1, y).is_some() && shared(i2, x).is_some() => (y, x),
[x, y] if shared(i1, x).is_some() && shared(i2, y).is_some() => (x, y),
[x, y] if shared(i1, y).is_some() && shared(i2, x).is_some() => (y, x),
_ => return Err(unsupported("each end of the edge must touch exactly one more face")),
};
// An end face that is not a plane, or meets the side faces in curves: the rails end where
// they reach its surface, and the blend's end is where its surface meets the end face
// (the cross-section through `p` slid along d onto it, point by point).
let is_line = |e: &mt::Edge| matches!(e.curve(), mt::Curve::Line(_));
if ga == gb
&& let (Some(e1), Some(e2)) = (shared(i1, ga), shared(i2, gb))
&& (faces.get(ga).and_then(|f| plane_normal(f)).is_none() || !is_line(&e1) || !is_line(&e2))
{
let g = faces.get(ga).ok_or_else(|| KernelError::Failed("face".into()))?;
let surf = g.surface();
let on_tol = (size * 1e-5).max(1e-6);
let reach = 3.0 * (s + r);
let (sa, sb) = (p + t1 * s, p + t2 * s);
let c0 = centre(p);
let theta = std::f64::consts::PI - phi;
let profile = |f: f64| -> Vec3 {
match shape {
Shape::Flat => sa + (sb - sa) * f,
Shape::Round => c0 + ((sa - c0) * ((1.0 - f) * theta).sin() + (sb - c0) * (f * theta).sin()) / theta.sin(),
}
};
// A rail's end: on the side edge, else on its continuation within the side face.
let rail_end = |x: Vec3, e: &mt::Edge, fi: usize| -> Result<(Vec3, f64)> {
let hits = line_surface_hits(&surf, x, d, reach, tol);
if let Some(l) = hits.iter().find(|l| on_edge(e, x + d * **l, on_tol).is_some()) {
return Ok((x + d * *l, *l));
}
let side = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
hits.iter()
.find(|l| on_extension(e, x + d * **l, on_tol) && extension_fits(side, p, x + d * **l, tol))
.map(|l| (x + d * *l, *l))
.ok_or_else(|| too_large("the blend is larger than the neighbouring faces"))
};
let ((a, la), (b, lb)) = (rail_end(sa, &e1, i1)?, rail_end(sb, &e2, i2)?);
let flat_plane = shape == Shape::Flat && plane_normal(g).is_some();
let mut cap: Option<Vec<Vec3>> = None;
let mut far = la.abs().max(lb.abs());
for n in [16usize, 32, 64] {
if flat_plane {
cap = Some(vec![a, b]);
break;
}
// The section's points on the end face, each continuing from the last.
let mut pts = vec![a];
let mut last = la;
let mut ok = true;
for i in 1..n {
let x = profile(i as f64 / n as f64);
match line_surface_hits(&surf, x, d, reach, tol).into_iter().min_by(|u, v| (u - last).abs().total_cmp(&(v - last).abs())) {
Some(l) => {
pts.push(x + d * l);
last = l;
far = far.max(l.abs());
}
None => {
ok = false;
break;
}
}
}
if !ok {
break;
}
pts.push(b);
// The fitted curve must stay on both surfaces between the points.
let Some(bs) = crate::build::interpolate_cubic(&pts) else { break };
let fits = (0..n).all(|i| {
use mt::{ParametricCurve, ParametricSurface, SearchNearestParameter};
let q = from_p3(bs.subs((i as f64 + 0.5) / n as f64));
let on_g = surf.search_nearest_parameter(p3(q), None, 100).is_some_and(|(u, v)| from_p3(surf.subs(u, v)).dist(q) <= on_tol);
let on_blend = match shape {
Shape::Round => {
let w = q - c0;
((w - d * w.dot(d)).len() - r).abs() <= on_tol
}
Shape::Flat => (sb - sa).cross(d).normalized().is_some_and(|m| (q - sa).dot(m).abs() <= on_tol),
};
on_g && on_blend
});
if fits {
cap = Some(pts);
break;
}
}
let cap = cap.ok_or_else(|| unsupported("the blend's end on a curved face"))?;
ends.push(End { v: v.clone(), g: vec![ga], e1, e2, e3: None, a, b, planes: Vec::new(), ca: None, cb: None, cap: Some(cap), reach: far });
continue;
}
let normal = |g: usize| -> Result<Vec3> {
let n = faces.get(g).and_then(|f| plane_normal(f)).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
if n.dot(d).abs() < 1e-9 {
@@ -311,6 +521,33 @@ fn blend_geometry(
let (len3, u) = (far.dist(p), (far - p).normalized().ok_or_else(|| unsupported("zero-length corner edge"))?);
let c0 = centre(p);
let perp = |x: Vec3| x - d * x.dot(d);
if shape == Shape::Flat {
// The chamfer plane through both rails crosses the edge between them.
let (sa, sb) = (p + t1 * s, p + t2 * s);
let m = (sb - sa).cross(d).normalized().ok_or_else(|| KernelError::Failed("chamfer plane".into()))?;
let den = m.dot(u);
let root = if den.abs() > 1e-12 { m.dot(sa - p) / den } else { f64::NAN };
let x = perp(p + u * root - sa);
let along = x.dot(sb - sa) / (sb - sa).len2();
if !(root > tol && root < len3 - tol && along > 1e-9 && along < 1.0 - 1e-9) {
return Err(unsupported("the blend does not cross the edge between the end faces"));
}
ends.push(End {
v: v.clone(),
g: vec![ga, gb],
e1: e1.clone(),
e2: e2.clone(),
e3: Some((e3.clone(), p + u * root)),
a,
b,
planes: vec![(p, na), (p, nb)],
ca: None,
cb: None,
cap: None,
reach: 0.0,
});
continue;
}
let (w, up) = (perp(p - c0), perp(u));
// |w + λ up|² = r²
let (qa, qb, qc) = (up.len2(), 2.0 * w.dot(up), w.len2() - r * r);
@@ -330,7 +567,7 @@ fn blend_geometry(
};
let planes = if ga == gb { vec![(p, na)] } else { vec![(p, na), (p, nb)] };
let [ca, cb] = consumed;
ends.push(End { v: v.clone(), g: if ga == gb { vec![ga] } else { vec![ga, gb] }, e1, e2, e3, a, b, planes, ca, cb });
ends.push(End { v: v.clone(), g: if ga == gb { vec![ga] } else { vec![ga, gb] }, e1, e2, e3, a, b, planes, ca, cb, cap: None, reach: 0.0 });
}
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
if end0.g.iter().any(|g| end1.g.contains(g)) {
@@ -393,6 +630,16 @@ fn blend_geometry(
let (va, vb) = (vertex(&end.ca, end.a), vertex(&end.cb, end.b));
let (sa, sb) = (p + t1 * s, p + t2 * s);
let (chain, vq) = match (&end.e3, &end.planes[..]) {
_ if end.cap.is_some() => {
let pts = end.cap.as_deref().unwrap_or_default();
let curve = if pts.len() == 2 {
builder::line(&va, &vb)
} else {
let bs = crate::build::interpolate_cubic(pts).ok_or_else(|| unsupported("the blend's end on a curved face"))?;
mt::Edge::try_new(&va, &vb, mt::Curve::BSplineCurve(bs)).map_err(|e| KernelError::Failed(format!("blend end: {e}")))?
};
(vec![curve], None)
}
(None, [plane]) => (vec![section(p, &va, &vb, sa, sb, *plane)?], None),
(Some((_, q)), [pa, pb]) => {
let vq = builder::vertex(p3(*q));
@@ -411,20 +658,18 @@ fn blend_geometry(
// between two end faces stops where the blend crosses it; the blended edge moves to A or B.
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
let mut dropped: Vec<mt::EdgeID> = Vec::new();
let shorten = |e: &mt::Edge, v: &mt::Vertex, nv: &mt::Vertex| {
let (f, bk) = (e.absolute_front(), e.absolute_back());
if f == v { builder::line(nv, bk) } else { builder::line(f, nv) }
};
let on_tol = (size * 1e-5).max(1e-6);
let shorten = |e: &mt::Edge, v: &mt::Vertex, nv: &mt::Vertex| retarget(e, v, nv, on_tol);
for (end, a, b, q) in [(end0, a0, b0, q0), (end1, a1, b1, q1)] {
for (e, nv, used) in [(&end.e1, a, &end.ca), (&end.e2, b, &end.cb)] {
if used.is_some() {
dropped.push(e.id());
continue;
}
subst.insert(e.id(), shorten(e, &end.v, nv));
subst.insert(e.id(), shorten(e, &end.v, nv)?);
}
if let (Some((e3, _)), Some(q)) = (&end.e3, q) {
subst.insert(e3.id(), shorten(e3, &end.v, q));
subst.insert(e3.id(), shorten(e3, &end.v, q)?);
}
}
// The side edges at the ends are pieces of themselves; the rails and corner curves are new.
@@ -479,6 +724,7 @@ fn blend_geometry(
let reach = ends
.iter()
.flat_map(|e| e.planes.iter().map(|(_, n)| (s + r) * (1.0 - n.dot(d).powi(2)).max(0.0).sqrt() / n.dot(d).abs()))
.chain(ends.iter().map(|e| e.reach))
.fold(0.0, f64::max)
+ r;
let start = p0 - d * reach;
@@ -601,32 +847,45 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z])));
}
let edge = Body::unique_edges(&solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?;
let attempt = match guard(what, || blend_one(&solid, &edge, size, r, shape)) {
Ok(o) => Ok((o, vec![j])),
// A curved edge, or one between curved faces: roll a ball along its smooth chain.
Err(e) if !curved_case(&solid, &edge) => Err(e),
Err(e) => match smooth_chain(&solid, &edge) {
Some(ids) => {
let on_chain: Vec<usize> = todo
.iter()
.enumerate()
.filter(|(_, q)| {
cur.nearest_edge(**q, tol)
.ok()
.flatten()
.and_then(|(i, _)| Body::unique_edges(&solid).into_iter().nth(i))
.is_some_and(|x| ids.contains(&x.id()))
})
.map(|(i, _)| i)
.collect();
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 }))
.map_err(|e2| match e2 {
KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m} ({e})")),
e2 => KernelError::Failed(format!("{e2} ({e})")),
})
}
None => Err(e),
// Roll a ball along the edge's smooth chain (the points of `todo` on it go with it).
let roll = |ids: &[mt::EdgeID]| {
let on_chain: Vec<usize> = todo
.iter()
.enumerate()
.filter(|(_, q)| {
cur.nearest_edge(**q, tol)
.ok()
.flatten()
.and_then(|(i, _)| Body::unique_edges(&solid).into_iter().nth(i))
.is_some_and(|x| ids.contains(&x.id()))
})
.map(|(i, _)| i)
.collect();
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 }))
};
// A straight edge running on smoothly into curved ones (a flange into its bend, a
// side into a rounded corner): the blend follows the whole smooth chain, as the
// rolling ball would.
let chain = smooth_chain(&solid, &edge);
let runs_on = !curved_case(&solid, &edge)
&& chain.as_ref().is_some_and(|ids| {
ids.len() > 1 && Body::unique_edges(&solid).iter().any(|x| ids.contains(&x.id()) && !matches!(x.curve(), mt::Curve::Line(_)))
});
let attempt = match chain.as_deref().filter(|_| runs_on).map(roll) {
Some(Ok(o)) => Ok(o),
Some(Err(e2)) => guard(what, || blend_one(&solid, &edge, size, r, shape)).map(|o| (o, vec![j])).map_err(|_| e2),
None => match guard(what, || blend_one(&solid, &edge, size, r, shape)) {
Ok(o) => Ok((o, vec![j])),
// A curved edge, or one between curved faces: roll a ball along its smooth chain.
Err(e) if !curved_case(&solid, &edge) => Err(e),
Err(e) => match chain.as_deref() {
Some(ids) => roll(ids).map_err(|e2| match e2 {
KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m} ({e})")),
e2 => KernelError::Failed(format!("{e2} ({e})")),
}),
None => Err(e),
},
},
};
match attempt.and_then(|((o, rec), js)| record::rebuilt(o, &[&solid], &rec).map(|b| (b, js))) {
@@ -761,8 +1020,9 @@ fn curved_case(solid: &Solid, edge: &mt::Edge) -> bool {
fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option<Vec<mt::EdgeID>> {
use mt::{BoundedCurve, ParametricCurve};
let all = Body::unique_edges(solid);
// Along the edge as it runs from its front to its back (an edge may run against its curve).
let dir = |e: &mt::Edge, at_end: bool| -> Option<Vec3> {
let c = e.curve();
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
let d = c.der(if at_end { t1 } else { t0 });
Vec3::new(d.x, d.y, d.z).normalized()
@@ -828,22 +1088,28 @@ fn smooth_loop(w: &mt::Wire) -> bool {
/// The B-rep edges nearest the points (each once).
fn edge_ids(cur: &Body, solid: &Solid, pts: &[Vec3]) -> Result<Vec<mt::EdgeID>> {
let size = cur.size();
let tol = (size * 2e-3).max(1e-3);
let all = Body::unique_edges(solid);
let mut ids = Vec::new();
for p in pts {
if let Some((idx, dist)) = cur.nearest_edge(*p, tol)?
&& dist <= size * 0.05 + 1e-3
&& let Some(e) = all.get(idx)
&& !ids.contains(&e.id())
if let Some(id) = nearest_edge_id(cur, &all, *p)?
&& !ids.contains(&id)
{
ids.push(e.id());
ids.push(id);
}
}
Ok(ids)
}
/// The edge of `all` (`cur`'s edges, in their order) a point picks: the nearest, if near enough.
fn nearest_edge_id(cur: &Body, all: &[mt::Edge], p: Vec3) -> Result<Option<mt::EdgeID>> {
let size = cur.size();
let tol = (size * 2e-3).max(1e-3);
Ok(match cur.nearest_edge(p, tol)? {
Some((idx, dist)) if dist <= size * 0.05 + 1e-3 => all.get(idx).map(|e| e.id()),
_ => None,
})
}
/// When the edges make up whole boundary loops of planar faces: the edge points per loop.
fn loop_groups(cur: &Body, pts: &[Vec3]) -> Result<Option<Vec<Vec<Vec3>>>> {
Ok(loop_split(cur, pts)?.and_then(|(g, rest)| rest.is_empty().then_some(g)))
@@ -955,6 +1221,116 @@ pub fn fillet_max(body: &Body, edges: &[Vec3], radius: f64) -> Option<(f64, Body
best.map(|b| (lo as f64 * q, b))
}
/// [`fillet_max`] for each group of the edges that may limit each other's radius (sharing a
/// face or a vertex, directly or through other edges in the group), so a round that fits isn't
/// reduced for a tight edge elsewhere. The radius per edge (in `edges`' order) and the result;
/// one radius for all (as [`fillet_max`]) when a group is refused for another reason than its
/// size, the groups don't build together, or there are too many to search (each search is up
/// to 16 fillets, and this runs in the live preview).
pub fn fillet_max_each(body: &Body, edges: &[Vec3], radius: f64) -> Option<(Vec<f64>, Body)> {
const MAX_GROUPS: usize = 8;
const MAX_SEARCHES: usize = 2;
let one = || fillet_max(body, edges, radius).map(|(r, b)| (vec![r; edges.len()], b));
let Some(groups) = interacting_groups(body, edges).filter(|g| g.len() > 1 && g.len() <= MAX_GROUPS) else { return one() };
let mut radii = vec![radius; edges.len()];
let mut searches = 0;
for g in &groups {
let pts: Vec<Vec3> = g.iter().filter_map(|i| edges.get(*i).copied()).collect();
let r = match fillet(body, &pts, radius) {
Ok(_) => continue,
Err(KernelError::TooLarge(_)) if searches < MAX_SEARCHES => {
searches += 1;
match fillet_max(body, &pts, radius) {
Some((r, _)) => r,
None => return one(),
}
}
Err(_) => return one(),
};
for i in g {
if let Some(x) = radii.get_mut(*i) {
*x = r;
}
}
}
match fillet_each(body, edges, &radii) {
Ok(b) => Some((radii, b)),
Err(_) => one(),
}
}
/// The fillet of each edge at its own radius (`radii` in `edges`' order): built together where
/// the corner build takes it, else the edges of each radius together, the largest first.
pub fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64]) -> Result<Body> {
if radii.len() != edges.len() {
return Err(KernelError::Invalid("one radius per edge".into()));
}
if let Some(r) = radii.first().filter(|r| radii.iter().all(|x| x.to_bits() == r.to_bits())) {
return fillet(body, edges, *r);
}
let sel: Vec<(Vec3, f64)> = edges.iter().copied().zip(radii.iter().copied()).collect();
if let Ok(b) = crate::blend_corner::fillet_radii(body, &sel) {
return Ok(b);
}
let mut by_radius: Vec<(f64, Vec<Vec3>)> = Vec::new();
for (p, r) in sel {
match by_radius.iter_mut().find(|(x, _)| x.to_bits() == r.to_bits()) {
Some((_, pts)) => pts.push(p),
None => by_radius.push((r, vec![p])),
}
}
by_radius.sort_by(|a, b| b.0.total_cmp(&a.0));
let mut cur = body.clone();
for (r, pts) in &by_radius {
cur = fillet(&cur, pts, *r)?;
}
Ok(cur)
}
/// The points' edges (found as [`edge_ids`] finds them, on the body as the blends see it) grouped
/// where they share a face or a vertex, directly or through other points' edges: indices into
/// `pts`. `None` when a point has no edge near it.
fn interacting_groups(body: &Body, pts: &[Vec3]) -> Option<Vec<Vec<usize>>> {
let cur = crate::boolean_record::healed(body).ok()?;
let all = Body::unique_edges(&cur.solid);
// What each point's edge touches: its faces and its ends.
let mut groups: Vec<(Vec<mt::FaceID>, Vec<mt::VertexID>, Vec<usize>)> = Vec::new();
for (i, p) in pts.iter().enumerate() {
let id = nearest_edge_id(&cur, &all, *p).ok()??;
let edge = all.iter().find(|e| e.id() == id)?;
let mut faces: Vec<mt::FaceID> = cur.solid.face_iter().filter(|f| f.edge_iter().any(|e| e.id() == id)).map(|f| f.id()).collect();
let mut ends = vec![edge.front().id(), edge.back().id()];
let mut members = vec![i];
// Join every group touching one of these faces or ends.
let (meet, rest): (Vec<_>, Vec<_>) =
groups.into_iter().partition(|(fs, vs, _)| fs.iter().any(|f| faces.contains(f)) || vs.iter().any(|v| ends.contains(v)));
for (fs, vs, ms) in meet {
for f in fs {
if !faces.contains(&f) {
faces.push(f);
}
}
for v in vs {
if !ends.contains(&v) {
ends.push(v);
}
}
members.extend(ms);
}
groups = rest;
groups.push((faces, ends, members));
}
Some(
groups
.into_iter()
.map(|(_, _, mut ms)| {
ms.sort_unstable();
ms
})
.collect(),
)
}
/// Equal-distance chamfer of the edges nearest to the given points. Each edge is cut with a
/// prism whose only face inside the body is the chamfer plane (so neighbouring chamfers meet in
/// a mitre); the local operation is the fallback.
@@ -1236,6 +1612,102 @@ mod tests {
}
}
/// A box with a cylinder joined across one side (#556): the bottom edge of that side runs
/// into the cylinder, which is curved where the edge ends.
fn box_and_boss() -> Body {
let b = crate::build::box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap();
let c = crate::build::cylinder(Vec3::new(40.0, 20.0, 0.0), Vec3::Z, 12.0, 40.0).unwrap();
crate::ops::boolean(&b, &c, crate::BoolOp::Union).unwrap().unwrap()
}
#[test]
fn a_blend_runs_into_a_cylinder_at_its_end() {
let body = box_and_boss();
let vol = |b: &Body| b.tessellate(1e-4).unwrap().measure().volume;
let v0 = vol(&body);
// The edge (40, 0..8, 0) ends in the cylinder wall at y = 8.
let len = 20.0 - (144.0f64 - 0.0).sqrt();
for (shape, size, area) in [(Shape::Round, 0.5, (1.0 - std::f64::consts::PI / 4.0) * 0.25), (Shape::Flat, 0.5, 0.125)] {
let out = blend(&body, &[Vec3::new(40.0, 4.0, 0.0)], size, shape, "blend").unwrap();
assert!(out.validity().is_empty(), "{:?}", out.validity());
let removed = v0 - vol(&out);
// The section runs on a little past y = 8 under the cylinder wall.
assert!(removed > area * len * 0.99 && removed < area * (len + 0.02), "{removed} vs {}", area * len);
assert_eq!(out.face_count(), body.face_count() + 1);
}
}
#[test]
fn a_blend_ends_on_a_hole_wall() {
// A plate with a hole cutting through the middle of its top front edge: each piece of
// that edge ends on the hole's wall.
let plate = crate::build::box_solid(Vec3::ZERO, Vec3::new(40.0, 20.0, 10.0)).unwrap();
let hole = crate::build::cylinder(Vec3::new(20.0, 0.0, -1.0), Vec3::Z, 5.0, 12.0).unwrap();
let body = crate::ops::boolean(&plate, &hole, crate::BoolOp::Cut).unwrap().unwrap();
let vol = |b: &Body| b.tessellate(1e-4).unwrap().measure().volume;
let v0 = vol(&body);
for (shape, area) in [(Shape::Round, (1.0 - std::f64::consts::PI / 4.0)), (Shape::Flat, 0.5)] {
let out = blend(&body, &[Vec3::new(5.0, 0.0, 10.0)], 1.0, shape, "blend").unwrap();
assert!(out.validity().is_empty(), "{:?}", out.validity());
let removed = v0 - vol(&out);
assert!(removed > area * 14.0 && removed < area * 15.2, "{removed}");
}
}
#[test]
fn a_chamfer_ends_across_two_faces() {
// A plate's outline chamfered, then the mitre at a corner chamfered: its low end meets
// both side faces, and the chamfer plane cuts back the vertical edge between them.
let k = 5.0;
let b = crate::build::box_solid(Vec3::ZERO, Vec3::new(100.0, 50.0, 20.0)).unwrap();
let outline = [Vec3::new(50.0, 0.0, 20.0), Vec3::new(100.0, 25.0, 20.0), Vec3::new(50.0, 50.0, 20.0), Vec3::new(0.0, 25.0, 20.0)];
let c = chamfer(&b, &outline, k).unwrap();
let out = blend(&c, &[Vec3::new(k / 2.0, k / 2.0, 20.0 - k / 2.0)], 1.0, Shape::Flat, "chamfer").unwrap();
assert!(out.validity().is_empty(), "{:?}", out.validity());
assert_eq!(out.lumps().unwrap().len(), 1);
assert_eq!(out.face_count(), c.face_count() + 1);
let vol = |b: &Body| b.tessellate(1e-4).unwrap().measure().volume;
// About the triangle (sides 1 and 1 at 120°) along the mitre; its ends slant.
let removed = vol(&c) - vol(&out);
let along = 0.5 * 120f64.to_radians().sin() * k * 3f64.sqrt();
assert!(removed > along * 0.8 && removed < along * 1.2, "{removed} vs {along}");
}
#[test]
fn a_curved_edge_blend_ends_in_a_face_at_an_angle() {
// A groove of radius 8 along y with its axis 4 below the top: the arcs where it meets the
// front and back faces end in the top face at 60° to it.
let b = crate::build::box_solid(Vec3::ZERO, Vec3::new(40.0, 20.0, 20.0)).unwrap();
let c = crate::build::cylinder(Vec3::new(20.0, -1.0, 16.0), Vec3::Y, 8.0, 22.0).unwrap();
let body = crate::ops::boolean(&b, &c, crate::BoolOp::Cut).unwrap().unwrap();
let vol = |b: &Body| b.tessellate(1e-4).unwrap().measure().volume;
let v0 = vol(&body);
for shape in [Shape::Round, Shape::Flat] {
let out = blend(&body, &[Vec3::new(20.0, 0.0, 8.0)], 0.5, shape, "blend").unwrap();
assert!(out.validity().is_empty(), "{:?}", out.validity());
// One blend face per piece of the arc (the groove's surface is in two faces).
assert_eq!(out.face_count(), body.face_count() + 2);
// About the section along the arc (240°, at the section's distance from the axis).
let area = if shape == Shape::Round { (1.0 - std::f64::consts::PI / 4.0) * 0.25 } else { 0.125 };
let along = 8.2 * 240f64.to_radians();
let removed = v0 - vol(&out);
assert!(removed > area * along * 0.9 && removed < area * along * 1.1, "{removed} vs {}", area * along);
}
}
#[test]
fn a_blend_of_an_edge_running_into_a_rounded_corner_follows_it() {
// A box with one vertical edge rounded: a top edge meeting that round smoothly is blended
// with the round's top arc and the next top edge, as one smooth chain.
let b = crate::build::box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let rounded = blend(&b, &[Vec3::new(40.0, 30.0, 10.0)], 5.0, Shape::Round, "fillet").unwrap();
for shape in [Shape::Round, Shape::Flat] {
let out = blend(&rounded, &[Vec3::new(20.0, 30.0, 20.0)], 1.0, shape, "blend").unwrap();
assert!(out.validity().is_empty(), "{:?}", out.validity());
assert_eq!(out.face_count(), rounded.face_count() + 3);
}
}
#[test]
fn local_chamfer_intersects_sloped_end_planes() {
for slope in [-0.5, 0.5] {
+81 -13
View File
@@ -40,7 +40,7 @@ pub struct Body {
pub(crate) history: Option<std::sync::Arc<crate::history::History>>,
}
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum FaceSource {
ExtrudeSide {
segment: solidcraft_geom::Seg2,
@@ -110,7 +110,7 @@ impl FaceSource {
}
/// How a body looks beyond its colour, for export: its opacity and its faces' own colours.
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Paint {
/// 1 = opaque.
pub opacity: f32,
@@ -118,7 +118,7 @@ pub struct Paint {
}
/// A face's own colour: the B-rep face index (`face_iter` order), sRGB as 0..1, opacity.
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FacePaint {
pub face: usize,
pub color: [f32; 3],
@@ -148,17 +148,38 @@ pub struct FaceInfo {
type MeshedShell = truck_topology::Shell<mt::Point3, PolylineCurve<mt::Point3>, Option<PolygonMesh>>;
/// The most one facet of the display mesh turns (radians): a curved face or edge is divided
/// until no facet spans more of it than this, however small its radius is beside the body.
/// 20 degrees: a quarter turn is drawn with at least five facets, a full circle with at least 18.
pub const DISPLAY_ANGLE: f64 = std::f64::consts::PI / 9.0;
/// The sag, as a fraction of the chord tolerance, below which the angle no longer divides a
/// facet: at [`DISPLAY_ANGLE`] and the application's tolerance of 1/1000 of the body, a radius
/// under about 1/15000 of the body gets fewer facets than the angle asks for, and one under
/// a millionth of it only those of the chord tolerance.
const DISPLAY_ANGLE_FLOOR: f64 = 1e-3;
/// The finest angle a display mesh is divided by (one degree).
const MIN_DISPLAY_ANGLE: f64 = std::f64::consts::PI / 180.0;
/// Triangulate each shell (not the solid: truck's solid constructor panics on the open or
/// non-manifold shells an import can produce). Boundaries that lie on their surface only within
/// tolerance (imports, boolean results) are projected onto it.
fn mesh_shells(solid: &Solid, tol: f64) -> Vec<MeshedShell> {
mesh_shells_within(solid, Deflection::chord(tol))
}
/// [`mesh_shells`] divided by a chord tolerance and, when it has one, a bound on the angle a
/// facet spans. Each edge is divided once for both its faces, so the faces still share its
/// points.
fn mesh_shells_within(solid: &Solid, deflection: Deflection) -> Vec<MeshedShell> {
solid
.boundaries()
.iter()
// The robust search tries the exact one first, so exact geometry costs nothing extra; a
// face can also come back as an empty mesh rather than none (boolean results with
// approximate intersection curves), so always use it.
.map(|sh| sh.robust_triangulation(tol))
.map(|sh| sh.robust_triangulation_within(deflection))
.collect()
}
@@ -456,13 +477,26 @@ impl Body {
if lo.x.is_finite() { (hi - lo).len() } else { 1.0 }
};
let tol = (span * 0.01).clamp(1e-3, 1.0);
let (v, missing) = guard("orient", || {
let shells = mesh_shells(&solid, tol);
let volume = shells.iter().map(|s| s.to_polygon().volume()).sum::<f64>();
let missing =
shells.iter().flat_map(|s| s.face_iter()).filter(|f| f.surface().is_none_or(|m| m.faces().triangle_iter().next().is_none())).count();
Ok((volume, missing))
})?;
let orient = |tol: f64| {
guard("orient", || {
let shells = mesh_shells(&solid, tol);
let volume = shells.iter().map(|s| s.to_polygon().volume()).sum::<f64>();
let missing = shells
.iter()
.flat_map(|s| s.face_iter())
.filter(|f| f.surface().is_none_or(|m| m.faces().triangle_iter().next().is_none()))
.count();
Ok((volume, missing))
})
};
let (mut v, mut missing) = orient(tol)?;
// A long thin round part (a through hole's tool: a cylinder whose vertices all lie on
// its seam, so they span its length only) meshed that coarsely can come out with a
// negative volume when it is outward already: check finely before turning it inside out.
let fine = (tol * 0.02).max(1e-4);
if v < 0.0 && fine < tol {
(v, missing) = orient(fine)?;
}
if v < 0.0 {
solid.not();
}
@@ -838,13 +872,24 @@ impl Body {
/// Like [`Body::tessellate`]; with `refine`, triangles that cut through curved faces are
/// split until within `tol` of the surface (for measuring).
pub fn tessellate_with(&self, tol: f64, refine: bool) -> Result<Mesh> {
self.tessellate_within(tol, f64::INFINITY, refine)
}
/// [`Body::tessellate_with`] in which no facet turns more than `angle` radians either
/// (infinite: the chord tolerance alone).
fn tessellate_within(&self, tol: f64, angle: f64, refine: bool) -> Result<Mesh> {
let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.05 };
let tol = tol.max(self.size() * 1e-6);
if let Some(m) = &self.mesh {
return Ok(m.to_mesh());
}
let deflection = if angle.is_finite() && angle > 0.0 {
Deflection { chord: tol, angle: angle.max(MIN_DISPLAY_ANGLE), floor: tol * DISPLAY_ANGLE_FLOOR }
} else {
Deflection::chord(tol)
};
guard("tessellate", || {
let meshed = mesh_shells(&self.solid, tol);
let meshed = mesh_shells_within(&self.solid, deflection);
let mut out = Mesh::default();
let solid_faces: Vec<&mt::Face> = self.solid.face_iter().collect();
for (fi, face) in meshed.iter().flat_map(|s| s.face_iter()).enumerate() {
@@ -941,7 +986,20 @@ impl Body {
/// Display mesh: like [`Body::tessellate`], with seam edges flagged.
pub fn display_mesh(&self, tol: f64) -> Result<Mesh> {
let mut m = self.tessellate(tol)?;
self.flagged(self.tessellate(tol)?)
}
/// Display mesh whose curved faces and edges look curved whatever the body's size: like
/// [`Body::display_mesh`], and no facet turns more than `angle` radians (see
/// [`DISPLAY_ANGLE`]). A chord tolerance that follows the body's size is larger than the
/// sagitta of a small radius on a large body (a sheet-metal bend, a small hole), which the
/// tolerance alone leaves as one flat facet.
pub fn display_mesh_angular(&self, tol: f64, angle: f64) -> Result<Mesh> {
self.flagged(self.tessellate_within(tol, angle, false)?)
}
/// A tessellation with its edges flagged for display (seams, tangent joins, protected creases).
fn flagged(&self, mut m: Mesh) -> Result<Mesh> {
if self.is_mesh() {
return Ok(m);
}
@@ -1101,6 +1159,12 @@ impl Body {
self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3))
}
/// A union's pieces must be clear of each other by the contact distance of [`Body::meets`];
/// closer shells are touching material that was never fused (#396).
pub(crate) fn union_lumps(&self) -> Result<Vec<Body>> {
self.lumps_with_clearance(2.0 * (self.size() * 1e-4).max(1e-6))
}
fn lumps_with_clearance(&self, clearance: f64) -> Result<Vec<Body>> {
if self.mesh.is_some() || self.solid.boundaries().len() < 2 {
return Ok(vec![self.clone()]);
@@ -1221,6 +1285,10 @@ mod lump_tests;
#[path = "refine_cache_tests.rs"]
mod refine_cache_tests;
#[cfg(test)]
#[path = "display_mesh_tests.rs"]
mod display_mesh_tests;
#[cfg(test)]
#[path = "regularization_protection_tests.rs"]
mod regularization_protection_tests;
+16
View File
@@ -303,3 +303,19 @@ fn grouping_lumps_does_not_infer_absent_or_misaligned_sources() {
let malformed = body.with_face_sources(Some(std::sync::Arc::new(vec![None])));
assert!(malformed.lumps().unwrap_err().to_string().contains("face source table"));
}
/// A long thin cylinder (a through hole's tool) far from the origin, its seam turned: its
/// vertices span only its length, so the coarse orientation mesh can give it a negative volume.
/// It must still come out outward, or a Cut with it removes the wrong side (#559).
#[test]
fn a_long_thin_cylinder_comes_out_outward() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
let plane = Plane::from_normal(Vec3::new(0., 24., 30.), Vec3::Z).unwrap();
for (lo, seam) in [(-181.0, 0.613), (-100.0, 0.613), (-10.0, 0.613), (-181.0, 0.0)] {
let disc = Region2 { outer: Loop2::circle_from(Vec2::ZERO, 0.5, seam), holes: vec![] };
let tool = crate::extrude(&plane, &[disc], lo, 0.9).unwrap().pop().unwrap();
let volume = tool.tessellate(0.01).unwrap().measure().volume;
let want = std::f64::consts::PI * 0.25 * (0.9 - lo);
assert!((volume - want).abs() < want * 0.02, "{lo} {seam}: {volume} vs {want}");
}
}
+13
View File
@@ -17,6 +17,9 @@ pub enum BooleanStrategy {
PlanarFallback,
ToolNudge,
Piecewise,
/// No retry gave a result, and the answer from sample points was kept although the bodies'
/// facetings disagree with it.
UnverifiedShortcut,
}
/// Numerical adjustments used by a successful retry, in millimetres/radians.
@@ -47,6 +50,10 @@ impl BooleanFallback {
BooleanStrategy::PlanarFallback => "exact planar fallback (no displacement)".into(),
BooleanStrategy::ToolNudge => format!("tool displacement {:.6e} mm, rotation {:.6e} rad", self.translation_mm, self.rotation_rad),
BooleanStrategy::Piecewise => "piecewise retry (no displacement)".into(),
BooleanStrategy::UnverifiedShortcut => format!(
"not verified: the bodies' facets pass through each other by more than {:.6e} mm, but they could not be intersected there; that part is ignored",
self.tolerance_mm
),
}
}
}
@@ -66,6 +73,12 @@ impl BooleanDiagnostics {
}
}
/// Why the result is not verified, when it ignores a place where the bodies seem to pass
/// through each other ([`BooleanStrategy::UnverifiedShortcut`]).
pub fn unverified(&self) -> Option<String> {
self.events.iter().find(|event| event.strategy == BooleanStrategy::UnverifiedShortcut).map(BooleanFallback::description)
}
pub fn warning(&self) -> Option<String> {
if self.events.is_empty() && !self.truncated {
return None;
+183
View File
@@ -0,0 +1,183 @@
//! The check every Boolean retry's result must pass before it is returned (issue #616).
//!
//! A retry moves things about (the tool nudged, faces pushed apart, pieces cut off and glued
//! back), so its result is held to what a join or a cut can do at all:
//! - it is closed;
//! - a join never loses volume and a cut never gains any;
//! - outside the tool's box nothing changes: there, the result's surface is the target's and
//! the target's surface is the result's. A join or a cut can only change the target where the
//! tool is, so geometry anywhere else (a slit through a wall, a face moved) is wrong.
//!
//! A result that fails is refused with an error: a wrong result that looks right is worse.
use solidcraft_geom::{Aabb3, Mesh, Vec3};
use crate::body::Body;
use crate::{BoolOp, KernelError, Result};
/// Most triangles sampled from each surface (evenly spaced through the mesh).
const MAX_SAMPLES: usize = 20_000;
/// Distance from `p` to the triangle `abc`.
fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
let n = (b - a).cross(c - a);
let Some(nn) = n.normalized() else {
return [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min);
};
let h = (p - a).dot(nn);
let q = p - nn * h;
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= 0.0);
if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) }
}
/// The triangles of a mesh in a uniform grid of cells, each listing the triangles within `reach`
/// of it, to answer "is this point within `reach` of the surface?".
struct Near<'a> {
mesh: &'a Mesh,
min: Vec3,
cell: f64,
dims: [usize; 3],
cells: Vec<Vec<u32>>,
reach: f64,
}
impl<'a> Near<'a> {
fn new(mesh: &'a Mesh, reach: f64) -> Option<Self> {
let bounds = mesh.bounds();
if bounds.is_empty() {
return None;
}
let min = bounds.min - Vec3::new(reach, reach, reach);
let size = bounds.size() + Vec3::new(2.0 * reach, 2.0 * reach, 2.0 * reach);
// About a few triangles a cell, at most 64^3 cells.
let n = (mesh.triangles.len().max(1) as f64).cbrt().clamp(1.0, 64.0);
let cell = (size.x.max(size.y).max(size.z) / n).max(reach * 2.0).max(1e-9);
let dims = [size.x, size.y, size.z].map(|s| ((s / cell).ceil() as usize).clamp(1, 256));
let mut cells = vec![Vec::new(); dims[0] * dims[1] * dims[2]];
for (i, t) in mesh.triangles.iter().enumerate() {
let Some(tri) = mesh.tri(t) else { continue };
let mut bx = Aabb3::EMPTY;
for p in tri {
bx.add(p);
}
let lo = Self::index(min, cell, dims, bx.min - Vec3::new(reach, reach, reach));
let hi = Self::index(min, cell, dims, bx.max + Vec3::new(reach, reach, reach));
for x in lo[0]..=hi[0] {
for y in lo[1]..=hi[1] {
for z in lo[2]..=hi[2] {
if let Some(c) = cells.get_mut((x * dims[1] + y) * dims[2] + z) {
c.push(i as u32);
}
}
}
}
}
Some(Near { mesh, min, cell, dims, cells, reach })
}
fn index(min: Vec3, cell: f64, dims: [usize; 3], p: Vec3) -> [usize; 3] {
let d = p - min;
let f = |v: f64, n: usize| ((v / cell).floor().max(0.0) as usize).min(n - 1);
[f(d.x, dims[0]), f(d.y, dims[1]), f(d.z, dims[2])]
}
fn close(&self, p: Vec3) -> bool {
let d = p - self.min;
let s = [d.x, d.y, d.z];
if s.iter().zip(self.dims).any(|(v, n)| *v < 0.0 || *v > self.cell * n as f64) {
return false;
}
let [x, y, z] = Self::index(self.min, self.cell, self.dims, p);
let Some(list) = self.cells.get((x * self.dims[1] + y) * self.dims[2] + z) else { return false };
list.iter().any(|&t| {
self.mesh.triangles.get(t as usize).and_then(|t| self.mesh.tri(t)).is_some_and(|[a, b, c]| point_triangle_dist(p, a, b, c) <= self.reach)
})
}
}
/// A point of `from`'s surface outside `region` that is not on `onto`'s surface (within
/// `reach`), if there is one.
fn stray_point(from: &Mesh, onto: &Near, region: &Aabb3) -> Option<Vec3> {
let step = from.triangles.len().div_ceil(MAX_SAMPLES).max(1);
from.triangles.iter().step_by(step).filter_map(|t| from.tri(t)).find_map(|[a, b, c]| {
let p = (a + b + c) / 3.0;
let outside =
p.x < region.min.x || p.y < region.min.y || p.z < region.min.z || p.x > region.max.x || p.y > region.max.y || p.z > region.max.z;
(outside && !onto.close(p)).then_some(p)
})
}
fn bad(op: BoolOp, what: String) -> KernelError {
let verb = match op {
BoolOp::Union => "join",
BoolOp::Cut => "cut",
BoolOp::Intersect => "intersect",
};
KernelError::Failed(format!(
"the {verb} could not be computed reliably: {what}. The result was refused rather than kept wrong; \
where the bodies only touch (faces flush with each other), overlapping them slightly usually helps"
))
}
/// Check the result of a Boolean that needed a retry (see the module docs). `result` is `None`
/// for an empty result.
pub(crate) fn check_retry(a: &Body, b: &Body, op: BoolOp, result: Option<&Body>) -> Result<()> {
if op == BoolOp::Intersect || a.is_mesh() || b.is_mesh() {
return Ok(());
}
let Some(r) = result else {
// Nothing left: only a cut whose tool covers the whole target (to within the meshes'
// chords: a tool touching a round side of the target).
let chord = a.size().max(b.size()) * 1e-3;
let (Ok(ma), Ok(mb)) = (a.tessellate(chord), b.tessellate(chord)) else { return Ok(()) };
let (ba, bb) = (ma.bounds(), mb.bounds());
let tol = 2.0 * chord;
let within = ba.min.x >= bb.min.x - tol
&& ba.min.y >= bb.min.y - tol
&& ba.min.z >= bb.min.z - tol
&& ba.max.x <= bb.max.x + tol
&& ba.max.y <= bb.max.y + tol
&& ba.max.z <= bb.max.z + tol;
return if op == BoolOp::Cut && within { Ok(()) } else { Err(bad(op, "it came out empty".into())) };
};
if r.solid.boundaries().iter().any(|s| s.shell_condition() != truck_modeling::ShellCondition::Closed) {
return Err(bad(op, "the result is not closed".into()));
}
let size = a.size().max(b.size());
let t = (size * 2e-4).max(1e-5);
let (Ok(ma), Ok(mb), Ok(mr)) = (a.tessellate_with(t, true), b.tessellate_with(t, true), r.tessellate_with(t, true)) else {
return Err(bad(op, "the result could not be checked".into()));
};
let (va, vb, vr) = (ma.measure().volume, mb.measure().volume, mr.measure().volume);
let slack = 2e-4 * (va.abs() + vb.abs()) + 1e-9;
let volume_ok = vr.is_finite()
&& match op {
BoolOp::Union => vr >= va.max(vb) - slack && vr <= va + vb + slack,
BoolOp::Cut => vr <= va + slack && vr >= va - vb - slack,
BoolOp::Intersect => true,
};
if !volume_ok {
let what = match op {
BoolOp::Union => format!("the joined body's volume would be {vr:.4} mm³ (the target is {va:.4} mm³, the tool {vb:.4} mm³)"),
_ => format!("the cut body's volume would be {vr:.4} mm³ (the target is {va:.4} mm³, the tool {vb:.4} mm³)"),
};
return Err(bad(op, what));
}
// Outside the tool's box (with room for the meshes' chords and a nudge) the result's
// surface and the target's must be the same.
let reach = 3.0 * t + size * 1e-5;
let mut region = mb.bounds();
region.min -= Vec3::new(reach, reach, reach) * 2.0;
region.max += Vec3::new(reach, reach, reach) * 2.0;
let (Some(near_a), Some(near_r)) = (Near::new(&ma, reach), Near::new(&mr, reach)) else {
return Err(bad(op, "the result could not be checked".into()));
};
let at = |p: Vec3| format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z);
if let Some(p) = stray_point(&mr, &near_a, &region) {
return Err(bad(op, format!("it would change the body away from the tool, at {} (outside the tool's region)", at(p))));
}
if let Some(p) = stray_point(&ma, &near_r, &region) {
return Err(bad(op, format!("it would remove part of the body away from the tool, at {} (outside the tool's region)", at(p))));
}
Ok(())
}
+74 -1
View File
@@ -494,7 +494,8 @@ pub fn torus(center: Vec3, major: f64, minor: f64) -> Result<Body> {
}
/// Offset every segment of a loop to its right by `d` (outward for a region's outer loop and
/// holes alike). Loops of lines (mitred corners) and loops of arcs on one circle are supported.
/// holes alike). Loops of lines (mitred corners), loops of arcs on one circle, and loops of lines
/// and arcs that meet the arcs tangentially are supported.
fn offset_loop(lp: &Loop2, d: f64) -> Result<Loop2> {
if d == 0.0 {
return Ok(lp.clone());
@@ -518,6 +519,9 @@ fn offset_loop(lp: &Loop2, d: f64) -> Result<Loop2> {
.collect::<Result<Vec<_>>>()?;
return Ok(Loop2 { segs });
}
if lp.segs.iter().any(|s| matches!(s, Seg2::Arc { .. })) {
return offset_lines_and_arcs(lp, d);
}
let n = lp.segs.len();
let mut lines: Vec<(Vec2, Vec2)> = Vec::with_capacity(n);
for s in &lp.segs {
@@ -544,6 +548,75 @@ fn offset_loop(lp: &Loop2, d: f64) -> Result<Loop2> {
Ok(out)
}
/// [`offset_loop`] for a loop of lines and arcs. Each arc keeps its centre and angles and its
/// radius moves by `d` (so its side stays an exact cone), each line moves `d` to its right, and
/// two lines meeting at a corner are mitred. An arc has to meet its neighbours tangentially: at
/// a corner its side would meet the next one along a conic, not a straight ruling.
fn offset_lines_and_arcs(lp: &Loop2, d: f64) -> Result<Loop2> {
let closes = || KernelError::Invalid("the taper closes the profile".into());
let right = |v: Vec2| Vec2::new(v.y, -v.x);
let n = lp.segs.len();
let moved: Vec<Option<Seg2>> = lp
.segs
.iter()
.map(|s| match *s {
Seg2::Line { .. } => Ok(None),
Seg2::Arc { center, radius, start, sweep } => {
let r = radius + sweep.signum() * d;
if r > 1e-6 { Ok(Some(Seg2::Arc { center, radius: r, start, sweep })) } else { Err(closes()) }
}
_ => Err(KernelError::Failed("not supported yet: tapered profiles with splines or conics".into())),
})
.collect::<Result<_>>()?;
// Where each segment starts after the offset.
let mut joints = Vec::with_capacity(n);
for (i, s) in lp.segs.iter().enumerate() {
let k = (i + n - 1) % n;
let (Some(prev), Some(prev_moved), Some(this_moved)) = (lp.segs.get(k), moved.get(k), moved.get(i)) else {
return Err(KernelError::Failed("taper: profile loop".into()));
};
let (tin, tout) = (prev.end_tangent(), s.start_tangent());
let tangent = tin.cross(tout).abs() < 1e-6 && tin.dot(tout) > 0.0;
joints.push(match (prev_moved, this_moved) {
(Some(a), _) if tangent => a.end(),
(None, Some(b)) if tangent => b.start(),
(None, None) => {
let v = s.start();
let (p0, p1) = (v + right(tin) * d, v + right(tout) * d);
let den = tin.cross(tout);
if den.abs() < 1e-12 { p1 } else { p0 + tin * ((p1 - p0).cross(tout) / den) }
}
_ => return Err(KernelError::Failed("not supported yet: tapered profiles where an arc meets another edge at a corner".into())),
});
}
let mut segs = Vec::with_capacity(n);
for (i, s) in lp.segs.iter().enumerate() {
let (Some(&a), Some(&b), Some(m)) = (joints.get(i), joints.get((i + 1) % n), moved.get(i)) else {
return Err(KernelError::Failed("taper: profile loop".into()));
};
match m {
Some(arc) => {
if arc.start().dist(a) > 1e-6 || arc.end().dist(b) > 1e-6 {
return Err(KernelError::Failed("not supported yet: tapered profiles where an arc meets another edge at a corner".into()));
}
segs.push(*arc);
}
None => {
// A line that turned around has been swallowed by its neighbours.
if (b - a).dot(s.end() - s.start()) <= 0.0 {
return Err(closes());
}
segs.push(Seg2::Line { a, b });
}
}
}
let out = Loop2 { segs };
if out.signed_area().signum() != lp.signed_area().signum() || out.signed_area().abs() < 1e-9 {
return Err(closes());
}
Ok(out)
}
/// Extrude one region by `length` along `dir_sign` × the plane normal with a taper angle
/// (radians; positive grows the profile).
pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f64, taper: f64) -> Result<Body> {
+196 -66
View File
@@ -335,19 +335,16 @@ pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
}
for (n, d) in &back {
// a's face points along n; b's face points along −n at the same plane (−d).
let shift = if op == BoolOp::Union && crate::ops::narrowing_bores() {
let shift = if op == BoolOp::Union {
// Pushed into `a`, b's face must have a's material behind it all over and that
// deep: over a hole in a's face (a narrowed bore's rim over the other body's
// bore) it would fill the hole, through a thin wall it would stick out the far
// side. Only checked inside `bore_union`, which then tries its other way; a
// general check turns some joins that come out slightly wrong today (a pad
// half past an edge) into failures, until such faces can be split first.
// bore, a rib standing half over a window) it would fill the hole, through a
// thin wall it would stick out the far side (#616). The other retries take
// such a join instead.
[d2, d2 * 0.25, d2 * 0.0625]
.into_iter()
.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 if op == BoolOp::Union {
d2
} else {
-d2
};
@@ -424,6 +421,30 @@ fn trimmed(x: &Body, h: History, slab: &Body, operands: [&Body; 2], n: Vec3, d:
Some(cut)
}
/// Do the bodies touch over an area, back to back on a common plane: has `a` material just
/// behind some part of a face of `b` lying in a plane of a face of `a` that faces the other way?
/// (Contact along a line or at a point is not this.)
pub(crate) fn faces_touch(a: &Body, b: &Body) -> bool {
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
let pa = planes(a);
let faces: Vec<(usize, Vec3)> = planes(b)
.into_iter()
.filter(|(_, y)| pa.iter().any(|(_, x)| x.n.dot(y.n) < -1.0 + 1e-9 && (x.d + y.d).abs() < tol))
.map(|(i, y)| (i, y.n))
.collect();
if faces.is_empty() {
return false;
}
let (Ok(ma), Ok(mb)) = (a.tessellate_with(size * 1e-3, true), b.tessellate_with(size * 1e-3, true)) else { return false };
let inside = ma.inside_index();
let depth = (size * 1e-4).max(1e-6);
mb.triangles.iter().zip(&mb.tri_face).take(100_000).any(|(t, fi)| {
let Some((_, n)) = faces.iter().find(|(i, _)| *i == *fi as usize) else { return false };
mb.tri(t).is_some_and(|[p, q, r]| inside.contains((p + q + r) / 3.0 + *n * depth))
})
}
/// 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 {
@@ -447,9 +468,10 @@ 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))))
}
/// Union of bodies that touch along one identical planar face, back to back (a body joined
/// with its mirror image): the two faces go and the shells are stitched along their edges.
/// `None` when no such pair of faces exists.
/// 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`
/// when no such pair of faces exists.
pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
use mt::{BoundedCurve, ParametricCurve};
let size = a.size().max(b.size());
@@ -460,13 +482,18 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
let (t0, t1) = c.range_tuple();
from_p3(c.subs((t0 + t1) * 0.5))
};
// Matching faces: same plane, opposite normals, the same edges.
// Matching faces: same plane, opposite normals, the same edges. Every pair is glued: a
// pair left behind would stay inside the result as a zero-thickness slit (issue #616).
let (pa, pb) = (planes(a), planes(b));
let fa_list: Vec<&mt::Face> = a.solid.face_iter().collect();
let fb_list: Vec<&mt::Face> = b.solid.face_iter().collect();
// B edge → the matching A edge; B vertex → the matching A vertex.
let mut map: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
let mut vmap: HashMap<mt::VertexID, mt::Vertex> = HashMap::new();
let (mut glued_a, mut glued_b): (Vec<usize>, Vec<usize>) = (Vec::new(), Vec::new());
for (ia, x) in &pa {
for (ib, y) in &pb {
if x.n.dot(y.n) > -1.0 + 1e-9 || (x.d + y.d).abs() > tol {
if glued_a.contains(ia) || glued_b.contains(ib) || x.n.dot(y.n) > -1.0 + 1e-9 || (x.d + y.d).abs() > tol {
continue;
}
let (Some(fa), Some(fb)) = (fa_list.get(*ia), fb_list.get(*ib)) else { continue };
@@ -475,9 +502,7 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
if ea.len() != eb.len() {
continue;
}
// B edge → the matching A edge.
let mut map: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
let mut vmap: HashMap<mt::VertexID, mt::Vertex> = HashMap::new();
let (mut pair_map, mut pair_vmap) = (Vec::new(), Vec::new());
let mut ok = true;
for e in &eb {
let ends = (key(from_p3(e.absolute_front().point())), key(from_p3(e.absolute_back().point())));
@@ -491,71 +516,176 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
break;
};
let same = key(from_p3(f.absolute_front().point())) == ends.0;
map.insert(e.id(), if same { f.absolute_clone() } else { f.absolute_clone().inverse() });
pair_map.push((e.id(), if same { f.absolute_clone() } else { f.absolute_clone().inverse() }));
let (fa_v, fb_v) = if same {
(f.absolute_front().clone(), f.absolute_back().clone())
} else {
(f.absolute_back().clone(), f.absolute_front().clone())
};
vmap.insert(e.absolute_front().id(), fa_v);
vmap.insert(e.absolute_back().id(), fb_v);
pair_vmap.push((e.absolute_front().id(), fa_v));
pair_vmap.push((e.absolute_back().id(), fb_v));
}
if !ok {
continue;
}
return Some(guard("glue", || {
let fail = |m: &str| KernelError::Failed(format!("joining touching faces: {m}"));
// B's other edges, rebuilt on A's vertices where they meet the seam.
let mut edges: HashMap<mt::EdgeID, mt::Edge> = map.clone();
for e in b.solid.edge_iter() {
if edges.contains_key(&e.id()) {
continue;
}
let (f0, f1) = (e.absolute_front(), e.absolute_back());
if !vmap.contains_key(&f0.id()) && !vmap.contains_key(&f1.id()) {
edges.insert(e.id(), e.absolute_clone());
continue;
}
let v0 = vmap.get(&f0.id()).cloned().unwrap_or_else(|| f0.clone());
let v1 = vmap.get(&f1.id()).cloned().unwrap_or_else(|| f1.clone());
edges.insert(e.id(), mt::Edge::new(&v0, &v1, e.curve()));
map.extend(pair_map);
vmap.extend(pair_vmap);
glued_a.push(*ia);
glued_b.push(*ib);
}
}
if glued_a.is_empty() {
return None;
}
Some(guard("glue", || {
let fail = |m: &str| KernelError::Failed(format!("joining touching faces: {m}"));
// B's other edges, rebuilt on A's vertices where they meet a seam.
let mut edges: HashMap<mt::EdgeID, mt::Edge> = map.clone();
for e in b.solid.edge_iter() {
if edges.contains_key(&e.id()) {
continue;
}
let (f0, f1) = (e.absolute_front(), e.absolute_back());
if !vmap.contains_key(&f0.id()) && !vmap.contains_key(&f1.id()) {
edges.insert(e.id(), e.absolute_clone());
continue;
}
let v0 = vmap.get(&f0.id()).cloned().unwrap_or_else(|| f0.clone());
let v1 = vmap.get(&f1.id()).cloned().unwrap_or_else(|| f1.clone());
edges.insert(e.id(), mt::Edge::new(&v0, &v1, e.curve()));
}
let mut faces: Vec<mt::Face> = fa_list.iter().enumerate().filter(|(i, _)| !glued_a.contains(i)).map(|(_, f)| (*f).clone()).collect();
// Every face but the glued ones is kept; an edge along a seam is an edge of both.
let kept = |operand: usize, n: usize, skip: &[usize]| {
(0..n).filter(|i| !skip.contains(i)).map(move |i| record::from_face(operand, i, Change::Kept)).collect::<Vec<_>>()
};
let from: Vec<FaceProv> = kept(0, fa_list.len(), &glued_a).into_iter().chain(kept(1, fb_list.len(), &glued_b)).collect();
let mut origins = record::edge_origins(&a.solid, 0);
for (old, o) in record::edge_origins(&b.solid, 1) {
if let Some(new) = edges.get(&old) {
origins.entry(new.id()).or_default().extend(o);
}
}
for (i, f) in fb_list.iter().enumerate() {
if glued_b.contains(&i) {
continue;
}
let wires: Vec<mt::Wire> = f
.absolute_boundaries()
.iter()
.map(|w| {
w.edge_iter()
.filter_map(|e| edges.get(&e.id()).map(|ne| if e.front() == e.absolute_front() { ne.clone() } else { ne.inverse() }))
.collect::<Vec<_>>()
.into()
})
.collect();
let mut nf = mt::Face::try_new(wires, f.surface()).map_err(|e| fail(&e.to_string()))?;
if !f.orientation() {
nf.invert();
}
faces.push(nf);
}
let solid = Solid::try_new(vec![faces.into()]).map_err(|e| fail(&e.to_string()))?;
// 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]))
}))
}
/// Union of bodies touching back to back where a planar face of one lies wholly inside a
/// coincident face of the other, clear of its edges (a loft or revolve standing on a box top,
/// #396): the inner face goes, and the outer face takes its outline as a hole, sharing its
/// edges. `None` when no such pair of faces exists. The caller checks that the bodies don't
/// also overlap elsewhere.
pub(crate) fn inset(a: &Body, b: &Body) -> Option<Result<Body>> {
inset_into(a, b, 0).or_else(|| inset_into(b, a, 1))
}
/// [`inset`] with a face of `inner` inside one of `outer`, `outer` being operand `outer_operand`.
fn inset_into(outer: &Body, inner: &Body, outer_operand: usize) -> Option<Result<Body>> {
use mt::{BoundedCurve, ParametricCurve};
const SAMPLES: usize = 64;
const MAX_WORK: usize = 4_000_000;
let size = outer.size().max(inner.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
// Clear of the outer face's edges by more than the samples' chord error.
let clear = (size * 2e-3).max(1e-6);
let sample = |w: &mt::Wire| -> Option<Vec<Vec3>> {
if w.len() > 1000 {
return None;
}
let mut pts = Vec::with_capacity(w.len() * SAMPLES);
for e in w.edge_iter() {
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
pts.extend((0..SAMPLES).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / SAMPLES as f64))));
}
(!pts.is_empty() && pts.iter().all(|p| p.is_finite())).then_some(pts)
};
let fo: Vec<&mt::Face> = outer.solid.face_iter().collect();
let fi: Vec<&mt::Face> = inner.solid.face_iter().collect();
for (io, x) in &planes(outer) {
for (ii, y) in &planes(inner) {
if x.n.dot(y.n) > -1.0 + 1e-9 || (x.d + y.d).abs() > tol {
continue;
}
let (Some(face_o), Some(face_i)) = (fo.get(*io), fi.get(*ii)) else { continue };
let outline = match face_i.boundaries().as_slice() {
[w] => w.clone(),
_ => continue,
};
let (Some(inner_pts), Some(outer_loops)) = (sample(&outline), face_o.boundaries().iter().map(sample).collect::<Option<Vec<_>>>()) else {
continue;
};
let outer_count: usize = outer_loops.iter().map(Vec::len).sum();
if inner_pts.len().saturating_mul(outer_count) > MAX_WORK {
continue;
}
let u = x.n.any_perp();
let w = x.n.cross(u);
let flat = |pts: &[Vec3]| {
solidcraft_geom::Loop2::polygon(&pts.iter().map(|p| solidcraft_geom::Vec2::new(p.dot(u), p.dot(w))).collect::<Vec<_>>())
};
let flat_point = |p: Vec3| solidcraft_geom::Vec2::new(p.dot(u), p.dot(w));
let outer_polys: Vec<_> = outer_loops.iter().map(|l| flat(l)).collect();
let inner_poly = flat(&inner_pts);
let in_outer = |p: Vec3| outer_polys.iter().filter(|l| l.contains(flat_point(p))).count() % 2 == 1;
let near_outer =
|p: Vec3| outer_loops.iter().any(|l| l.iter().zip(l.iter().cycle().skip(1)).any(|(s, t)| p.dist_to_segment(*s, *t) < clear));
let fits = inner_pts.iter().all(|p| in_outer(*p) && !near_outer(*p))
&& outer_loops.iter().flatten().all(|p| !inner_poly.contains(flat_point(*p)));
if !fits {
continue;
}
return Some(guard("inset", || {
let fail = |m: &str| KernelError::Failed(format!("joining a face standing inside another: {m}"));
// The outline runs round the inner face's outward normal, the reverse of the
// outer face's: the way a hole in the outer face runs.
let mut wires = face_o.absolute_boundaries().clone();
wires.push(if face_o.orientation() { outline.clone() } else { outline.inverse() });
let mut holed = mt::Face::try_new(wires, face_o.surface()).map_err(|e| fail(&e.to_string()))?;
if !face_o.orientation() {
holed.invert();
}
let mut faces: Vec<mt::Face> = fa_list.iter().enumerate().filter(|(i, _)| i != ia).map(|(_, f)| (*f).clone()).collect();
// Every face but the two glued is kept; an edge along the seam is an edge of both.
let kept = |operand: usize, n: usize, skip: usize| {
(0..n).filter(move |i| *i != skip).map(move |i| record::from_face(operand, i, Change::Kept))
};
let from: Vec<FaceProv> = kept(0, fa_list.len(), *ia).chain(kept(1, fb_list.len(), *ib)).collect();
let mut origins = record::edge_origins(&a.solid, 0);
for (old, o) in record::edge_origins(&b.solid, 1) {
if let Some(new) = edges.get(&old) {
origins.entry(new.id()).or_default().extend(o);
}
let inner_operand = 1 - outer_operand;
let mut faces = Vec::with_capacity(fo.len() + fi.len());
let mut from = Vec::with_capacity(fo.len() + fi.len());
for (i, f) in fo.iter().enumerate() {
let (face, change) = if i == *io { (holed.clone(), Change::Modified) } else { ((*f).clone(), Change::Kept) };
faces.push(face);
from.push(record::from_face(outer_operand, i, change));
}
for (i, f) in fb_list.iter().enumerate() {
if i == *ib {
continue;
}
let wires: Vec<mt::Wire> = f
.absolute_boundaries()
.iter()
.map(|w| {
w.edge_iter()
.filter_map(|e| edges.get(&e.id()).map(|ne| if e.front() == e.absolute_front() { ne.clone() } else { ne.inverse() }))
.collect::<Vec<_>>()
.into()
})
.collect();
let mut nf = mt::Face::try_new(wires, f.surface()).map_err(|e| fail(&e.to_string()))?;
if !f.orientation() {
nf.invert();
}
faces.push(nf);
for (i, f) in fi.iter().enumerate().filter(|(i, _)| i != ii) {
faces.push((*f).clone());
from.push(record::from_face(inner_operand, i, Change::Kept));
}
let solid = Solid::try_new(vec![faces.into()]).map_err(|e| fail(&e.to_string()))?;
// Each edge's origins in the solid's edge order.
let mut origins = record::edge_origins(&outer.solid, outer_operand);
origins.extend(record::edge_origins(&inner.solid, inner_operand));
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 counts = if outer_operand == 0 { record::counts(&[outer, inner]) } else { record::counts(&[inner, outer]) };
crate::boolean_record::heal_build(solid, size, &[], Some(from), origins, &counts)
}));
}
}
+293 -25
View File
@@ -24,6 +24,8 @@ fn unsupported(msg: &str) -> KernelError {
const SAMPLES: usize = 24;
const MAX_SAMPLES: usize = 369;
const MAX_CHAIN_SAMPLES: usize = 100_000;
/// Sections past a slanted end of an open chain.
const EXTENSION: usize = 8;
struct Fit {
surface: mt::Surface,
@@ -72,21 +74,32 @@ impl Guide {
/// One interpolation for both the face and its boundary curves. Fitting the contacts
/// independently gives different end conditions and leaves gaps between the same faces.
fn fit_sections(sections: &[Vec<Section>], k: usize, closed: bool, round: bool) -> Result<Fit> {
use mt::Cut;
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
let n = sections.len();
let list = sections.get(k).ok_or_else(|| fail("section"))?;
let before = usize::from(closed || k > 0) * 2;
let mut ext = Vec::with_capacity(list.len() + 4);
if before > 0 {
let mut before = Vec::new();
if closed || k > 0 {
let prev = sections.get((k + n - 1) % n).ok_or_else(|| fail("section"))?;
ext.extend(prev.iter().rev().skip(1).take(2).rev().copied());
before.extend(prev.iter().rev().skip(1).take(2).rev().copied());
}
ext.extend(list.iter().copied());
let mut after = Vec::new();
if closed || k + 1 < n {
let next = sections.get((k + 1) % n).ok_or_else(|| fail("section"))?;
ext.extend(next.iter().skip(1).take(2).copied());
after.extend(next.iter().skip(1).take(2).copied());
}
fit_strip(list, &before, &after, round)
}
/// [`fit_sections`] with the neighbouring sections given: best spaced as the strip's own, since
/// the interpolation's parameters are uniform.
fn fit_strip(list: &[Section], before: &[Section], after: &[Section], round: bool) -> Result<Fit> {
use mt::Cut;
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
let mut ext = Vec::with_capacity(list.len() + before.len() + after.len());
ext.extend(before.iter().copied());
ext.extend(list.iter().copied());
ext.extend(after.iter().copied());
let before = before.len();
let ctrl: Vec<[mt::Vector4; 3]> = ext.iter().map(|s| section_controls(s, round)).collect::<Option<_>>().ok_or_else(|| fail("section"))?;
let mut rows = Vec::with_capacity(3);
let mut vknots = None;
@@ -161,6 +174,88 @@ fn section_fits(fit: &Fit, section: &Section, along: f64, round: bool, sigma: f6
true
}
/// Where a blend strip run on past a chain end meets the end face: the contacts' parameters
/// there and the end curve from contact 1 to contact 2 (fitted through points where the
/// strip's cross-section curves meet the face's surface, and checked against both).
fn end_cap(fit: &Fit, g: &mt::Face, at: &Section, round: bool, tolerance: f64) -> Result<(f64, f64, mt::Curve)> {
use mt::{ParametricCurve, ParametricSurface, SearchNearestParameter};
let fail = || unsupported("the blend's end on its end face");
let (surf, gs) = (&fit.surface, g.surface());
let mid = if round { at.c + ((at.q1 - at.c) + (at.q2 - at.c)).normalized().ok_or_else(fail)? * at.c.dist(at.q1) } else { (at.q1 + at.q2) * 0.5 };
let (_, v_start) = surf.search_nearest_parameter(p3(mid), None, 100).ok_or_else(fail)?;
// S(u, v) = G(a, b) for v, a, b by Newton from v.
let meet = |u: f64, v: f64| -> Option<f64> {
let mut v = v;
let (mut a, mut b) = gs.search_nearest_parameter(surf.subs(u, v), None, 100)?;
for _ in 0..60 {
let f = from_p3(surf.subs(u, v)) - from_p3(gs.subs(a, b));
if !f.is_finite() {
return None;
}
if f.len() <= tolerance * 1e-3 {
return (-1e-9..=1.0 + 1e-9).contains(&v).then_some(v.clamp(0.0, 1.0));
}
let (sv, ga, gb) = (surf.vder(u, v), gs.uder(a, b), gs.vder(a, b));
let m = [[sv.x, -ga.x, -gb.x], [sv.y, -ga.y, -gb.y], [sv.z, -ga.z, -gb.z]];
let [dv, da, db] = crate::polyhedron::solve3_pub(m, [-f.x, -f.y, -f.z])?;
// Keep each step within the strip's parameters.
let k = if dv.abs() > 0.1 { 0.1 / dv.abs() } else { 1.0 };
v += dv * k;
a += da * k;
b += db * k;
}
None
};
for m in [16usize, 32, 64] {
let mut pts = Vec::with_capacity(m + 1);
let mut vs = Vec::with_capacity(m + 1);
// From the middle out to both contacts, each search starting where the last ended.
let half = m / 2;
let mid_v = meet(0.5_f64.min(half as f64 / m as f64), v_start).ok_or_else(fail)?;
let mut found = vec![None; m + 1];
if let Some(slot) = found.get_mut(half) {
*slot = Some(mid_v);
}
let mut last = mid_v;
for i in (0..half).rev() {
last = meet(i as f64 / m as f64, last).ok_or_else(fail)?;
if let Some(slot) = found.get_mut(i) {
*slot = Some(last);
}
}
last = mid_v;
for i in half + 1..=m {
last = meet(i as f64 / m as f64, last).ok_or_else(fail)?;
if let Some(slot) = found.get_mut(i) {
*slot = Some(last);
}
}
for (i, v) in found.iter().enumerate() {
let v = v.ok_or_else(fail)?;
vs.push(v);
pts.push(from_p3(surf.subs(i as f64 / m as f64, v)));
}
let bs = crate::build::interpolate_cubic(&pts).ok_or_else(fail)?;
let fits = (0..m).all(|i| {
let q = p3(from_p3(bs.subs((i as f64 + 0.5) / m as f64)));
let hint = vs.get(i).map(|v| ((i as f64 + 0.5) / m as f64, *v));
let on_g = gs.search_nearest_parameter(q, None, 100).is_some_and(|(a, b)| from_p3(gs.subs(a, b)).dist(from_p3(q)) <= tolerance);
let on_s = surf.search_nearest_parameter(q, hint, 100).is_some_and(|(u, v)| from_p3(surf.subs(u, v)).dist(from_p3(q)) <= tolerance);
on_g && on_s
});
if fits {
let (Some(t1), Some(t2)) = (vs.first(), vs.last()) else { return Err(fail()) };
// Both contacts are cut there: strictly inside the strip's own part.
let inside = |t: f64| t > fit.range.0 + 1e-6 && t < fit.range.1 - 1e-6;
if !(inside(*t1) && inside(*t2)) {
return Err(too_large("the blend is larger than the neighbouring faces"));
}
return Ok((*t1, *t2, mt::Curve::BSplineCurve(bs)));
}
}
Err(fail())
}
/// One cross-section of the blend: ball centre and its contacts with side 1 and side 2.
#[derive(Clone, Copy, Debug)]
struct Section {
@@ -409,6 +504,35 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
let s1: Vec<&mt::Face> = side1.iter().filter_map(|i| faces.get(*i)).collect();
let s2: Vec<&mt::Face> = side2.iter().filter_map(|i| faces.get(*i)).collect();
// The faces the ball may touch near edges `ks` of the chain: their own, on each side. (The
// surface of another face of a side, such as a wall's plane past the bend it runs into,
// may pass nearer than the face the ball actually rolls on.)
let edge_sides = |ks: &[usize]| -> (Vec<&mt::Face>, Vec<&mt::Face>) {
let pick = |side: &[usize]| {
let mut out: Vec<usize> = Vec::new();
for k in ks {
if let Some(fi) = side.get(*k)
&& !out.contains(fi)
{
out.push(*fi);
}
}
out.iter().filter_map(|i| faces.get(*i)).collect::<Vec<_>>()
};
(pick(&side1), pick(&side2))
};
// The chain's edges at vertex k.
let vertex_edges = |k: usize| -> Vec<usize> {
if closed {
vec![(k + n - 1) % n, k % n]
} else if k == 0 {
vec![0]
} else if k >= n {
vec![n - 1]
} else {
vec![k - 1, k]
}
};
// Convex or concave, from the first edge as its side-1 face runs it.
let e0 = chain.first().ok_or_else(|| KernelError::Failed("chain".into()))?;
let f1 = s1.first().ok_or_else(|| KernelError::Failed("side".into()))?;
@@ -435,6 +559,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let mut seams: Vec<Vec<(usize, mt::Edge)>> = Vec::with_capacity(nv);
// At an open chain's ends: the end face (planar, square to the edge there).
let mut end_faces: Vec<(usize, usize)> = Vec::new(); // (vertex index, face index)
// Chain ends whose end face is not a plane square to the edge: (vertex, face, point, tangent).
let mut slanted: Vec<(usize, usize, Vec3, Vec3)> = Vec::new();
for (k, v) in verts.iter().enumerate() {
let at_end = !closed && (k == 0 || k == nv - 1);
let (k1, k2) = {
@@ -491,19 +617,19 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let here = seams.get(k).cloned().unwrap_or_default();
let at_end = end_faces.iter().any(|(kk, _)| *kk == k);
if at_end {
// The end face must be square to the edge: the blend's end then lies in it.
// A plane square to the edge holds the blend's end section. Any other end face: the
// blend runs on past the end and is cut back where it meets that face.
let (_, fi) = end_faces.iter().find(|(kk, _)| *kk == k).copied().unwrap_or_default();
let g = faces.get(fi).ok_or_else(|| KernelError::Failed("end face".into()))?;
let ng = match g.oriented_surface() {
let square = match g.oriented_surface() {
mt::Surface::Plane(pl) => {
let m = pl.normal();
Vec3::new(m.x, m.y, m.z).normalized()
Vec3::new(m.x, m.y, m.z).normalized().is_some_and(|ng| ng.dot(t).abs() >= 1.0 - 1e-6)
}
_ => None,
}
.ok_or_else(|| unsupported("the faces at the ends of the edges must be planar"))?;
if ng.dot(t).abs() < 1.0 - 1e-6 {
return Err(unsupported("the faces at the ends of the edges must be square to them"));
_ => false,
};
if !square {
slanted.push((k, fi, p, t));
}
}
let extra = match here.first().cloned().filter(|_| !at_end) {
@@ -516,12 +642,19 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
return Err(unsupported("a curved seam where the edge chain turns"));
}
let fs = if side == 1 { &s1 } else { &s2 };
let nn =
fs.iter().filter_map(|f| project(f, p, None)).map(|x| x.1).next().ok_or_else(|| KernelError::Failed("seam normal".into()))?;
// The side's normal at the joint: from its face nearest the joint (any face of a
// longer side is no guide here).
let nn = fs
.iter()
.filter_map(|f| project(f, p, None))
.min_by(|a, b| a.0.dist(p).total_cmp(&b.0.dist(p)))
.map(|x| x.1)
.ok_or_else(|| KernelError::Failed("seam normal".into()))?;
Some((side, o, d.cross(nn).normalized().ok_or_else(|| KernelError::Failed("seam plane".into()))?))
}
};
let s = solve(&s1, &s2, p, t, r, sigma, extra, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?;
let (a1, a2) = edge_sides(&vertex_edges(k));
let s = solve(&a1, &a2, p, t, r, sigma, extra, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?;
// A seam on the other side too must pass through that side's contact.
if let Some((side, seam)) = here.get(1).filter(|_| !at_end) {
let q = if *side == 1 { s.q1 } else { s.q2 };
@@ -532,6 +665,34 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
vsec.push(s);
}
// Past a slanted end: sections of the same rolling ball beyond the end vertex (far to near
// at the chain's start, near to far at its end), far enough to cross the end face.
let mut beyond: Vec<(usize, Vec<Section>)> = Vec::new();
for (k, fi, p, t) in &slanted {
let g = faces.get(*fi).ok_or_else(|| KernelError::Failed("end face".into()))?;
let at = vsec.get(*k).copied().ok_or_else(|| KernelError::Failed("section".into()))?;
let (_, ng, _) = project(g, *p, None).ok_or_else(|| unsupported("the blend's end on its end face"))?;
let cos = ng.dot(*t).abs();
if cos < 0.15 {
return Err(unsupported("an end face almost along the edge"));
}
let width = at.q1.dist(*p).max(at.q2.dist(*p)).max(at.c.dist(*p)) + r;
let reach = 1.5 * width / cos;
let out = if *k == 0 { -*t } else { *t };
let (e1, e2) = edge_sides(&vertex_edges(*k));
let mut list = Vec::with_capacity(EXTENSION);
for j in 1..=EXTENSION {
let anchor = *p + out * (reach * j as f64 / EXTENSION as f64);
list.push(
solve(&e1, &e2, anchor, *t, r, sigma, None, tol * 100.0)
.ok_or_else(|| unsupported("the ball doesn't fit past the end of the edge"))?,
);
}
if *k == 0 {
list.reverse();
}
beyond.push((*k, list));
}
// Uniform refinement preserves a common spacing across neighbouring patches. Work is
// capped: a difficult strip returns an error instead of accepting an inaccurate fit.
let fit_tolerance = (size * 1e-5).clamp(1e-5, 0.001);
@@ -554,7 +715,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let (p, t) = tangent_at(edge, along).ok_or_else(|| KernelError::Failed("tangent".into()))?;
let (start, end) = offsets.get(k).copied().ok_or_else(|| KernelError::Failed("section offset".into()))?;
let anchor = p + t * (start * (1.0 - along) + end * along);
solve(&s1, &s2, anchor, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))
let (a1, a2) = edge_sides(&[k]);
solve(&a1, &a2, anchor, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))
};
let mut samples = SAMPLES.min(max_samples);
let (esec, fits) = loop {
@@ -568,6 +730,13 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
list.push(section_at(k, e, j as f64 / (samples - 1) as f64)?);
}
list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?);
for (kv, ext) in &beyond {
if *kv == k && k == 0 {
list.splice(0..0, ext.iter().copied());
} else if *kv == k + 1 && k + 1 == nv - 1 {
list.extend(ext.iter().copied());
}
}
guides.push(Guide::new(list)?);
}
// Intersection curves inherit their leader's piecewise parameter speed. Fitting
@@ -583,12 +752,41 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
return guide.sections.last().copied().ok_or_else(|| KernelError::Failed("section".into()));
}
let (anchor, tangent) = guide.anchor(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?;
solve(&s1, &s2, anchor, tangent, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine"))
let (a1, a2) = edge_sides(&[k]);
solve(&a1, &a2, anchor, tangent, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine"))
};
let esec: Vec<Vec<Section>> = (0..n)
.map(|k| (0..samples).map(|j| uniform_section(k, j as f64 / (samples - 1) as f64)).collect::<Result<Vec<_>>>())
.collect::<Result<_>>()?;
let fits = crate::guard("fit curve blend", || (0..n).map(|k| fit_sections(&esec, k, closed, round)).collect::<Result<Vec<_>>>())?;
// Each strip's neighbouring sections at its own spacing (edges of different lengths
// have differently spaced samples, which makes a uniform interpolation overshoot).
let length = |k: usize| guides.get(k).and_then(|g| g.lengths.last().copied()).unwrap_or(0.0);
let mut fits = Vec::with_capacity(n);
for k in 0..n {
let h = length(k) / (samples - 1) as f64;
let spaced = |nb: usize, from_end: bool| -> Result<Option<Vec<Section>>> {
let l = length(nb);
if !(l > 2.0 * h && h > 0.0) {
return Ok(None);
}
let mut out = Vec::with_capacity(2);
for j in [1.0, 2.0] {
out.push(uniform_section(nb, if from_end { 1.0 - j * h / l } else { j * h / l })?);
}
if from_end {
out.reverse();
}
Ok(Some(out))
};
let before = if closed || k > 0 { spaced((k + n - 1) % n, true)? } else { Some(Vec::new()) };
let after = if closed || k + 1 < n { spaced((k + 1) % n, false)? } else { Some(Vec::new()) };
let list = esec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?;
let fit = match (before, after) {
(Some(b), Some(a)) => crate::guard("fit curve blend", || fit_strip(list, &b, &a, round))?,
_ => crate::guard("fit curve blend", || fit_sections(&esec, k, closed, round))?,
};
fits.push(fit);
}
let mut accurate = true;
'edges: for k in 0..n {
let fit = fits.get(k).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?;
@@ -611,11 +809,37 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
samples = (2 * samples - 1).min(max_samples);
};
// Where each extended blend meets its slanted end face: (vertex, edge, cut parameters of
// the two contacts, the end curve from contact 1 to contact 2).
let mut caps: Vec<(usize, usize, f64, f64, mt::Curve)> = Vec::new();
for (k, fi, _, _) in &slanted {
let ke = if *k == 0 { 0 } else { n - 1 };
let fit = fits.get(ke).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?;
let g = faces.get(*fi).ok_or_else(|| KernelError::Failed("end face".into()))?;
let at = vsec.get(*k).copied().ok_or_else(|| KernelError::Failed("section".into()))?;
let cap = crate::guard("curve blend end", || end_cap(fit, g, &at, round, fit_tolerance))?;
caps.push((*k, ke, cap.0, cap.1, cap.2));
}
// Both ends of one edge cut back: the contacts must keep a piece between them.
if let [(_, 0, s1, s2, _), (_, 0, e1, e2, _)] = &caps[..]
&& n == 1
&& !(s1 + 1e-6 < *e1 && s2 + 1e-6 < *e2)
{
return Err(too_large("the blend is larger than the neighbouring faces"));
}
crate::guard("curve blend", || {
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
// Vertices and arcs at the chain's vertices.
let v1: Vec<mt::Vertex> = vsec.iter().map(|s| builder::vertex(p3(s.q1))).collect();
let v2: Vec<mt::Vertex> = vsec.iter().map(|s| builder::vertex(p3(s.q2))).collect();
let mut v1: Vec<mt::Vertex> = vsec.iter().map(|s| builder::vertex(p3(s.q1))).collect();
let mut v2: Vec<mt::Vertex> = vsec.iter().map(|s| builder::vertex(p3(s.q2))).collect();
for (k, _, _, _, curve) in &caps {
use mt::{BoundedCurve, ParametricCurve};
let (lo, hi) = curve.range_tuple();
if let (Some(a), Some(b)) = (v1.get_mut(*k), v2.get_mut(*k)) {
*a = builder::vertex(curve.subs(lo));
*b = builder::vertex(curve.subs(hi));
}
}
let mut arcs = Vec::with_capacity(nv);
for (k, s) in vsec.iter().enumerate() {
let (a, b) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?);
@@ -625,6 +849,10 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
} else {
builder::line(a, b)
};
let e = match caps.iter().find(|c| c.0 == k) {
Some((_, _, _, _, curve)) => mt::Edge::try_new(a, b, curve.clone()).map_err(|e| fail(&e.to_string()))?,
None => e,
};
arcs.push(e);
}
// Contact curves and blend faces per edge.
@@ -637,8 +865,24 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let (Some(a1), Some(b1), Some(a2), Some(b2)) = (a1, b1, a2, b2) else { return Err(fail("vertex")) };
let fit = fits.get(k).ok_or_else(|| fail("fit"))?;
let [cv1, cv2] = &fit.contacts;
let e1 = mt::Edge::new(a1, b1, cv1.clone());
let e2 = mt::Edge::new(a2, b2, cv2.clone());
// Contacts past a slanted end stop at the end curve.
let (mut cv1, mut cv2) = (cv1.clone(), cv2.clone());
for (kv, _, t1, t2, _) in caps.iter().filter(|c| c.1 == k) {
use mt::Cut;
if *kv == k + 1 {
let _ = cv1.cut(*t1);
let _ = cv2.cut(*t2);
}
}
for (kv, _, t1, t2, _) in caps.iter().filter(|c| c.1 == k) {
use mt::Cut;
if *kv == k && k == 0 {
cv1 = cv1.cut(*t1);
cv2 = cv2.cut(*t2);
}
}
let e1 = mt::Edge::new(a1, b1, cv1);
let e2 = mt::Edge::new(a2, b2, cv2);
let mut surface = fit.surface.clone();
// Outward: toward the ball centre on a concave edge, away on a convex one.
{
@@ -678,6 +922,30 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
let mut seam_subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
for (id, (seam, a, b)) in ends {
// At a slanted end the blend may stop past the seam's old end: the seam then runs
// on along its line or circle, within its planar face.
let one_end = match (&a, &b) {
(Some(nv), None) => Some((seam.front().clone(), nv.clone())),
(None, Some(nv)) => Some((seam.back().clone(), nv.clone())),
_ => None,
};
if let Some((old, nv)) = one_end
&& slanted.iter().any(|(k, ..)| verts.get(*k) == Some(&old))
{
let on_tol = (size * 1e-5).max(1e-6);
let q = from_p3(nv.point());
if crate::blend::on_edge(&seam, q, on_tol).is_none() {
let fits = faces
.iter()
.filter(|f| f.edge_iter().any(|e| e.id() == id))
.any(|f| crate::blend::plane_normal(f).is_some() && crate::blend::extension_fits(f, from_p3(old.point()), q, tol));
if !fits || !crate::blend::on_extension(&seam, q, on_tol) {
return Err(too_large("the blend is larger than the neighbouring faces"));
}
}
seam_subst.insert(id, crate::blend::retarget(&seam, &old, &nv, on_tol)?);
continue;
}
if !matches!(seam.curve(), mt::Curve::Line(_)) {
seam_subst.insert(id, trim_curved_seam(&seam, a, b, size)?);
continue;
+350
View File
@@ -0,0 +1,350 @@
//! The display mesh of a large body with small radii: bends and holes look round, the mesh
//! stays closed, and the cost stays bounded.
use std::collections::HashMap;
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, Vec2, Vec3};
use super::{Body, DISPLAY_ANGLE};
use crate::{FaceSurface, extrude, face_surface};
/// The chord tolerance the application draws a body with.
fn display_tol(b: &Body) -> f64 {
(b.size() * 1e-3).max(1e-3)
}
fn rect(w: f64, h: f64) -> Loop2 {
Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)])
}
/// A `length` long angle of 2 mm sheet: a 100 mm leg along X, a 90 degree bend of inner
/// radius 1 (outer 3) about the line x = y = 3, and a 40 mm leg along Y, extruded along Z.
fn bent_sheet(length: f64) -> Body {
let line = |a: (f64, f64), b: (f64, f64)| Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) };
let c = Vec2::new(3.0, 3.0);
let outline = Loop2 {
segs: vec![
line((100.0, 0.0), (100.0, 2.0)),
line((100.0, 2.0), (3.0, 2.0)),
Seg2::Arc { center: c, radius: 1.0, start: 1.5 * PI, sweep: -FRAC_PI_2 },
line((2.0, 3.0), (2.0, 40.0)),
line((2.0, 40.0), (0.0, 40.0)),
line((0.0, 40.0), (0.0, 3.0)),
Seg2::Arc { center: c, radius: 3.0, start: PI, sweep: FRAC_PI_2 },
line((3.0, 0.0), (100.0, 0.0)),
],
};
assert!(outline.signed_area() > 0.0);
extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0.0, length).unwrap().pop().unwrap()
}
/// A 3000 x 1000 x 2 plate with round holes of `radius` at `centres`.
fn plate_with_holes(centres: &[Vec2], radius: f64) -> Body {
let holes = centres.iter().map(|c| Loop2::circle(*c, radius).reversed()).collect();
extrude(&Plane::XY, &[Region2 { outer: rect(3000.0, 1000.0), holes }], 0.0, 2.0).unwrap().pop().unwrap()
}
/// The faces of a body on cylinders of this radius.
fn cylinder_faces(b: &Body, radius: f64) -> Vec<(usize, Vec3, Vec3)> {
(0..b.face_count())
.filter_map(|i| match face_surface(b, i)? {
FaceSurface::Cylinder { origin, axis, radius: r } if (r - radius).abs() < 1e-6 => Some((i, origin, axis)),
_ => None,
})
.collect()
}
/// Distance of a point from an axis.
fn radial(p: Vec3, origin: Vec3, axis: Vec3) -> f64 {
let d = p - origin;
(d - axis * d.dot(axis)).len()
}
/// How a face on a cylinder is divided around its axis: the angles (radians, sorted, from the
/// first) at which it has vertices.
fn angles_around(m: &Mesh, face: usize, origin: Vec3, axis: Vec3) -> Vec<f64> {
let u = axis.any_perp();
let v = axis.cross(u);
let mut a: Vec<f64> = m
.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, f)| **f as usize == face)
.flat_map(|(t, _)| t.iter())
.filter_map(|k| m.positions.get(*k as usize))
.map(|p| (*p - origin).dot(v).atan2((*p - origin).dot(u)))
.collect();
a.sort_by(f64::total_cmp);
// Start at the widest gap, so an arc that crosses ±π stays in one piece.
let n = a.len();
let gap = |i: usize| (a[(i + 1) % n] - a[i]).rem_euclid(TAU);
let start = (0..n).max_by(|i, j| gap(*i).total_cmp(&gap(*j))).map_or(0, |i| (i + 1) % n);
let first = a[start];
let mut out: Vec<f64> = (0..n).map(|i| (a[(start + i) % n] - first).rem_euclid(TAU)).collect();
out.dedup_by(|x, y| (*x - *y).abs() < 1e-7);
out
}
/// The most a triangle of `face` lies inside the cylinder it is on (its corners are on it).
fn sag(m: &Mesh, face: usize, origin: Vec3, axis: Vec3, radius: f64) -> f64 {
let mut worst = 0.0f64;
for (t, _) in m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == face) {
let [a, b, c] = m.tri(t).unwrap();
for p in [a, b, c] {
assert!((radial(p, origin, axis) - radius).abs() < 1e-6, "a corner off the cylinder");
}
for p in [(a + b) * 0.5, (b + c) * 0.5, (c + a) * 0.5, (a + b + c) / 3.0] {
worst = worst.max((radius - radial(p, origin, axis)).abs());
}
}
worst
}
/// Triangle sides that are not shared by exactly two triangles once corners closer than `eps`
/// are joined (a closed mesh has none).
fn open_sides(m: &Mesh, eps: f64) -> usize {
let cell = |p: &Vec3| ((p.x / eps).floor() as i64, (p.y / eps).floor() as i64, (p.z / eps).floor() as i64);
let mut grid: HashMap<(i64, i64, i64), Vec<usize>> = HashMap::new();
let mut points: Vec<Vec3> = Vec::new();
let ids: Vec<usize> = m
.positions
.iter()
.map(|p| {
let (cx, cy, cz) = cell(p);
for (dx, dy, dz) in (-1..=1).flat_map(|x| (-1..=1).flat_map(move |y| (-1..=1).map(move |z| (x, y, z)))) {
if let Some(i) = grid.get(&(cx + dx, cy + dy, cz + dz)).and_then(|v| v.iter().find(|i| points[**i].dist(*p) < eps)) {
return *i;
}
}
points.push(*p);
grid.entry((cx, cy, cz)).or_default().push(points.len() - 1);
points.len() - 1
})
.collect();
let mut sides: HashMap<(usize, usize), i32> = HashMap::new();
for t in &m.triangles {
let [a, b, c] = t.map(|k| ids[k as usize]);
// A triangle at a pole has two corners in one point: no area, no sides.
if a == b || b == c || c == a {
continue;
}
for (p, q) in [(a, b), (b, c), (c, a)] {
*sides.entry((p.min(q), p.max(q))).or_default() += 1;
}
}
sides.values().filter(|n| **n != 2).count()
}
/// The polyline segments around a circle of `radius` about `centre` in the plane z = `z`,
/// and the widest angle one of them spans.
fn rim(m: &Mesh, centre: Vec2, radius: f64, z: f64) -> (usize, f64) {
let on = |p: &Vec3| (p.z - z).abs() < 1e-6 && (Vec2::new(p.x, p.y).dist(centre) - radius).abs() < 1e-6;
let (mut segments, mut widest) = (0, 0.0f64);
for e in m.edges.iter().filter(|e| e.len() >= 2 && e.iter().all(on)) {
for w in e.windows(2) {
segments += 1;
widest = widest.max(2.0 * (w[0].dist(w[1]) / (2.0 * radius)).clamp(0.0, 1.0).asin());
}
}
(segments, widest)
}
#[test]
fn a_small_bend_on_a_large_sheet_is_meshed_round() {
let b = bent_sheet(3000.0);
let tol = display_tol(&b);
// The tolerance is larger than either side's sagitta: 1 - cos 45° of the radius.
assert!(tol > 3.0 * (1.0 - (PI / 4.0).cos()));
let coarse = b.display_mesh(tol).unwrap();
let round = b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap();
// A quarter turn in facets of at most DISPLAY_ANGLE.
let facets = (FRAC_PI_2 / DISPLAY_ANGLE).ceil() as usize;
assert_eq!(facets, 5);
for radius in [1.0, 3.0] {
let faces = cylinder_faces(&b, radius);
assert_eq!(faces.len(), 1, "one bend face of radius {radius}");
let (face, origin, axis) = faces[0];
// The chord tolerance alone: one flat strip from tangent line to tangent line.
assert_eq!(angles_around(&coarse, face, origin, axis).len(), 2, "radius {radius}");
assert!(sag(&coarse, face, origin, axis, radius) > 0.29 * radius);
let a = angles_around(&round, face, origin, axis);
assert!(a.len() > facets, "radius {radius}: {} facets across the bend", a.len() - 1);
assert!((a[a.len() - 1] - FRAC_PI_2).abs() < 1e-6, "the bend is a quarter turn: {a:?}");
let widest = a.windows(2).map(|w| w[1] - w[0]).fold(0.0, f64::max);
assert!(widest <= DISPLAY_ANGLE * 1.001, "radius {radius}: a facet spans {} degrees", widest.to_degrees());
// Within the chord tolerance of the cylinder, and by much: a facet that turns no more
// than the angle sags no more than 1 - cos(angle / 2) of the radius.
let s = sag(&round, face, origin, axis, radius);
assert!(s <= radius * (1.0 - (DISPLAY_ANGLE / 2.0).cos()) * 1.001 && s < tol, "radius {radius}: sag {s}");
}
// The flat faces along the sheet meet the bends in straight lines and need nothing more:
// only the bends, and the two ends where their arcs now have more points, gain triangles.
let count = |m: &Mesh, face: usize| m.tri_face.iter().filter(|f| **f as usize == face).count();
let mut along = 0;
for face in 0..b.face_count() {
if let Some(FaceSurface::Plane { normal, .. }) = face_surface(&b, face)
&& normal.dot(Vec3::Z).abs() < 0.5
{
along += 1;
assert_eq!(count(&round, face), count(&coarse, face), "face {face}");
}
}
assert_eq!(along, 6);
}
#[test]
fn the_round_display_mesh_stays_closed_and_keeps_its_edges_and_bounds() {
let b = bent_sheet(3000.0);
let tol = display_tol(&b);
let coarse = b.display_mesh(tol).unwrap();
let round = b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap();
// Neighbouring faces share the points of the edge between them.
assert_eq!(open_sides(&round, 1e-6), 0);
// The same edges in the same order, between the same faces, with the same ends and flags.
assert_eq!(round.edges.len(), coarse.edges.len());
assert_eq!(round.edge_faces, coarse.edge_faces);
assert_eq!((&round.seams, &round.smooth_edges, &round.sharp_edges), (&coarse.seams, &coarse.smooth_edges, &coarse.sharp_edges));
for (fine, rough) in round.edges.iter().zip(&coarse.edges) {
assert!(fine.len() >= rough.len());
assert!(fine[0].dist(rough[0]) < 1e-9 && fine[fine.len() - 1].dist(rough[rough.len() - 1]) < 1e-9);
}
// The body's box: flat faces and tangent lines bound it, so nothing moves.
let (r, c) = (round.bounds(), coarse.bounds());
assert!(r.min.dist(c.min) < 1e-9 && r.max.dist(c.max) < 1e-9);
// Closer to the solid: the flat strips cut the outer bend's corner off and fill the inner one.
let exact = crate::measure(&b).unwrap().volume;
assert!((round.measure().volume - exact).abs() < (coarse.measure().volume - exact).abs());
}
#[test]
fn a_small_hole_on_a_large_plate_has_a_round_rim() {
let centre = Vec2::new(30.0, 20.0);
let b = plate_with_holes(&[centre], 2.0);
let tol = display_tol(&b);
assert!(tol > 2.0, "the hole's radius is below the chord tolerance");
let coarse = b.display_mesh(tol).unwrap();
let round = b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap();
let segments = (TAU / DISPLAY_ANGLE).ceil() as usize;
assert_eq!(segments, 18);
for z in [0.0, 2.0] {
// The chord tolerance alone: each half circle is one segment, the rim a line.
assert_eq!(rim(&coarse, centre, 2.0, z).0, 2);
let (n, widest) = rim(&round, centre, 2.0, z);
assert!(n >= segments, "{n} segments around the rim at z = {z}");
assert!(widest <= DISPLAY_ANGLE * 1.001, "a segment spans {} degrees", widest.to_degrees());
}
// The hole's wall follows its rims.
for (face, origin, axis) in cylinder_faces(&b, 2.0) {
let a = angles_around(&round, face, origin, axis);
assert!(a.windows(2).map(|w| w[1] - w[0]).fold(0.0, f64::max) <= DISPLAY_ANGLE * 1.001);
assert!(sag(&round, face, origin, axis, 2.0) <= 2.0 * (1.0 - (DISPLAY_ANGLE / 2.0).cos()) * 1.001);
}
assert_eq!(open_sides(&round, 1e-6), 0);
}
#[test]
fn an_arc_the_chord_tolerance_already_divides_finely_is_left_alone() {
// A 40 mm cylinder at a tolerance of 0.01 mm: 64 facets around, far inside the angle.
let b = crate::cylinder(Vec3::ZERO, Vec3::Z, 20.0, 30.0).unwrap();
let coarse = b.display_mesh(0.01).unwrap();
let round = b.display_mesh_angular(0.01, DISPLAY_ANGLE).unwrap();
assert_eq!(round.positions, coarse.positions);
assert_eq!(round.triangles, coarse.triangles);
assert_eq!(round.edges, coarse.edges);
// And a body with nothing curved at any tolerance.
let b = crate::box_solid(Vec3::ZERO, Vec3::new(3000.0, 1000.0, 2.0)).unwrap();
let tol = display_tol(&b);
assert_eq!(b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap().positions, b.display_mesh(tol).unwrap().positions);
}
#[test]
fn a_radius_far_below_the_tolerance_keeps_its_chord_facets() {
// A half circle of radius 0.002 in a 3 m plate sags less than a thousandth of the chord
// tolerance: below that floor nothing is divided for the angle, however far the body
// outgrows its smallest radius.
let centre = Vec2::new(30.0, 20.0);
let b = plate_with_holes(&[centre], 0.002);
let tol = display_tol(&b);
let coarse = b.display_mesh(tol).unwrap();
let round = b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap();
assert_eq!(round.triangles.len(), coarse.triangles.len());
assert_eq!(round.edges, coarse.edges);
// Just above the floor the rim is divided, but coarsely: the angle stops where the sag
// falls below the floor, not where the facets reach the angle.
let b = plate_with_holes(&[centre], 0.01);
let round = b.display_mesh_angular(display_tol(&b), DISPLAY_ANGLE).unwrap();
let (n, _) = rim(&round, centre, 0.01, 2.0);
assert!((3..18).contains(&n), "{n} segments");
}
#[test]
fn a_face_crowded_with_holes_gets_a_coarser_angle_and_bounded_work() {
// 1000 holes of diameter 4 in a 3 m plate. At the full angle the top and bottom faces
// would each be bounded by more than 20 000 segments, and the work of triangulating a
// face grows faster than its boundary.
let centres: Vec<Vec2> = (0..50).flat_map(|i| (0..20).map(move |j| Vec2::new(30.0 + 60.0 * i as f64, 25.0 + 50.0 * j as f64))).collect();
let b = plate_with_holes(&centres, 2.0);
let tol = display_tol(&b);
let coarse = b.display_mesh(tol).unwrap();
let round = b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap();
let boundary = |m: &Mesh, z: f64| m.edges.iter().filter(|e| e.iter().all(|p| (p.z - z).abs() < 1e-6)).map(|e| e.len() - 1).sum::<usize>();
let budget = truck_meshalgo::tessellation::Deflection::MAX_ANGLE_BOUNDARY;
for z in [0.0, 2.0] {
assert_eq!(boundary(&coarse, z), 4 + 2 * centres.len());
let n = boundary(&round, z);
assert!(n > boundary(&coarse, z), "the rims are still rounder than the chord tolerance leaves them: {n}");
assert!(n <= budget, "{n} segments bound the face at z = {z}");
}
// Every rim got the same share, and the walls follow their rims.
let per_rim: Vec<usize> = centres.iter().map(|c| rim(&round, *c, 2.0, 2.0).0).collect();
assert!(per_rim.iter().all(|n| *n == per_rim[0] && *n >= 3), "{:?}", &per_rim[..4]);
assert!(
round.triangles.len() < 4 * coarse.triangles.len(),
"{} triangles, {} at the chord tolerance",
round.triangles.len(),
coarse.triangles.len()
);
assert_eq!(open_sides(&round, 1e-6), 0);
// A plate with few holes keeps the full angle.
let few = plate_with_holes(&centres[..40], 2.0);
let round = few.display_mesh_angular(display_tol(&few), DISPLAY_ANGLE).unwrap();
assert!(rim(&round, centres[0], 2.0, 2.0).0 >= 18);
}
#[test]
fn round_faces_of_every_kind_are_divided_by_the_angle() {
// A sphere, a torus and a cone a few millimetres across, drawn at the 3 mm tolerance of a
// body 3 m long: more than any of their sagittas.
let disc = Region2 { outer: Loop2::circle(Vec2::ZERO, 4.0), holes: vec![] };
let bodies = [
("sphere", crate::sphere(Vec3::ZERO, 4.0).unwrap()),
("torus", crate::torus(Vec3::ZERO, 6.0, 2.0).unwrap()),
("cone", crate::extrude_tapered(&Plane::XY, &disc, 6.0, 1.0, -0.3).unwrap()),
];
// The most the surface normal turns along one side of a triangle of a curved face.
let turn = |b: &Body, m: &Mesh| {
let flat: Vec<bool> = (0..b.face_count()).map(|i| matches!(face_surface(b, i), Some(FaceSurface::Plane { .. }))).collect();
m.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, f)| !flat[**f as usize])
.flat_map(|(t, _)| [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])])
.filter(|(a, b)| m.positions[*a as usize].dist(m.positions[*b as usize]) > 1e-9)
.map(|(a, b)| m.normals[a as usize].dot(m.normals[b as usize]).clamp(-1.0, 1.0).acos())
.fold(0.0, f64::max)
};
let check = |name: &str, b: &Body| {
let coarse = b.display_mesh(3.0).unwrap();
let round = b.display_mesh_angular(3.0, DISPLAY_ANGLE).unwrap();
assert!(turn(b, &coarse) > 2.0 * DISPLAY_ANGLE, "{name}: the chord tolerance leaves facets of {} degrees", turn(b, &coarse).to_degrees());
// A side crosses at most one facet in each direction.
assert!(turn(b, &round) <= 1.5 * DISPLAY_ANGLE, "{name}: a side turns {} degrees", turn(b, &round).to_degrees());
assert_eq!(open_sides(&round, 1e-6), 0, "{name}");
// Bounded: no more than a full turn of facets each way on every face.
assert!(round.triangles.len() <= b.face_count() * 2 * 36 * 36, "{name}: {} triangles", round.triangles.len());
};
for (name, b) in &bodies {
check(name, b);
}
}
+166 -49
View File
@@ -199,60 +199,129 @@ fn merge_coplanar(shell: &mt::Shell, tol: f64, keep: &[Vec<Vec3>]) -> Option<(mt
Some((out.into(), from))
}
/// Join pairs of collinear straight edges at vertices only they use.
/// Join runs of collinear straight edges at vertices only they use: each run becomes one edge,
/// every face it borders is rebuilt once (linear in the shell, however long the runs; a faceted
/// body such as a modeled thread has thousands of such edges).
fn merge_collinear(shell: &mt::Shell, trace: &mut Trace) -> Option<mt::Shell> {
let mut faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
let mut changed = false;
for _round in 0..10_000 {
let mut at: HashMap<mt::VertexID, Vec<mt::Edge>> = HashMap::new();
let mut seen = std::collections::HashSet::new();
for f in &faces {
for e in f.edge_iter() {
if seen.insert(e.id()) {
at.entry(e.front().id()).or_default().push(e.absolute_clone());
at.entry(e.back().id()).or_default().push(e.absolute_clone());
}
}
}
let pair = at.iter().find_map(|(v, es)| {
let [a, b] = &es[..] else { return None };
if !matches!(a.curve(), mt::Curve::Line(_)) || !matches!(b.curve(), mt::Curve::Line(_)) {
return None;
}
let other = |e: &mt::Edge| if e.front().id() == *v { e.back().clone() } else { e.front().clone() };
let mid = if a.front().id() == *v { a.front().clone() } else { a.back().clone() };
let (pa, pm, pb) = (from_p3(other(a).point()), from_p3(mid.point()), from_p3(other(b).point()));
let (u, w) = ((pm - pa).normalized()?, (pb - pm).normalized()?);
(u.dot(w) > 1.0 - 1e-10).then(|| (a.clone(), b.clone(), other(a), other(b)))
});
let Some((a, b, va, vb)) = pair else { break };
let joined = builder::line(&va, &vb);
trace.made(&joined, [a.id(), b.id()]);
for f in &mut faces {
if !f.edge_iter().any(|e| e.id() == a.id()) {
let mut edges: Vec<mt::Edge> = Vec::new();
let mut index: HashMap<mt::EdgeID, usize> = HashMap::new();
let mut at: HashMap<mt::VertexID, Vec<usize>> = HashMap::new();
for f in &faces {
for e in f.edge_iter() {
if index.contains_key(&e.id()) {
continue;
}
let mut wires = Vec::new();
for w in f.absolute_boundaries() {
let mut es: Vec<mt::Edge> = Vec::new();
for e in w.edge_iter() {
if e.id() == b.id() {
continue;
}
if e.id() == a.id() {
// a+b (in either order) become the joined edge, in this wire's direction.
es.push(if e.front() == &va { joined.clone() } else { joined.inverse() });
continue;
}
es.push(e.clone());
}
wires.push(mt::Wire::from(es));
}
*f = absolute_face(f, wires)?;
index.insert(e.id(), edges.len());
at.entry(e.front().id()).or_default().push(edges.len());
at.entry(e.back().id()).or_default().push(edges.len());
edges.push(e.absolute_clone());
}
changed = true;
}
changed.then(|| faces.into())
// Edges that continue each other in line through a vertex nothing else uses.
let mut parent: Vec<usize> = (0..edges.len()).collect();
let mut any = false;
for (v, es) in &at {
let [ia, ib] = es[..] else { continue };
let (Some(a), Some(b)) = (edges.get(ia), edges.get(ib)) else { continue };
if !matches!(a.curve(), mt::Curve::Line(_)) || !matches!(b.curve(), mt::Curve::Line(_)) {
continue;
}
let other = |e: &mt::Edge| if e.front().id() == *v { e.back().clone() } else { e.front().clone() };
let mid = if a.front().id() == *v { a.front().clone() } else { a.back().clone() };
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 {
let (ra, rb) = (find(&mut parent, ia), find(&mut parent, ib));
if ra != rb
&& let Some(x) = parent.get_mut(ra)
{
*x = rb;
any = true;
}
}
}
if !any {
return None;
}
let mut runs: HashMap<usize, Vec<usize>> = HashMap::new();
for i in 0..edges.len() {
let r = find(&mut parent, i);
runs.entry(r).or_default().push(i);
}
// Each run's joined edge, and the position of its vertices along it.
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();
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;
}
}
let mut ends = ends.into_values().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()];
let (mut cur, mut prev) = (first.id(), usize::MAX);
for _ in 0..members.len() {
let next = at.get(&cur).and_then(|es| es.iter().copied().find(|i| *i != prev && members.contains(i)));
let Some(e) = next.and_then(|i| edges.get(i).map(|e| (i, e))) else { break };
cur = if e.1.front().id() == cur { e.1.back().id() } else { e.1.front().id() };
prev = e.0;
order.push(cur);
}
if order.last() != Some(&last.id()) || order.len() != members.len() + 1 {
continue;
}
// Every piece must lie along the whole run, not just along its neighbour.
let Some(dir) = (from_p3(last.point()) - from_p3(first.point())).normalized() else { continue };
let straight = members
.iter()
.filter_map(|i| edges.get(*i))
.all(|e| (from_p3(e.back().point()) - from_p3(e.front().point())).normalized().is_some_and(|d| d.dot(dir).abs() > 1.0 - 1e-10));
if !straight {
continue;
}
let line = builder::line(&first, &last);
trace.made_of(&line, members.iter().filter_map(|i| edges.get(*i)).map(|e| e.id()));
for i in members {
if let Some(e) = edges.get(*i) {
run_of.insert(e.id(), joined.len());
}
}
joined.push((line, order.into_iter().enumerate().map(|(k, v)| (v, k)).collect()));
}
if joined.is_empty() {
return None;
}
for f in &mut faces {
if !f.edge_iter().any(|e| run_of.contains_key(&e.id())) {
continue;
}
let mut wires = Vec::new();
for w in f.absolute_boundaries() {
let mut done = std::collections::HashSet::new();
let mut es: Vec<mt::Edge> = Vec::new();
for e in w.edge_iter() {
let Some(r) = run_of.get(&e.id()).copied() else {
es.push(e.clone());
continue;
};
// The run's pieces become its joined edge, in this wire's direction.
if done.insert(r)
&& let Some((line, pos)) = joined.get(r)
{
let forward = pos.get(&e.front().id()) < pos.get(&e.back().id());
es.push(if forward { line.clone() } else { line.inverse() });
}
}
wires.push(mt::Wire::from(es));
}
*f = absolute_face(f, wires)?;
}
Some(faces.into())
}
/// The exact curve an approximate edge (a boolean's intersection curve) really is: a line or a
@@ -504,10 +573,15 @@ impl Trace {
/// `edge` was made from the live edges `from`.
fn made<const N: usize>(&mut self, edge: &mt::Edge, from: [mt::EdgeID; N]) {
self.made_of(edge, from);
}
/// `edge` was made from the live edges `from`, however many.
fn made_of(&mut self, edge: &mt::Edge, from: impl IntoIterator<Item = mt::EdgeID>) {
if !self.on {
return;
}
let mut under: Vec<usize> = from.iter().flat_map(|id| self.of(id)).collect();
let mut under: Vec<usize> = from.into_iter().flat_map(|id| self.of(&id)).collect();
under.sort_unstable();
under.dedup();
self.made.insert(edge.id(), under);
@@ -571,6 +645,49 @@ fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool) -> Op
#[cfg(test)]
mod tests {
use super::*;
use crate::Body;
/// Distinct faces, edges and vertices of a shell (its iterators repeat shared ones).
fn counts(shell: &mt::Shell) -> (usize, usize, usize) {
let edges: std::collections::HashSet<_> = shell.edge_iter().map(|e| e.id()).collect();
let vertices: std::collections::HashSet<_> = shell.vertex_iter().map(|v| v.id()).collect();
(shell.face_iter().count(), edges.len(), vertices.len())
}
/// A prism whose front face was cut into `n` strips along a single line: the strips merge
/// into one face, which leaves `n` collinear pieces on each of its long edges.
fn strip_prism(n: usize) -> Solid {
let pts: Vec<mt::Vertex> = (0..=n)
.map(|k| builder::vertex(mt::Point3::new(k as f64, 0.0, 0.0)))
.chain([builder::vertex(mt::Point3::new(n as f64, 3.0, 0.0)), builder::vertex(mt::Point3::new(0.0, 3.0, 0.0))])
.collect();
let edges: Vec<mt::Edge> = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| builder::line(a, b)).collect();
let face = builder::try_attach_plane(&[mt::Wire::from(edges)]).expect("a plane");
builder::tsweep(&face, mt::Vector3::new(0.0, 0.0, 2.0))
}
/// Runs of collinear edges join in one pass, however long: past the 10 000 pieces the old
/// pairwise loop stopped at, and in time (a modeled thread has thousands of such pieces,
/// and filleting beside one froze the app).
#[test]
fn long_collinear_runs_join_whole() {
let n = 12_000;
let started = std::time::Instant::now();
let healed = heal(strip_prism(n), n as f64);
let shell = healed.boundaries().first().expect("a shell").clone();
assert_eq!(counts(&shell), (6, 12, 8));
let body = Body::new(healed).expect("a valid body");
let volume = crate::measure::measure(&body).expect("measures").volume;
assert!((volume - n as f64 * 3.0 * 2.0).abs() < 1e-6 * volume, "{volume}");
assert!(started.elapsed().as_secs() < 30, "took {:?}", started.elapsed());
}
#[test]
fn short_collinear_runs_join_and_keep_the_corners() {
let healed = heal(strip_prism(3), 3.0);
let shell = healed.boundaries().first().expect("a shell").clone();
assert_eq!(counts(&shell), (6, 12, 8));
}
#[test]
fn approximate_arcs_become_exact() {
+15 -7
View File
@@ -6,6 +6,14 @@
//! the operation's own operand list, which each operation documents. A body made by an
//! operation carries its history (see [`Body::history`]); [`History::substitute`] composes an
//! operation with one that made one of its operands.
//!
//! This is what persistent names and face attachments stand on. The document composes each
//! feature's histories into face names (`F5:side:l1`, `F7:blend:…`, `F4:[email protected]`, see
//! `solidcraft_doc::history_names`); a name is exact only when every operation on the way
//! reported its history. A flat face with an exact name takes a durable sketch attachment by that
//! name (`FaceCertificate::Lineage`, docs/face-attachments.md), so an operation that records its
//! history here gives its faces attachments with no attachment code of its own. An operation
//! that can't should leave its result without one (`Body::without_history`), never guess.
use std::collections::BTreeSet;
@@ -13,7 +21,7 @@ use crate::body::{Body, FaceSource, Solid};
use crate::{KernelError, Result};
/// Where a result face came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
pub enum FaceOrigin {
/// A face of input `operand` (its index in the operand's face order).
Face { operand: usize, face: usize },
@@ -28,7 +36,7 @@ pub enum FaceOrigin {
}
/// How a result face relates to its origins.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Change {
/// The same face as its origin (same region).
Kept,
@@ -42,7 +50,7 @@ pub enum Change {
Generated,
}
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FaceProv {
pub origins: Vec<FaceOrigin>,
pub change: Change,
@@ -51,7 +59,7 @@ pub struct FaceProv {
/// One side of a meeting: the origins of the face on that side of the edge. One origin, or
/// several where that face was made from several (a merged face) and the edge lies along it
/// as a whole. Never empty; sorted, without repeats, so equal sides compare equal.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
pub struct Side(Vec<FaceOrigin>);
impl Side {
@@ -99,7 +107,7 @@ impl From<FaceOrigin> for Side {
}
/// Where a result edge came from.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
pub enum EdgeOrigin {
/// (Part of) edge `edge` of input `operand`.
Edge { operand: usize, edge: usize },
@@ -110,14 +118,14 @@ pub enum EdgeOrigin {
/// A result edge: the result faces it separates and what it came from (empty when it is new
/// and lies between faces new to the operation).
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct EdgeProv {
pub faces: Vec<usize>,
pub origins: Vec<EdgeOrigin>,
}
/// History of an operation's result: one entry per face and per edge, in the result's orders.
#[derive(Clone, Debug, Default, PartialEq)]
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct History {
/// How many operands the operation had.
pub operands: usize,
+185
View File
@@ -0,0 +1,185 @@
//! Issue #616: small boxes joined flush to the walls and floor of a part never change it away
//! from the box, and a retry that would is refused.
use solidcraft_geom::{Aabb3, Mesh, Vec3};
use crate::*;
fn bx(c: [f64; 3], s: [f64; 3]) -> Body {
box_solid(Vec3::new(c[0], c[1], c[2]), Vec3::new(c[0] + s[0], c[1] + s[1], c[2] + s[2])).unwrap()
}
fn volume(b: &Body) -> f64 {
measure(b).unwrap().volume
}
/// The lid of the issue (public dimensions): a plate with rounded corners, a skirt ring on
/// it, a window and a slot cut through, and a notch cut in the skirt.
fn lid() -> Body {
let plate = bx([100., 0., 0.], [76.8, 89.3, 2.4]);
let corners: Vec<Vec3> = [[100., 0.], [176.8, 0.], [100., 89.3], [176.8, 89.3]].iter().map(|p| Vec3::new(p[0], p[1], 1.2)).collect();
let plate = fillet(&plate, &corners, 3.0).unwrap();
let ring = boolean(&bx([102.65, 2.65, 2.4], [71.5, 84., 3.]), &bx([104.15, 4.15, 2.0], [68.5, 81., 4.]), BoolOp::Cut).unwrap().unwrap();
let mut b = boolean(&plate, &ring, BoolOp::Union).unwrap().unwrap();
for (c, s) in [([104.4, 5.4, -1.], [68., 61., 8.]), ([116.4, 72., -1.], [44., 10., 8.]), ([127.4, 84., 2.4], [22., 6., 4.])] {
b = boolean(&b, &bx(c, s), BoolOp::Cut).unwrap().unwrap();
}
b
}
/// Distance from `p` to the triangle `abc` (brute force, independent of the guard's grid).
fn tri_dist(p: Vec3, [a, b, c]: [Vec3; 3]) -> f64 {
let n = (b - a).cross(c - a);
let Some(u) = n.normalized() else { return f64::INFINITY };
let h = (p - a).dot(u);
let q = p - u * h;
if [(a, b), (b, c), (c, a)].iter().all(|(x, y)| (*y - *x).cross(q - *x).dot(n) >= 0.0) {
h.abs()
} else {
[(a, b), (b, c), (c, a)].iter().map(|(x, y)| p.dist_to_segment(*x, *y)).fold(f64::INFINITY, f64::min)
}
}
/// A point of `from`'s surface outside `region` that is not on `onto`'s surface.
fn moved_outside(from: &Mesh, onto: &Mesh, region: &Aabb3, tol: f64) -> Option<Vec3> {
let tris: Vec<[Vec3; 3]> = onto.triangles.iter().filter_map(|t| onto.tri(t)).collect();
from.triangles.iter().filter_map(|t| from.tri(t)).find_map(|[a, b, c]| {
let p = (a + b + c) / 3.0;
let inside = p.x > region.min.x && p.y > region.min.y && p.z > region.min.z && p.x < region.max.x && p.y < region.max.y && p.z < region.max.z;
(!inside && !tris.iter().any(|t| tri_dist(p, *t) < tol)).then_some(p)
})
}
/// The join of `tool` onto `target` left `target` as it was away from the tool: no new surface
/// (a slit, a moved face) and none lost, outside the tool's box.
fn assert_local(target: &Body, tool: &Body, joined: &Body, what: &str) {
let tol = 1e-3;
let (mt, mtool, mj) = (target.tessellate(0.01).unwrap(), tool.tessellate(0.01).unwrap(), joined.tessellate(0.01).unwrap());
let mut region = mtool.bounds();
region.min -= Vec3::new(0.01, 0.01, 0.01);
region.max += Vec3::new(0.01, 0.01, 0.01);
assert_eq!(moved_outside(&mj, &mt, &region, tol), None, "{what}: new surface away from the tool");
assert_eq!(moved_outside(&mt, &mj, &region, tol), None, "{what}: surface lost away from the tool");
}
/// The four ribs of the issue, 1.6 × 6 × 11.7, sit on the plate (z = 2.4), over the skirt, flush
/// with the window's sides. Each join adds exactly what lies outside the skirt, changes nothing
/// else (it used to leave a slit through the far wall), and needs no displacement of the tool.
#[test]
fn ribs_joined_flush_to_the_lid_change_nothing_else() {
let mut b = lid();
assert!((volume(&b) - 6703.5222).abs() < 1e-3, "{}", volume(&b));
for c in [[102.8, 14., 2.4], [102.8, 48., 2.4], [172.4, 14., 2.4], [172.4, 48., 2.4]] {
let tool = bx(c, [1.6, 6., 11.7]);
let (r, report) = capture_boolean_diagnostics(|| boolean(&b, &tool, BoolOp::Union));
let r = r.unwrap().unwrap();
// 1.6 × 6 × 11.7 less the 1.35 × 6 × 3 inside the skirt.
assert!((volume(&r) - volume(&b) - 88.02).abs() < 1e-6, "{c:?}: {}", volume(&r) - volume(&b));
assert!(!report.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{c:?}: {report:?}");
assert_eq!(r.solid.boundaries().len(), 1);
assert_local(&b, &tool, &r, &format!("{c:?}"));
b = r;
}
let bounds = b.tessellate(0.01).unwrap().bounds();
assert!((bounds.max.z - 14.1).abs() < 1e-9, "the ribs keep their height: {}", bounds.max.z);
}
/// A plane cut through a frame meets it in two faces; gluing the halves back joins both (one
/// left over was the slit of #616).
#[test]
fn glue_joins_every_face_the_pieces_share() {
let frame = boolean(&bx([0., 0., 0.], [40., 30., 5.]), &bx([5., 5., -1.], [30., 20., 7.]), BoolOp::Cut).unwrap().unwrap();
let low = boolean(&frame, &bx([-1., -1., -1.], [42., 16., 7.]), BoolOp::Intersect).unwrap().unwrap();
let high = boolean(&frame, &bx([-1., 15., -1.], [42., 16., 7.]), BoolOp::Intersect).unwrap().unwrap();
let glued = crate::coplanar::glue(&low, &high).unwrap().unwrap();
assert_eq!(glued.solid.boundaries().len(), 1);
assert!((volume(&glued) - volume(&frame)).abs() < 1e-6);
assert!((measure(&glued).unwrap().area - measure(&frame).unwrap().area).abs() < 1e-6, "no faces left inside");
}
/// The guard refuses a result that changed the target away from the tool, or lost volume in a
/// join, and accepts the right one.
#[test]
fn the_retry_guard_refuses_changes_away_from_the_tool() {
let target = bx([0., 0., 0.], [40., 30., 5.]);
let tool = bx([0., 0., 5.], [4., 4., 4.]);
let right = boolean(&target, &tool, BoolOp::Union).unwrap().unwrap();
assert!(crate::boolean_guard::check_retry(&target, &tool, BoolOp::Union, Some(&right)).is_ok());
// A notch far from the tool (volume still up: the tool adds more than the notch takes).
let notched = boolean(&right, &bx([30., 10., 4.], [2., 2., 2.]), BoolOp::Cut).unwrap().unwrap();
let e = crate::boolean_guard::check_retry(&target, &tool, BoolOp::Union, Some(&notched)).unwrap_err().to_string();
assert!(e.contains("away from the tool") && e.contains("refused"), "{e}");
// A join that lost volume.
let e = crate::boolean_guard::check_retry(&target, &tool, BoolOp::Union, Some(&bx([0., 0., 0.], [40., 30., 4.]))).unwrap_err().to_string();
assert!(e.contains("volume"), "{e}");
// A cut that comes out empty while the tool covers only part of the target.
assert!(crate::boolean_guard::check_retry(&target, &tool, BoolOp::Cut, None).is_err());
assert!(crate::boolean_guard::check_retry(&tool, &target, BoolOp::Cut, None).is_err());
assert!(crate::boolean_guard::check_retry(&tool, &bx([-1., -1., 4.], [6., 6., 6.]), BoolOp::Cut, None).is_ok());
}
/// Random small boxes joined flush to the walls and floor of a shelled box with rounded corners
/// (sitting on the floor, against a wall, flush with a wall's top or into its corner), and of
/// the same box with a window through its floor (on the ledge beside the window, flush with its
/// side, as the ribs of #616), never lose volume and never change the part away from the box. A
/// join that fails is an error, never a wrong result.
#[test]
fn boxes_joined_flush_to_a_shelled_box_stay_local() {
let (w, d, h, t) = (60.0, 40.0, 20.0, 2.0);
let outer = bx([0., 0., 0.], [w, d, h]);
let corners: Vec<Vec3> = [[0., 0.], [w, 0.], [0., d], [w, d]].iter().map(|p| Vec3::new(p[0], p[1], h / 2.0)).collect();
let outer = fillet(&outer, &corners, 3.0).unwrap();
let shelled = boolean(&outer, &bx([t, t, t], [w - 2. * t, d - 2. * t, h]), BoolOp::Cut).unwrap().unwrap();
// The window leaves a 1.5 ledge inside the walls.
let (ledge, gap) = (t + 1.5, 6.0);
let framed = boolean(&shelled, &bx([ledge, gap, -1.], [w - 2. * ledge, d - 2. * gap, t + 2.]), BoolOp::Cut).unwrap().unwrap();
let mut seed = 0x616_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)
};
// Sizes and places on a 0.1 grid, like typed values.
let q = |x: f64| (x * 10.0).round() / 10.0;
let (mut joined, mut refused) = (0, 0);
for (name, target) in [("shelled", &shelled), ("framed", &framed)] {
let base = volume(target);
for case in 0..20 {
let s = [q(next(1.0, 6.0)), q(next(1.0, 6.0)), q(next(1.0, 12.0))];
let (x, y) = (q(next(t + 1.0, w - t - 8.0)), q(next(gap, d - gap - 8.0)));
let c = match (name, case % 4) {
// On the floor.
("shelled", 0) => [x, y, t],
// On the floor against the -x wall.
("shelled", 1) => [t, y, t],
// Into the +y wall, flush with its top, overlapping it.
("shelled", 2) => [x, d - t - s[1] / 2.0, h - s[2]],
// On the floor in the corner of the -x and -y walls, sunk into both.
("shelled", _) => [t - 0.5, t - 0.5, t],
// On the ledge over the wall, flush with the window's -x or +x side.
(_, 0) => [ledge - 1.6, y, t],
(_, 1) => [w - ledge, y, t],
// On the ledge against the -x wall, short of the window.
(_, 2) => [t, y, t],
// Into the +x wall from the ledge, flush with the window's side.
_ => [w - ledge, y, t],
};
let s = if name == "framed" && case % 4 != 2 { [1.6, s[1], s[2]] } else { s };
let tool = bx(c, s);
let what = format!("{name} case {case}: corner {c:?} size {s:?}");
match boolean(target, &tool, BoolOp::Union) {
Ok(Some(r)) => {
assert!(volume(&r) >= base - 1e-6, "{what}: the join lost volume ({} < {base})", volume(&r));
assert!(volume(&r) <= base + volume(&tool) + 1e-6, "{what}");
assert_local(target, &tool, &r, &what);
joined += 1;
}
Ok(None) => panic!("{what}: a join came out empty"),
Err(e) => {
eprintln!("{what}: refused: {e}");
refused += 1;
}
}
}
}
assert_eq!((joined, refused), (40, 0), "every one of these joins works");
}
+7 -2
View File
@@ -17,6 +17,7 @@ mod blend_corner;
mod blend_variable;
mod body;
mod boolean_diagnostics;
mod boolean_guard;
mod boolean_record;
mod bore_guard;
mod build;
@@ -36,6 +37,8 @@ mod iges_in;
mod iges_out;
#[cfg(test)]
mod intersection_curve_tests;
#[cfg(test)]
mod issue_616_tests;
mod lip;
mod loopblend;
mod measure;
@@ -43,6 +46,7 @@ mod mesh_convert;
mod meshbody;
#[cfg(test)]
mod modify_history_tests;
mod native;
mod numbered;
mod offset;
mod ops;
@@ -69,10 +73,10 @@ mod thread_tests;
mod topo;
mod tspline;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_max};
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 body::{Body, CapSignature, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
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};
pub use delete_face::delete_faces;
@@ -84,6 +88,7 @@ pub use lip::raise_band;
pub use measure::{BodyMeasure, measure};
pub use mesh_convert::convert_prismatic_mesh;
pub use meshbody::{MAX_TRIANGLES, mesh_body};
pub use native::{decode_native_body, encode_native_body, native_preview_mesh};
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};
+307
View File
@@ -0,0 +1,307 @@
//! SolidCraft native, versioned binary geometry. No STEP conversion or display meshes.
//! A cache remains provisional until the document is rebuilt; hashes are not trust.
use crate::{Body, KernelError, Result, guard};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Number, Value};
use std::sync::Arc;
mod adapter;
mod binary;
mod metadata;
mod preview;
pub use preview::native_preview_mesh;
// 01/02 were development-only prototypes; independent metadata records start at 03.
const MAGIC: &[u8; 8] = b"SCBREP03";
const MAX_BYTES: usize = 64 << 20;
const MAX_ITEMS: usize = 100_000;
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct SavedBody {
solid: solidcraft_geom::brep::Brep,
metadata: solidcraft_geom::brep::envelope::Metadata,
}
fn invalid(detail: impl std::fmt::Display) -> KernelError {
KernelError::Invalid(format!("native B-rep: {detail}"))
}
/// Explicit topology, spatial geometry, construction certificates and history.
/// Procedural intersections use bounded fitting for provisional cache display.
/// Failure is a cache miss at the IO boundary, never a failed document save.
pub fn encode_native_body(body: &Body) -> Result<Vec<u8>> {
guard("encode native B-rep", || {
for source in body.face_sources.iter().flat_map(|s| s.iter()).flatten() {
let mut at = source;
let mut depth = 0usize;
while let crate::FaceSource::ShellPlanar { source, .. } = at {
depth += 1;
if depth > 32 {
return Err(invalid("face-source nesting"));
}
at = source;
}
}
if body.face_count() > MAX_ITEMS
|| body.solid.edge_iter().take(MAX_ITEMS + 1).count() > MAX_ITEMS
|| body.mesh.as_ref().is_some_and(|m| m.positions.len() > MAX_ITEMS || m.triangles.len() > MAX_ITEMS)
{
return Err(invalid("native geometry budget"));
}
if body.mesh.is_some() {
return Err(invalid("mesh bodies are source blobs, not B-rep"));
}
let saved = SavedBody { solid: adapter::encode(&body.solid)?, metadata: metadata::encode(body)? };
// Structural validation does not invoke another geometric reconstruction.
validate_topology(&saved)?;
let value = serde_json::to_value(saved).map_err(invalid)?;
validate_value(&value, 0)?;
if value_nodes(&value) > 2_000_000 {
return Err(invalid("value budget"));
}
let mut out = MAGIC.to_vec();
binary::write(&value, &mut out, 0)?;
Ok(out)
})
}
/// Decode bounded untrusted data. Validate indices before topology extraction.
/// Callers must not treat the decoded body as authoritative editing geometry.
pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
if bytes.len() > MAX_BYTES || bytes.get(..8) != Some(MAGIC.as_slice()) {
return Err(invalid("version or size"));
}
guard("decode native B-rep", || {
let mut input = bytes.get(8..).ok_or_else(|| invalid("header"))?;
let mut remaining = 2_000_000usize;
let value = binary::read(&mut input, 0, &mut remaining)?;
if !input.is_empty() {
return Err(invalid("trailing data"));
}
validate_value(&value, 0)?;
let saved: SavedBody = serde_json::from_value(value).map_err(invalid)?;
validate_topology(&saved)?;
let solid = adapter::decode(saved.solid)?;
let mut body = Body {
solid: Arc::new(solid),
coarse_missing: None,
mesh: None,
color: None,
paint: None,
splits: None,
face_sources: None,
history: None,
regularization_protected: false,
};
metadata::apply(&mut body, saved.metadata)?;
Ok(body)
})
}
fn validate_topology(saved: &SavedBody) -> Result<()> {
saved.solid.validate().map_err(invalid)?;
let faces: usize = saved.solid.shells.iter().map(|s| s.faces.len()).sum();
let edges: usize = saved.solid.shells.iter().map(|s| s.edges.len()).sum();
let saved = &saved.metadata;
if saved.version != solidcraft_geom::brep::envelope::VERSION {
return Err(invalid("metadata version"));
}
if saved.face_sources.as_ref().is_some_and(|s| s.len() != faces) {
return Err(invalid("face source count"));
}
if let Some(history) = &saved.history
&& (history.faces.len() != faces
|| history.edges.len() != edges
|| history.operands as usize > MAX_ITEMS
|| history.edges.iter().flat_map(|e| &e.faces).any(|&f| f as usize >= faces))
{
return Err(invalid("history topology"));
}
if saved.color.is_some_and(|c| c.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v))) {
return Err(invalid("colour"));
}
if let Some(paint) = &saved.paint
&& (!paint.opacity.is_finite()
|| !(0.0..=1.0).contains(&paint.opacity)
|| paint.faces.iter().any(|p| {
p.face as usize >= faces
|| !p.opacity.is_finite()
|| !(0.0..=1.0).contains(&p.opacity)
|| p.color.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v))
}))
{
return Err(invalid("face paint"));
}
Ok(())
}
// The binary reader bounds allocation before deserialization. Geometry semantics
// are checked by the independent schema validator, not by backend serde internals.
fn validate_value(v: &Value, depth: usize) -> Result<()> {
if depth > 48 {
return Err(invalid("nesting"));
}
match v {
Value::String(s) if s.len() > 1 << 20 => return Err(invalid("string budget")),
Value::Number(n) if n.is_f64() && n.as_f64().is_none_or(|x| !x.is_finite() || x.abs() > 1e12) => return Err(invalid("non-finite metadata")),
Value::Array(a) => {
if a.len() > MAX_ITEMS {
return Err(invalid("array budget"));
}
for x in a {
validate_value(x, depth + 1)?;
}
}
Value::Object(o) => {
for x in o.values() {
validate_value(x, depth + 1)?;
}
}
_ => {}
}
Ok(())
}
fn value_nodes(v: &Value) -> usize {
match v {
Value::Array(a) => a.iter().fold(1usize, |n, v| n.saturating_add(value_nodes(v))),
Value::Object(o) => o.values().fold(1usize, |n, v| n.saturating_add(value_nodes(v))),
_ => 1,
}
}
#[cfg(test)]
mod tests {
use super::*;
use solidcraft_geom::Vec3;
fn equal(a: &Body, b: &Body) {
assert_eq!(a.face_count(), b.face_count());
assert_eq!(a.face_sources, b.face_sources);
assert_eq!(a.history, b.history);
assert_eq!(a.paint, b.paint);
assert_eq!(a.color, b.color);
assert_eq!(a.splits, b.splits);
assert_eq!(a.regularization_protected, b.regularization_protected);
assert_eq!(encode_native_body(a).unwrap(), encode_native_body(b).unwrap());
let am = crate::measure(a).unwrap();
let bm = crate::measure(b).unwrap();
assert!((am.volume - bm.volume).abs() < 1e-7);
}
#[test]
fn native_solids_keep_geometry_metadata_and_later_modelling() {
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
let rounded = crate::fillet(&cube, &[Vec3::new(20., 0., 10.)], 2.).unwrap();
let bore = crate::cylinder(Vec3::new(10., 10., -1.), Vec3::Z, 3., 12.).unwrap();
let cut = crate::boolean(&rounded, &bore, crate::BoolOp::Cut).unwrap().unwrap();
for body in [cube, rounded, cut] {
let body = body.with_color(Some([0.1, 0.5, 0.9]));
let bytes = encode_native_body(&body).unwrap();
assert!(bytes.starts_with(MAGIC));
let decoded = decode_native_body(&bytes).unwrap();
equal(&body, &decoded);
let a = crate::boolean(&body, &bore, crate::BoolOp::Cut).unwrap().unwrap();
let b = crate::boolean(&decoded, &bore, crate::BoolOp::Cut).unwrap().unwrap();
assert!((crate::measure(&a).unwrap().volume - crate::measure(&b).unwrap().volume).abs() < 1e-7);
}
}
#[test]
fn native_spheres_tori_and_open_faces_round_trip_without_conversion() {
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
let surface = crate::copy_faces(&cube, &[Vec3::new(20., 15., 10.)]).unwrap();
let sphere = crate::sphere(Vec3::ZERO, 12.).unwrap();
let torus = crate::torus(Vec3::ZERO, 20., 4.).unwrap();
for body in [surface, sphere, torus] {
let encoded = encode_native_body(&body).unwrap();
let restored = decode_native_body(&encoded).unwrap();
assert_eq!(body.is_surface(), restored.is_surface());
assert_eq!(body.face_count(), restored.face_count());
assert_eq!(encoded, encode_native_body(&restored).unwrap());
if !body.is_surface() {
equal(&body, &restored);
}
}
}
#[test]
fn box_and_fillet_have_stable_wire_records() {
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
let rounded = crate::fillet(&cube, &[Vec3::new(20., 0., 10.)], 2.).unwrap();
let records: Vec<_> = [cube, rounded]
.iter()
.map(|body| {
let bytes = encode_native_body(body).unwrap();
assert_eq!(&bytes[..8], b"SCBREP03");
let mut raw = &bytes[8..];
let value = binary::read(&mut raw, 0, &mut 2_000_000).unwrap();
assert!(raw.is_empty());
value
})
.collect();
let actual = serde_json::json!(records);
let expected: Value = serde_json::from_str(include_str!("native/fixtures/box-fillet-v3.json")).unwrap();
compare_wire(&expected, &actual);
}
fn compare_wire(expected: &Value, actual: &Value) {
match (expected, actual) {
(Value::Number(a), Value::Number(b)) if a.is_f64() && b.is_f64() => {
let (a, b) = (a.as_f64().unwrap(), b.as_f64().unwrap());
assert!((a - b).abs() <= 1e-10 * a.abs().max(1.), "wire number changed: {a} vs {b}");
}
(Value::Array(a), Value::Array(b)) => {
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b) {
compare_wire(a, b);
}
}
(Value::Object(a), Value::Object(b)) => {
assert_eq!(a.keys().collect::<Vec<_>>(), b.keys().collect::<Vec<_>>());
for (key, a) in a {
compare_wire(a, &b[key]);
}
}
_ => assert_eq!(expected, actual),
}
}
#[test]
fn malformed_native_geometry_is_bounded_and_never_panics() {
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
let bytes = encode_native_body(&body).unwrap();
for n in [0, 1, 7, 8, 9, bytes.len() - 1] {
assert!(decode_native_body(&bytes[..n]).is_err());
}
for i in (0..bytes.len()).step_by((bytes.len() / 256).max(1)) {
let mut bad = bytes.clone();
bad[i] ^= 0xff;
assert!(std::panic::catch_unwind(|| decode_native_body(&bad)).is_ok());
}
let mut raw = &bytes[8..];
let value = binary::read(&mut raw, 0, &mut 2_000_000).unwrap();
for key in ["vertices", "index"] {
let mut v = value.clone();
if key == "vertices" {
v["solid"]["shells"][0]["edges"][0]["vertices"] = serde_json::json!([u64::MAX, 0]);
} else {
v["solid"]["shells"][0]["faces"][0]["loops"][0][0]["edge"] = serde_json::json!(u64::MAX);
}
let mut bad = MAGIC.to_vec();
binary::write(&v, &mut bad, 0).unwrap();
assert!(decode_native_body(&bad).is_err());
}
let mut huge = MAGIC.to_vec();
huge.push(7);
huge.extend(u32::MAX.to_le_bytes());
assert!(decode_native_body(&huge).is_err());
}
#[test]
fn native_preserves_face_appearance_and_authored_boundaries() {
let mut body = crate::box_solid(Vec3::new(1., 2., 3.), Vec3::new(10., 20., 30.))
.unwrap()
.with_paint(Some(crate::Paint { opacity: 0.7, faces: vec![crate::FacePaint { face: 2, color: [0.25, 0.5, 0.75], opacity: 0.6 }] }));
body.splits = Some(Arc::new(vec![vec![Vec3::new(1., 2., 3.), Vec3::new(11., 2., 3.)]]));
body.regularization_protected = true;
let restored = decode_native_body(&encode_native_body(&body).unwrap()).unwrap();
equal(&body, &restored);
}
}
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more