mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 11:05:56 +00:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 200a83a629 | |||
| d302aa1a00 | |||
| 9184994721 | |||
| d2676da789 | |||
| 6a6a7e2865 | |||
| 3ea8562c5e | |||
| e540832626 | |||
| 8dd583930e | |||
| 9bb18842e9 | |||
| 9cabc3532d | |||
| bdcfe3ff1d | |||
| dbfb689f6c | |||
| 17b4b9a32c | |||
| c8d668108a | |||
| dd084ec9e1 | |||
| bfbf5f9432 | |||
| facff1050e | |||
| de1479029d | |||
| 16766bdbc7 | |||
| d4fd760b0d | |||
| d437c21368 | |||
| a8b3b21fd0 | |||
| be55a49b04 | |||
| 293d660d6c | |||
| beca7eb126 | |||
| 1a0100cdd9 | |||
| 5267c96ab2 | |||
| 159f9913c1 | |||
| dfa9e094cd | |||
| 8cdefd4cd5 | |||
| 7b0a1b296c | |||
| 25a3dc501b | |||
| a3dfbaa0b0 | |||
| 58af8accfa | |||
| 4a989f6e2d | |||
| aae70053ee | |||
| 9b5e39844a | |||
| 1d30e4e1d2 | |||
| 1a5e961362 | |||
| ff609bc320 | |||
| 62a7f903f2 | |||
| 11c03fb0ab | |||
| 6f4a9d96fb | |||
| 4112189551 | |||
| 2bb24d1d2a | |||
| a1cb8d4d36 | |||
| a513304917 | |||
| d7f5b2435e | |||
| ce5762d873 | |||
| 831fa34752 | |||
| 11084a5963 | |||
| 2c4d197bf4 | |||
| 4ff8f3e14d | |||
| eac4d66adc | |||
| cb071bb93b | |||
| 0e500cacd3 | |||
| 1e4387f3ea | |||
| 9ba72e118b | |||
| add4a18961 | |||
| 17e42e7b94 | |||
| 812669e861 | |||
| c166c64b8e | |||
| 0b1e61504d | |||
| 7249623855 | |||
| 7b34d57b25 | |||
| aa24d9f071 | |||
| 319d360c85 | |||
| e10b0b7d22 | |||
| d1a773a684 | |||
| fe1e5f1b95 | |||
| 1e18ca9a46 | |||
| 5ef1dcba8e | |||
| e30f3c9ebe | |||
| 738d532ce6 | |||
| 434fbc2d73 | |||
| 302dd7e343 | |||
| d2086835c1 |
+77
-49
@@ -5,68 +5,96 @@ parameter to watch the timeline rebuild. [Documentation](https://bherbruck.githu
|
||||
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
|
||||
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
|
||||
**Why 0.8:** this is a minor bump because the design file format moved to version 16, and
|
||||
**Why 0.9:** this is a minor bump because the design file format moved to version 17, and
|
||||
because it adds many new features.
|
||||
|
||||
**What's new**
|
||||
- **Face actions work on far more faces.** Sketches, offset planes and press-pull now attach to
|
||||
Cut, Hole and chamfer faces, each pattern or mirror copy, side walls a Join merged and the
|
||||
pieces a later feature split a face into
|
||||
([#625](https://github.com/bherbruck/solidcraft/pull/625)), to moved or pasted components
|
||||
([#610](https://github.com/bherbruck/solidcraft/pull/610)) and beside long thin holes
|
||||
([#629](https://github.com/bherbruck/solidcraft/pull/629)). Across the test models, flat-face
|
||||
actions pass 70% of the time, up from 61% (sketch 52% → 71%, offset plane 52% → 71%,
|
||||
press-pull 41% → 60%).
|
||||
- **Fillet and chamfer** handle a single edge whose ends meet curved or slanted faces, and blend
|
||||
smooth chains of edges ([#646](https://github.com/bherbruck/solidcraft/pull/646)): 83% → 93%
|
||||
of edges.
|
||||
- **New:** user-defined physical materials stored in the design, linked components (insert
|
||||
another design), saved body visibility, named views through `view.apply`, sketch text fonts,
|
||||
corner reliefs, flanges on flanges, edge flanges that trim the base along their edge, grouped
|
||||
sketch tools, Home thumbnails, and an improved Select Other list.
|
||||
- **Fixes:** filleting beside a modeled thread no longer freezes the app, a loft standing on a
|
||||
face fuses with it, and many sheet metal, hole and sketch fixes.
|
||||
- **Booleans are checked.** A Boolean retry that would change the part away from the tool is now
|
||||
refused instead of returning wrong geometry, which fixes stray cuts
|
||||
([#616](https://github.com/bherbruck/solidcraft/issues/616)). MCP feature replies include
|
||||
warnings.
|
||||
- **Opening a design** keeps the window draggable, shows progress, and can be cancelled with
|
||||
Cancel or Esc.
|
||||
- **Under the hood:** compact MCP replies, a kernel-neutral native B-rep format, and an opt-in
|
||||
preview from the native cache while a design rebuilds.
|
||||
- **Quality:** every ordinary action is run on every face, edge, vertex and body of the test
|
||||
models; CI blocks regressions and the full matrix runs nightly.
|
||||
- **Security:** private vulnerability reporting is on; see
|
||||
[SECURITY.md](https://github.com/bherbruck/solidcraft/blob/main/SECURITY.md).
|
||||
- **Face actions work on about 90% of flat faces.** Sketches, offset planes and press-pull now
|
||||
attach to sheet metal rims and holes, plastic features (Boss, Lip, Rest, Snap Fit), lofts,
|
||||
revolved faces, pieces Offset Face moved and walls a Join merged
|
||||
([#677](https://github.com/bherbruck/solidcraft/pull/677), fixing
|
||||
[#681](https://github.com/bherbruck/solidcraft/issues/681)). Across the test models: sketch
|
||||
75% → 90%, offset plane 75% → 90%, press-pull 64% → 85%.
|
||||
- **Joins:** a logo-style part with pieces extruded up and down from one sketch now joins
|
||||
([#679](https://github.com/bherbruck/solidcraft/issues/679)), faces that only partly overlap
|
||||
fuse correctly ([#704](https://github.com/bherbruck/solidcraft/pull/704), fixing
|
||||
[#674](https://github.com/bherbruck/solidcraft/issues/674)), and booleans build the same body
|
||||
every time ([#748](https://github.com/bherbruck/solidcraft/pull/748)), so a design rebuilds
|
||||
identically on every run.
|
||||
- **Sketch:** type a dimension right after picking (D, click, type;
|
||||
[#695](https://github.com/bherbruck/solidcraft/pull/695)); redundant but consistent
|
||||
constraints are accepted, such as a rectangle clover
|
||||
([#699](https://github.com/bherbruck/solidcraft/pull/699)); tool windows for Scale, Offset,
|
||||
Mirror, Move/Copy, Fillet and Chamfer and the rectangular pattern (thanks @wdunn001), shown
|
||||
next to the Sketch Palette, which now lists the running tool's options; a typed Rho for the
|
||||
conic; a Construction Geometries row in the Sketch Palette that hides construction geometry
|
||||
([#804](https://github.com/bherbruck/solidcraft/pull/804)); sketch tool shortcuts open a sketch on the selected plane; Edit Sketch from the viewport
|
||||
menu.
|
||||
- **Features:** feature history on the left side with folders (opt in under Preferences;
|
||||
[#449](https://github.com/bherbruck/solidcraft/pull/449)); Convert to Sheet Metal from a solid
|
||||
or shelled body by its bend edges ([#725](https://github.com/bherbruck/solidcraft/pull/725));
|
||||
a tear corner relief ([#809](https://github.com/bherbruck/solidcraft/pull/809)); tapered and
|
||||
variable fillet radii ([#675](https://github.com/bherbruck/solidcraft/pull/675),
|
||||
[#749](https://github.com/bherbruck/solidcraft/pull/749)) with a radius spline you drag on the
|
||||
edge ([#752](https://github.com/bherbruck/solidcraft/pull/752)); free-form cut-outs stay exact
|
||||
in sheet-metal flat patterns ([#692](https://github.com/bherbruck/solidcraft/pull/692)) and edge
|
||||
flanges run along what cut-outs leave of an edge
|
||||
([#702](https://github.com/bherbruck/solidcraft/pull/702)); and Surface Extrude, sheets from
|
||||
open curves and profile walls ([#815](https://github.com/bherbruck/solidcraft/pull/815)).
|
||||
- **View:** Home and Fit frame only what is shown, and a middle double-click goes Home
|
||||
([#701](https://github.com/bherbruck/solidcraft/pull/701)); a cube navigator option
|
||||
(thanks @Karl-VonRichter, [#664](https://github.com/bherbruck/solidcraft/pull/664)); the window
|
||||
stays responsive and draggable while a design opens, with a Cancel button that stays put
|
||||
([#643](https://github.com/bherbruck/solidcraft/pull/643),
|
||||
[#686](https://github.com/bherbruck/solidcraft/pull/686)).
|
||||
- **Quality:** cuts no longer split walls they don't touch
|
||||
([#670](https://github.com/bherbruck/solidcraft/pull/670)); booleans are faster and use less
|
||||
memory ([#714](https://github.com/bherbruck/solidcraft/pull/714)); STL export welds shared
|
||||
vertices ([#816](https://github.com/bherbruck/solidcraft/pull/816)); complete Japanese
|
||||
translation ([#753](https://github.com/bherbruck/solidcraft/pull/753)).
|
||||
- **Fixes:** pasted features act on the pasted bodies (#613); a refused OK's error clears when
|
||||
you change the inputs (#641); dialogs show the units of their sizes (#630); the wheel over a
|
||||
dialog's list scrolls only the list (#678); a Single hole picked at a circle's centre lands on
|
||||
the centre (#657); axis or tangent plane on a drill tip works (#567); sketch text edits from
|
||||
letter interiors and moves as a whole; a projected edge stays the edge you picked; a size from
|
||||
a bare diameter keeps its unit (#716); Convert Mesh says how big a mesh is and what converts;
|
||||
the Save changes prompt takes arrow keys and Enter.
|
||||
- **MCP:** agent skills for modeling and editing over MCP
|
||||
([#728](https://github.com/bherbruck/solidcraft/pull/728)).
|
||||
- A new README demo and the SELENE rover showcase.
|
||||
|
||||
**File format 16.** Designs are now format 16 (`solidcraft/16`). SolidCraft 0.7 and older can't
|
||||
open designs saved by 0.8, as a container or as JSON; keep a copy of a file you may still open
|
||||
in 0.7. Files from every earlier version still open in 0.8.
|
||||
**File format 17.** Designs are now format 17 (`solidcraft/17`). SolidCraft 0.8 and older can't
|
||||
open designs saved by 0.9, as a container or as JSON; they ask you to update instead. Keep a
|
||||
copy of a file you may still open in 0.8. Files from every earlier version still open in 0.9.
|
||||
|
||||
**Linux app ID.** The Linux app ID is now `ai.storyteller.solidcraft` (it was
|
||||
`ai.storyteller.solvecraft`). Flatpak users from the SolidCraft feed: `flatpak update` reports
|
||||
the old app as end-of-life and asks to replace it; say yes, and your settings come along. The
|
||||
`.deb`, `.rpm` and AppImage remove the old menu entry on upgrade or first launch.
|
||||
**Upgrading from 0.8.** Nothing to do: open your designs and save (they're saved as format 17
|
||||
from then on). Feature-history folders on
|
||||
the left are off by default; turn them on in Preferences.
|
||||
|
||||
**Known limitations**
|
||||
- Some faces still can't take an attached sketch: after Revolve, Offset Face or Boss
|
||||
([#494](https://github.com/bherbruck/solidcraft/issues/494)), sheet metal thickness faces
|
||||
([#495](https://github.com/bherbruck/solidcraft/issues/495)), Loft, Sweep and Coil faces
|
||||
([#561](https://github.com/bherbruck/solidcraft/issues/561)), faces after a Draft or a tapered
|
||||
Extrude ([#563](https://github.com/bherbruck/solidcraft/issues/563)) and a Shell's rim
|
||||
([#564](https://github.com/bherbruck/solidcraft/issues/564)).
|
||||
- A Hole on a sheet metal face is refused
|
||||
([#560](https://github.com/bherbruck/solidcraft/issues/560)); some through-all holes along a
|
||||
wall or beside curved faces still fail
|
||||
([#559](https://github.com/bherbruck/solidcraft/issues/559)).
|
||||
- Some through-all holes along a wall or beside curved faces still fail
|
||||
([#559](https://github.com/bherbruck/solidcraft/issues/559)), as does a hole crossing an
|
||||
equal-radius hole ([#788](https://github.com/bherbruck/solidcraft/issues/788)).
|
||||
- Some fillets are still refused, such as along an outline of lines and arcs
|
||||
([#431](https://github.com/bherbruck/solidcraft/issues/431)).
|
||||
- Cutting a coaxial cylinder from a thin disc can lose a little volume
|
||||
([#500](https://github.com/bherbruck/solidcraft/issues/500)); an edge flange with different
|
||||
- Some faces still can't take an attached sketch: a revolve's flat faces before a later feature
|
||||
([#726](https://github.com/bherbruck/solidcraft/issues/726)) and faces after Split Body or
|
||||
Combine Intersect ([#565](https://github.com/bherbruck/solidcraft/issues/565)).
|
||||
- Fillet and chamfer accept an edge pick far from any edge and change the nearest one
|
||||
([#783](https://github.com/bherbruck/solidcraft/issues/783)); Press Pull fails on faces beside
|
||||
rounds ([#562](https://github.com/bherbruck/solidcraft/issues/562)).
|
||||
- `sheet.fold` refuses a fixed point exactly on the sheet face
|
||||
([#685](https://github.com/bherbruck/solidcraft/issues/685)), and some tabs on L-shaped or
|
||||
collinear blanks can't be folded ([#638](https://github.com/bherbruck/solidcraft/issues/638),
|
||||
[#639](https://github.com/bherbruck/solidcraft/issues/639)); an edge flange with different
|
||||
per-edge angles leaves gaps at the corners
|
||||
([#501](https://github.com/bherbruck/solidcraft/issues/501)).
|
||||
- More in [ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
|
||||
|
||||
**Upgrading from 0.7 or earlier.** 0.8 changed the Linux app ID to `ai.storyteller.solidcraft`:
|
||||
`flatpak update` offers to replace the old app, and the `.deb`, `.rpm` and AppImage remove the old
|
||||
menu entry.
|
||||
|
||||
**Upgrading from 0.5 or earlier.** 0.6 renamed SolveCraft to SolidCraft and made the zip
|
||||
container the default save format. `.solvecraft` designs, old settings folders and
|
||||
`SOLVECRAFT_*` variables are still picked up, and `solvecraft-cli` still forwards to
|
||||
|
||||
+4
-2
@@ -37,8 +37,10 @@ Generated-in-code assets are original and have no file to list:
|
||||
| assets/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.6.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.6.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon (2× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/rover-build.mp4 | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon | MIT OR Apache-2.0 |
|
||||
| docs/book/src/og.png | SolidCraft contributors | original 1280 × 640 social preview card (the SolidCraft mark, name and tagline), served by the docs site for link previews | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sample-plate.png | SolidCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/bracket.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
|
||||
Generated
+15
-13
@@ -3281,7 +3281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3298,7 +3298,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -3307,7 +3307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3322,7 +3322,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3338,7 +3338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3347,7 +3347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"quick-xml",
|
||||
"serde",
|
||||
@@ -3362,12 +3362,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-geom",
|
||||
"spade",
|
||||
"thiserror 2.0.21",
|
||||
"truck-meshalgo",
|
||||
"truck-modeling",
|
||||
@@ -3378,7 +3380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -3386,7 +3388,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3396,7 +3398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3407,7 +3409,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3422,7 +3424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4845,7 +4847,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+5
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -37,6 +37,10 @@ truck-shapeops = { version = "0.4", default-features = false }
|
||||
truck-meshalgo = { version = "0.4", default-features = false, features = ["tessellation", "analyzers"] }
|
||||
truck-stepio = { version = "0.3", default-features = false }
|
||||
truck-topology = "0.6"
|
||||
# constrained Delaunay + Ruppert refinement for trimmed faces (MIT OR Apache-2.0); already in the
|
||||
# tree through truck-meshalgo.
|
||||
spade = { version = "2.15", default-features = false, features = ["std"] }
|
||||
rayon = "1.12"
|
||||
|
||||
# packages (3MF)
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
|
||||
@@ -6,9 +6,9 @@ Sketch, constrain, and build solids with a parametric feature timeline. Change a
|
||||
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
|
||||
model through the built-in MCP server.
|
||||
|
||||

|
||||

|
||||
|
||||
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots and a colour, then height 24 → 32 mm in Parameters and it all rebuilds. 6× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.6.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
|
||||
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, the rim rounded in one pick, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots, "Pi 4" engraved in DejaVu Sans Bold and a PETG material defined in the design, with its mass in Properties, then height 24 → 32 mm in Parameters and it all rebuilds. 9× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.8.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
|
||||
|
||||
## Download
|
||||
|
||||
@@ -68,6 +68,16 @@ are listed by area in [docs/features.md](docs/features.md); status and plans are
|
||||
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solidcraft/issues),
|
||||
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Built with SolidCraft
|
||||
|
||||
<a href="https://github.com/0xlillith/solidcraft-rover"><img src="docs/screenshots/rover-build.gif" alt="SELENE, a scientific rover by 0xlillith, assembling itself as the timeline plays through its 3,323 steps in SolidCraft: tub, wheels, electronics, suspension, sampling arm, solar wings, dish and camera turret" loading="lazy"></a>
|
||||
|
||||
*SELENE, a scientific rover built entirely through SolidCraft's MCP server by
|
||||
[@0xlillith](https://github.com/0xlillith/solidcraft-rover): 3,323 editable timeline steps that
|
||||
rebuild with no errors, replayed here at 2× speed.
|
||||
[Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/rover-build.mp4)
|
||||
· [download the editable model](https://github.com/0xlillith/solidcraft-rover/releases/tag/v1.0.0).*
|
||||
|
||||
## What works
|
||||
|
||||
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
|
||||
@@ -91,7 +101,7 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
by parameters. Over-constraining edits are rejected; fully constrained geometry turns black.
|
||||
Dragging solves the sketch every frame and rebuilds the model once, when the drag ends.
|
||||
Select a line or its two endpoints, type its dimension and press Enter to commit one undo
|
||||
step. Fit keeps sketch geometry clear of the palette and navigation controls.
|
||||
step; with Sketch Dimension it's D, click, type, Enter. Fit keeps sketch geometry clear of the palette and navigation controls.
|
||||
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
|
||||
Newly captured complete adaptive boundaries retain their outer and hole source identities
|
||||
when spline span counts change. Partial or ambiguous boundaries do not qualify.
|
||||
|
||||
+19
-5
@@ -36,7 +36,7 @@ In scope, in priority order:
|
||||
metal rules (thickness, K-factor, bend radius) (M10).
|
||||
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
|
||||
|
||||
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need, PCB / 3D
|
||||
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need (scope proposed in #772), PCB / 3D
|
||||
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Milestones
|
||||
@@ -49,16 +49,25 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace | deferred | — |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular) chosen by the rule | in progress | 36 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
|
||||
## Render foundation
|
||||
|
||||
- R1 adds renderer-independent backend/profile metadata, strict request resolution and
|
||||
bounded image/scratch admission checks, exposed by the read-only `render.backends`
|
||||
command. It describes the current CPU raster snapshot path without evaluating the model.
|
||||
Existing snapshot and viewport routes remain unchanged. The Render workspace, HDRI/PBR,
|
||||
render jobs and optional engines remain future work; see [the contract](docs/render-backends.md)
|
||||
and [the phased proposal (#620)](https://github.com/bherbruck/solidcraft/issues/620).
|
||||
|
||||
## Dimensioning and error reporting
|
||||
|
||||
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
|
||||
@@ -81,11 +90,16 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
arguments reject such a name when an older design's construction plane already has it. Use
|
||||
the other alias, the construction feature's numeric ID or an explicit plane; existing
|
||||
stored references keep their saved meaning. Section Analysis uses the same check.
|
||||
- Sketch text can be edited from a selected outline's context menu or the Edit Text command.
|
||||
- Sketch text can be edited from a letter interior or outline's context menu or the Edit Text command.
|
||||
Interior selection requires every profile boundary to belong to the same text source;
|
||||
the editor highlights the whole source without replacing profile selections.
|
||||
Content, baseline X/Y, height and rotation update the original text source in one undo step;
|
||||
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
|
||||
use the document units captured when editing starts; generated outlines remain associated
|
||||
with their text source, and untouched inputs preserve their saved internal values.
|
||||
Dragging a text outline moves the entire source in one undo step; Escape restores the
|
||||
gesture. Move/Copy on a selected text outline provides unit-aware X/Y distances and rotation
|
||||
about its baseline; copies keep an independent editable text source.
|
||||
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
|
||||
waits for a distinct point without a degenerate-shape error or history entry. Typed input
|
||||
and programmatic commands retain their validation.
|
||||
|
||||
@@ -514,7 +514,23 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
|
||||
out.push(json!({"command": "solid.mirror", "params": p}));
|
||||
}
|
||||
Some("fillet") => {
|
||||
out.push(json!({"command": "solid.fillet", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}}))
|
||||
let mut p = json!({"edges": edge_points(f), "radius": f.get("radius"), "name": name});
|
||||
// Constant radius (the default), chord length, or a radius varying along each edge
|
||||
// (`radius` at the end nearest `start`, `radius2` at the other, `mid` between,
|
||||
// `smooth`), or a B-spline along each edge (`radius` its first control radius,
|
||||
// `radii` the rest, `knots`); `fit` for a radius too large for the geometry.
|
||||
match f.get("type").and_then(Value::as_str) {
|
||||
Some("chord_length") => p["type"] = json!("chord"),
|
||||
Some("variable_radius") => p["type"] = json!("variable"),
|
||||
Some("spline_radius") => p["type"] = json!("spline"),
|
||||
_ => {}
|
||||
}
|
||||
for key in ["radius2", "start", "mid", "smooth", "fit", "radii", "knots"] {
|
||||
if let Some(v) = f.get(key) {
|
||||
p[key] = v.clone();
|
||||
}
|
||||
}
|
||||
out.push(json!({"command": "solid.fillet", "params": p}))
|
||||
}
|
||||
Some("chamfer") => {
|
||||
out.push(json!({"command": "solid.chamfer", "params": {"edges": edge_points(f), "distance": f.get("distance"), "name": name}}))
|
||||
|
||||
@@ -47,6 +47,10 @@ const SLOW: [&str; 7] = [
|
||||
const EDIT_SAMPLE: u64 = 6;
|
||||
/// An action that takes longer than this is recorded as a timeout.
|
||||
const ACTION_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
/// Building a design and listing its actions, before the first one runs: the biggest designs
|
||||
/// (oracle 41's tube with radial holes, about 25 s here) take several times that on a loaded CI
|
||||
/// runner, and a design that never loads loses all its actions, not one.
|
||||
const LOAD_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
/// Caps per body, so one large imported or patterned body cannot dominate the run.
|
||||
const MAX_FACES: usize = 120;
|
||||
const MAX_EDGES: usize = 240;
|
||||
@@ -725,7 +729,9 @@ fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut seen = 0usize;
|
||||
// Until the child writes, it is loading the design (again, after a restart).
|
||||
let loaded_at = std::fs::metadata(out).map(|m| m.len() as usize).unwrap_or(0);
|
||||
let mut seen = loaded_at;
|
||||
let mut last_change = Instant::now();
|
||||
let (status, timed_out) = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
@@ -735,7 +741,7 @@ fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
|
||||
if n != seen {
|
||||
seen = n;
|
||||
last_change = Instant::now();
|
||||
} else if last_change.elapsed() > ACTION_TIMEOUT {
|
||||
} else if last_change.elapsed() > if seen == loaded_at { LOAD_TIMEOUT } else { ACTION_TIMEOUT } {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break (None, true);
|
||||
@@ -1046,7 +1052,6 @@ fn a_through_hole_into_a_shelled_box_rim() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #560 is fixed"]
|
||||
fn a_hole_through_a_sheet_metal_base() {
|
||||
let mut c: Vec<Value> = rectangle(json!("XY"), [0., 0.], [60., 40.]).into();
|
||||
c.extend([
|
||||
@@ -1057,7 +1062,6 @@ fn a_hole_through_a_sheet_metal_base() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #561 is fixed"]
|
||||
fn sketches_on_loft_and_sweep_faces() {
|
||||
let mut loft: Vec<Value> = rectangle(json!("XY"), [-20., -20.], [20., 20.]).into();
|
||||
loft.extend([
|
||||
@@ -1096,7 +1100,6 @@ fn press_pull_on_the_pi_case_floor() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #563 is fixed"]
|
||||
fn sketches_after_a_draft_and_on_a_tapered_extrude() {
|
||||
reproduces(
|
||||
None,
|
||||
@@ -1117,7 +1120,6 @@ fn sketches_after_a_draft_and_on_a_tapered_extrude() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #564 is fixed"]
|
||||
fn a_sketch_on_the_rim_a_shell_leaves() {
|
||||
let mut c: Vec<Value> = rectangle(json!("XY"), [-35., -25.], [35., 25.]).into();
|
||||
c.extend([
|
||||
@@ -1173,8 +1175,36 @@ fn a_shell_of_a_swept_bar() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The construction a drill-tip script ends with.
|
||||
struct TipConstruction(solidcraft_engine::doc::construct::ConstructGeom);
|
||||
|
||||
impl TipConstruction {
|
||||
fn axis(&self) -> (Vec3, Vec3) {
|
||||
match &self.0 {
|
||||
solidcraft_engine::doc::construct::ConstructGeom::Axis { origin, dir } => (*origin, *dir),
|
||||
other => panic!("expected an axis, got {other:?}"),
|
||||
}
|
||||
}
|
||||
fn plane(&self) -> solidcraft_engine::geom::Plane {
|
||||
match &self.0 {
|
||||
solidcraft_engine::doc::construct::ConstructGeom::Plane(p) => *p,
|
||||
other => panic!("expected a plane, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replays the script and returns its last construction.
|
||||
fn drill_tip_construction(commands: Vec<Value>) -> TipConstruction {
|
||||
let mut s = Session::default();
|
||||
for c in &commands {
|
||||
let id = c["command"].as_str().unwrap();
|
||||
s.execute(id, &c["params"]).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
let state = s.model.state();
|
||||
TipConstruction(state.construct.last().map(|c| c.geom.clone()).expect("a construction"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #567 is fixed"]
|
||||
fn an_axis_through_a_drill_tip() {
|
||||
let mut c: Vec<Value> = rectangle(json!("XY"), [0., 0.], [60., 40.]).into();
|
||||
c.extend([
|
||||
@@ -1182,7 +1212,30 @@ fn an_axis_through_a_drill_tip() {
|
||||
json!({"command": "solid.hole", "params": {"position": [50, 20, 15], "direction": [0, 0, -1], "diameter": 6, "depth": 10, "tip_angle": 118}}),
|
||||
json!({"command": "construct.axis.through_cylinder", "params": {"face": {"face": [48.44468907075447, 21.22448979591836, 4.399139380972439]}}}),
|
||||
]);
|
||||
reproduces(None, Value::Array(c));
|
||||
reproduces(None, Value::Array(c.clone()));
|
||||
// Not just "no error": the axis runs along the drill, through the hole's centre line.
|
||||
let (origin, dir) = drill_tip_construction(c).axis();
|
||||
assert!((origin.x - 50.).abs() < 1e-6 && (origin.y - 20.).abs() < 1e-6, "{origin:?}");
|
||||
assert!(dir.z.abs() > 1. - 1e-9, "{dir:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tangent_plane_on_a_drill_tip() {
|
||||
let mut c: Vec<Value> = rectangle(json!("XY"), [0., 0.], [60., 40.]).into();
|
||||
c.extend([
|
||||
json!({"command": "solid.extrude", "params": {"distance": 15}}),
|
||||
json!({"command": "solid.hole", "params": {"position": [50, 20, 15], "direction": [0, 0, -1], "diameter": 6, "depth": 10, "tip_angle": 118}}),
|
||||
json!({"command": "construct.plane.tangent", "params": {"face": {"face": [48.44468907075447, 21.22448979591836, 4.399139380972439]}}}),
|
||||
]);
|
||||
reproduces(None, Value::Array(c.clone()));
|
||||
// The plane touches the cone at the picked point and leans like the cone does: a 118 degree tip
|
||||
// puts the wall 59 degrees off the drill axis, so its normal is 31 degrees off it.
|
||||
let plane = drill_tip_construction(c).plane();
|
||||
let picked = Vec3::new(48.44468907075447, 21.22448979591836, 4.399139380972439);
|
||||
let distance = (picked - plane.origin).dot(plane.normal());
|
||||
// The pick lies on the tessellated cone and the plane is the exact one, so allow for chord error.
|
||||
assert!(distance.abs() < 0.05, "the pick is {distance} mm off the plane");
|
||||
assert!((plane.normal().z.abs() - 31f64.to_radians().cos()).abs() < 1e-2, "{:?}", plane.normal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1199,3 +1252,30 @@ fn a_plane_on_a_patterned_counterbore_after_a_thickness_change() {
|
||||
]);
|
||||
reproduces(None, Value::Array(c));
|
||||
}
|
||||
|
||||
/// #707: the same design builds the same body every time, in one process too. Faces were put in
|
||||
/// the hash order of their addresses in a few places (truck's connected components, the planar
|
||||
/// boolean's rebuild, healing's merges), so a later boolean on the ribbed lid passed or failed
|
||||
/// from run to run.
|
||||
#[test]
|
||||
fn the_ribbed_lid_builds_the_same_body_every_time() {
|
||||
let path = root().join("examples/regressions/lid_ribs_flush_with_window.json");
|
||||
let fingerprint = || {
|
||||
let s = load(&path).unwrap();
|
||||
let st = s.model.state();
|
||||
st.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let mesh = b.body.tessellate(0.1).unwrap();
|
||||
let bits = mesh.positions.iter().fold(0u64, |acc, p| {
|
||||
acc.rotate_left(7).wrapping_add(p.x.to_bits() ^ p.y.to_bits().rotate_left(21) ^ p.z.to_bits().rotate_left(42))
|
||||
});
|
||||
format!("{} {} {bits:x}", b.name, b.body.face_count())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let first = fingerprint();
|
||||
for _ in 0..5 {
|
||||
assert_eq!(fingerprint(), first);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,13 +137,7 @@ fn body_names_saved_before_exact_history_keep_their_pieces() {
|
||||
/// ignored test below that checks its faces; a listed plane whose face attaches fails the main
|
||||
/// check until it is removed from here.
|
||||
type Planes = &'static [([f64; 3], f64)];
|
||||
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 5] = [
|
||||
("m0-revolve-primitives", "Body1", None, 494),
|
||||
("naming-split-pieces", "Body1", None, 494),
|
||||
("modern-plastic", "Body1", None, 494),
|
||||
("modern-sheet-metal", "Body1", None, 495),
|
||||
("issue-616-lid-ribs", "Lid", None, 628),
|
||||
];
|
||||
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 1] = [("issue-616-lid-ribs", "Lid", None, 628)];
|
||||
|
||||
/// The issue a face is listed under, and whether it is listed by its plane (not as every face).
|
||||
fn known_without_attachment(design: &str, body: &str, normal: [f64; 3], distance: f64) -> Option<(u32, bool)> {
|
||||
@@ -262,18 +256,6 @@ fn known_faces_attach(issue: u32) {
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #494 is fixed"]
|
||||
fn faces_after_revolve_offset_face_and_boss_take_an_attached_sketch() {
|
||||
known_faces_attach(494);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #495 is fixed"]
|
||||
fn sheet_metal_thickness_faces_take_an_attached_sketch() {
|
||||
known_faces_attach(495);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #628 is fixed"]
|
||||
fn faces_of_a_lid_with_joined_ribs_take_an_attached_sketch() {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! A parameter change gives the same topology as building at the new value from scratch, and
|
||||
//! changing it back gives the original topology: no stale splits, no faces added to regions
|
||||
//! nothing touched. Over the design corpus (`tests/corpus`, `tests/action_matrix/designs`), and
|
||||
//! the README demo's Raspberry Pi case pinned step by step.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::doc::kernel::{FaceSurface, face_surface};
|
||||
|
||||
fn root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
|
||||
}
|
||||
|
||||
/// A folder of its own for test `name` (tests run side by side).
|
||||
fn scratch_dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("solidcraft-rebuild-topology-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A body's topology: its face and edge counts, and how many faces lie in each plane.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Topology {
|
||||
faces: usize,
|
||||
edges: usize,
|
||||
planes: BTreeMap<[i64; 4], usize>,
|
||||
}
|
||||
|
||||
/// Every solid body's topology, by name.
|
||||
fn topology(s: &Session) -> BTreeMap<String, Topology> {
|
||||
let st = s.world_state();
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| !b.body.is_mesh())
|
||||
.map(|b| {
|
||||
let mut planes = BTreeMap::new();
|
||||
for i in 0..b.body.face_count() {
|
||||
if let Some(FaceSurface::Plane { normal, point }) = face_surface(&b.body, i) {
|
||||
let key = [normal.x, normal.y, normal.z, normal.dot(point) / 100.].map(|x| (x * 1e4).round() as i64);
|
||||
*planes.entry(key).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
(b.name.clone(), Topology { faces: b.body.face_count(), edges: b.body.edge_count(), planes })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
s
|
||||
}
|
||||
|
||||
/// The design built from scratch: saved as it is (timeline only, no cached geometry) and
|
||||
/// opened again.
|
||||
fn rebuilt(s: &Session, path: &Path) -> Session {
|
||||
let mut t = s.scratch();
|
||||
t.execute("file.save", &json!({"path": path.to_string_lossy(), "format": "json"})).unwrap();
|
||||
open(path)
|
||||
}
|
||||
|
||||
fn errors(s: &Session) -> Vec<u64> {
|
||||
s.model.results.iter().filter(|r| r.error.is_some()).map(|r| r.id).collect()
|
||||
}
|
||||
|
||||
/// The corpus's designs, as sessions.
|
||||
fn designs() -> Vec<(String, Session)> {
|
||||
let root = root();
|
||||
let mut out = Vec::new();
|
||||
for dir in ["apps/solidcraft-cli/tests/corpus", "tests/action_matrix/designs"] {
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(root.join(dir))
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "solidcraft" || x == "json") && !p.to_string_lossy().ends_with(".expected.json"))
|
||||
.collect();
|
||||
files.sort();
|
||||
for p in files {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let s = if p.extension().is_some_and(|x| x == "solidcraft") {
|
||||
open(&p)
|
||||
} else {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
|
||||
s.run_script(&script).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
if s.active_sketch.is_some() {
|
||||
let _ = s.execute("sketch.finish", &json!({}));
|
||||
}
|
||||
s
|
||||
};
|
||||
out.push((name, s));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// For each design and its first length parameter that edits cleanly (the user's first): the parameter
|
||||
/// scaled by 1.1 gives what a fresh build at that value gives, and setting it back gives the
|
||||
/// original topology.
|
||||
#[test]
|
||||
fn a_parameter_edit_rebuilds_like_a_fresh_build_and_reverts_exactly() {
|
||||
let dir = scratch_dir("corpus");
|
||||
let mut problems = Vec::new();
|
||||
let mut checked = 0;
|
||||
for (name, s) in designs() {
|
||||
let original = topology(&s);
|
||||
let before = errors(&s);
|
||||
let mut params: Vec<_> = s.doc.params.iter().filter(|p| p.unit == "mm").cloned().collect();
|
||||
params.sort_by_key(|p| p.model);
|
||||
let mut tried = 0;
|
||||
for p in params {
|
||||
if tried == 1 {
|
||||
break;
|
||||
}
|
||||
let mut t = s.scratch();
|
||||
let expr = format!("({}) * 1.1", p.expr);
|
||||
if t.execute("parameters.change", &json!({"name": p.name, "expression": expr})).is_err() || errors(&t) != before {
|
||||
continue;
|
||||
}
|
||||
tried += 1;
|
||||
checked += 1;
|
||||
let edited = topology(&t);
|
||||
let fresh = topology(&rebuilt(&t, &dir.join(format!("{name}-{}.solidcraft", p.name))));
|
||||
if edited != fresh {
|
||||
problems.push(format!("{name}: {} = {expr}: edited {edited:?}\n fresh {fresh:?}", p.name));
|
||||
}
|
||||
t.execute("parameters.change", &json!({"name": p.name, "expression": p.expr})).unwrap();
|
||||
let back = topology(&t);
|
||||
if back != original {
|
||||
problems.push(format!("{name}: {} back to {}: {back:?}\n original {original:?}", p.name, p.expr));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(checked >= 8, "only {checked} parameter edits checked");
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
/// The README demo's Raspberry Pi case (`examples/demo/enclosure_ui_mcp.py --story pi`): its
|
||||
/// face count after each feature, and each outer side (the four walls and the bottom) one face
|
||||
/// throughout: (a) built at height 24, (b) built at 24 and changed to 32 in Parameters, (c)
|
||||
/// built at 32. The port cut-outs and the floor vents used to split the long wall (or the end
|
||||
/// wall at 24) into strips along the openings' tops and bottoms.
|
||||
#[test]
|
||||
fn pi_case_faces_per_step_at_both_heights() {
|
||||
// Sketch1 (1) .. RectangularPattern2 (14): the faces once features 1..=k are built.
|
||||
const FACES: [(usize, usize); 13] =
|
||||
[(2, 6), (3, 10), (4, 19), (5, 19), (6, 25), (7, 43), (8, 43), (9, 55), (10, 55), (11, 59), (12, 59), (13, 63), (14, 79)];
|
||||
let dir = scratch_dir("pi");
|
||||
let design: Value = serde_json::from_slice(&std::fs::read(root().join("tests/action_matrix/designs/pi-case.solidcraft")).unwrap()).unwrap();
|
||||
let at = |height: &str, file: &str| {
|
||||
let mut d = design.clone();
|
||||
for p in d["params"].as_array_mut().unwrap() {
|
||||
if p["name"] == "height" {
|
||||
p["expr"] = json!(height);
|
||||
}
|
||||
}
|
||||
let path = dir.join(file);
|
||||
std::fs::write(&path, serde_json::to_vec(&d).unwrap()).unwrap();
|
||||
open(&path)
|
||||
};
|
||||
let a = at("24 mm", "pi-24.solidcraft");
|
||||
let mut b = at("24 mm", "pi-24-to-32.solidcraft");
|
||||
b.execute("parameters.change", &json!({"name": "height", "expression": "32 mm"})).unwrap();
|
||||
let c = at("32 mm", "pi-32.solidcraft");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut per_case = Vec::new();
|
||||
for (case, s, h) in [("(a) at 24", a, 24.), ("(b) 24 changed to 32", b, 32.), ("(c) at 32", c, 32.)] {
|
||||
assert!(errors(&s).is_empty(), "{case}: features fail: {:?}", errors(&s));
|
||||
let mut steps = Vec::new();
|
||||
for (k, want) in FACES {
|
||||
let mut t = s.scratch();
|
||||
t.execute("timeline.rollback", &json!({"position": k})).unwrap();
|
||||
let st = t.world_state();
|
||||
let body = st.bodies.iter().find(|b| b.name == "Body1").unwrap();
|
||||
assert_eq!(body.body.face_count(), want, "{case}: faces after feature {k}");
|
||||
// Each outer side: the faces in its plane.
|
||||
for (n, d) in [([0., -1., 0.], 33.), ([0., 1., 0.], 33.), ([-1., 0., 0.], 47.5), ([1., 0., 0.], 47.5), ([0., 0., -1.], 0.)] {
|
||||
let on = (0..body.body.face_count())
|
||||
.filter(|&i| match face_surface(&body.body, i) {
|
||||
Some(FaceSurface::Plane { normal, point }) => {
|
||||
(normal.x - n[0]).abs() + (normal.y - n[1]).abs() + (normal.z - n[2]).abs() < 1e-6 && (normal.dot(point) - d).abs() < 1e-6
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.count();
|
||||
assert_eq!(on, 1, "{case}: after feature {k} the outer side facing {n:?} is {on} faces");
|
||||
}
|
||||
let bbox_top = body.mesh().bounds().max.z;
|
||||
assert!((bbox_top - h).abs() < 1e-6, "{case}: height {bbox_top}");
|
||||
steps.push(topology(&t));
|
||||
}
|
||||
per_case.push((case, steps));
|
||||
}
|
||||
// (b) and (c): the same topology after every feature.
|
||||
let (b, c) = (&per_case[1].1, &per_case[2].1);
|
||||
for (k, (x, y)) in b.iter().zip(c).enumerate() {
|
||||
assert_eq!(x, y, "after feature {}: (b) differs from (c)", k + 2);
|
||||
}
|
||||
}
|
||||
@@ -227,7 +227,7 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::Fillet { edges, style, .. } => {
|
||||
edges.iter_mut().for_each(pt);
|
||||
if let crate::FilletStyle::Variable { start, .. } = style {
|
||||
if let crate::FilletStyle::Variable { start, .. } | crate::FilletStyle::Spline { start, .. } = style {
|
||||
pt(start);
|
||||
}
|
||||
}
|
||||
@@ -265,7 +265,10 @@ impl FeatureKind {
|
||||
FeatureKind::Thread { face, .. } => pt(face),
|
||||
FeatureKind::SheetFlange { edges, .. } => edges.iter_mut().for_each(|e| pt(&mut e.point)),
|
||||
FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SheetConvert { face, .. } => pt(face),
|
||||
FeatureKind::SheetConvert { face, bends, .. } => {
|
||||
pt(face);
|
||||
bends.iter_mut().for_each(pt);
|
||||
}
|
||||
FeatureKind::SheetFold { a, b, fixed, .. } => {
|
||||
pt(a);
|
||||
pt(b);
|
||||
@@ -318,6 +321,7 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::Revolve { .. }
|
||||
| FeatureKind::Extrude { .. }
|
||||
| FeatureKind::SurfaceExtrude { .. }
|
||||
| FeatureKind::Combine { .. }
|
||||
| FeatureKind::Loft { .. }
|
||||
| FeatureKind::Sweep { .. }
|
||||
|
||||
+67
-57
@@ -47,6 +47,7 @@ impl FeatureKind {
|
||||
}
|
||||
}
|
||||
FeatureKind::Extrude { sketch, .. }
|
||||
| FeatureKind::SurfaceExtrude { sketch, .. }
|
||||
| FeatureKind::Revolve { sketch, .. }
|
||||
| FeatureKind::Emboss { sketch, .. }
|
||||
| FeatureKind::SheetBase { sketch, .. }
|
||||
@@ -243,69 +244,19 @@ fn remap_link(
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Feature {
|
||||
/// Clone identities through typed reference fields. Sketch-local entity IDs stay local.
|
||||
pub fn remap_clone(
|
||||
&mut self,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
parameters: &BTreeMap<String, String>,
|
||||
) -> crate::Result<()> {
|
||||
impl FeatureKind {
|
||||
/// Point the body names a feature acts on (targets, tools, the body it rounds…) at the
|
||||
/// copies in `bodies`; names not in the map stay as they are.
|
||||
pub fn remap_bodies(&mut self, bodies: &BTreeMap<String, String>) {
|
||||
use FeatureKind as K;
|
||||
match &self.kind {
|
||||
K::Sketch { plane, sketch } => {
|
||||
checked_plane(plane, 0)?;
|
||||
for link in &sketch.links {
|
||||
checked_link(&link.source, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPlane { plane }
|
||||
| K::Split { plane, .. }
|
||||
| K::SplitFace { plane, .. }
|
||||
| K::Mirror { plane, .. }
|
||||
| K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?,
|
||||
K::Draft { neutral, .. } => checked_plane(neutral, 0)?,
|
||||
K::ReplaceFace { target, .. } => checked_plane(target, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPoint { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.kind.remap_refs(ids, names);
|
||||
for n in &mut self.body_names {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
for n in &mut self.participants {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
for n in &mut self.param_names {
|
||||
mapped_name(n, parameters);
|
||||
}
|
||||
for n in self.face_names.iter_mut().chain(&mut self.edge_names) {
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
for (_, e, _) in self.kind.inputs_mut() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
for e in self.kind.point_expr_mut().into_iter().flatten() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
match &mut self.kind {
|
||||
match self {
|
||||
K::Emboss { face: Some(face), .. } => mapped_name(&mut face.body, bodies),
|
||||
K::Split { tool: Some(tool), .. } | K::SplitFace { tool: Some(tool), .. } | K::SurfaceTrim { tool: Some(tool), .. } => {
|
||||
mapped_name(&mut tool.body, bodies)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match &mut self.kind {
|
||||
match self {
|
||||
K::Extrude { targets, .. }
|
||||
| K::Revolve { targets, .. }
|
||||
| K::Loft { targets, .. }
|
||||
@@ -373,6 +324,65 @@ impl crate::Feature {
|
||||
| K::MeshConvert { body, .. } => mapped_name(body, bodies),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Feature {
|
||||
/// Clone identities through typed reference fields. Sketch-local entity IDs stay local.
|
||||
pub fn remap_clone(
|
||||
&mut self,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
parameters: &BTreeMap<String, String>,
|
||||
) -> crate::Result<()> {
|
||||
use FeatureKind as K;
|
||||
match &self.kind {
|
||||
K::Sketch { plane, sketch } => {
|
||||
checked_plane(plane, 0)?;
|
||||
for link in &sketch.links {
|
||||
checked_link(&link.source, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPlane { plane }
|
||||
| K::Split { plane, .. }
|
||||
| K::SplitFace { plane, .. }
|
||||
| K::Mirror { plane, .. }
|
||||
| K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?,
|
||||
K::Draft { neutral, .. } => checked_plane(neutral, 0)?,
|
||||
K::ReplaceFace { target, .. } => checked_plane(target, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPoint { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.kind.remap_refs(ids, names);
|
||||
for n in &mut self.body_names {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
for n in &mut self.participants {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
for n in &mut self.param_names {
|
||||
mapped_name(n, parameters);
|
||||
}
|
||||
for n in self.face_names.iter_mut().chain(&mut self.edge_names) {
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
for (_, e, _) in self.kind.inputs_mut() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
for e in self.kind.point_expr_mut().into_iter().flatten() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
self.kind.remap_bodies(bodies);
|
||||
match &mut self.kind {
|
||||
K::Sketch { plane, sketch } => {
|
||||
remap_plane(plane, ids, names, bodies, 0)?;
|
||||
@@ -383,7 +393,7 @@ impl crate::Feature {
|
||||
}
|
||||
for link in &mut sketch.links {
|
||||
remap_link(&mut link.source, ids, names, bodies, 0);
|
||||
if let Some(n) = &mut link.face_name {
|
||||
for n in link.face_name.iter_mut().chain(&mut link.edge_name) {
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,6 +421,40 @@ impl<'a> ExactSketchCurves<'a> {
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`find`](Self::find), else the one sketch line a generated straight segment runs along
|
||||
/// and overlaps: a profile a feature grew to overlap a flush face (its neighbours lengthened
|
||||
/// past the sketch's corners) still names its sides by the lines they were drawn on. Two
|
||||
/// candidates stay ambiguous.
|
||||
pub(crate) fn find_along(&self, generated: &Seg2) -> Option<String> {
|
||||
if let Some(id) = self.find(generated) {
|
||||
return Some(id);
|
||||
}
|
||||
let Seg2::Line { a: c, b: d } = *generated else { return None };
|
||||
let mut found: Option<Option<&str>> = None;
|
||||
for (id, linked, originals) in &self.segments {
|
||||
if *linked {
|
||||
continue;
|
||||
}
|
||||
let along = originals.iter().any(|original| {
|
||||
let Seg2::Line { a, b } = *original else { return false };
|
||||
let direction = b - a;
|
||||
let scale = direction.len().max((d - c).len()).max(1.);
|
||||
if direction.len() <= 1e-12
|
||||
|| direction.cross(c - a).abs() > 1e-10 * scale * scale
|
||||
|| direction.cross(d - a).abs() > 1e-10 * scale * scale
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (tc, td) = ((c - a).dot(direction) / direction.len2(), (d - a).dot(direction) / direction.len2());
|
||||
tc.min(td).max(0.) < tc.max(td).min(1.) - 1e-9
|
||||
});
|
||||
if along {
|
||||
found = Some(if found.is_some() { None } else { Some(*id) });
|
||||
}
|
||||
}
|
||||
found.flatten().map(str::to_owned)
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool {
|
||||
@@ -581,6 +615,14 @@ pub fn lineage_frame(body: &ModelBody, plane: &solidcraft_geom::Plane) -> Option
|
||||
solidcraft_geom::Plane::new(origin, x, n.cross(x))
|
||||
}
|
||||
|
||||
/// Whether Split Face split `body`'s faces on `plane` (a split line lies on it).
|
||||
fn split_on_purpose(body: &ModelBody, plane: &solidcraft_geom::Plane) -> bool {
|
||||
let tolerance = 1e-6 * (1. + body.body.size());
|
||||
let on_plane =
|
||||
|line: &Vec<solidcraft_geom::Vec3>| !line.is_empty() && line.iter().all(|p| (*p - plane.origin).dot(plane.normal()).abs() <= tolerance);
|
||||
body.body.split_lines().is_some_and(|lines| lines.iter().any(on_plane))
|
||||
}
|
||||
|
||||
/// Whether two exact planes are one oriented plane (pieces of one face).
|
||||
fn same_oriented_plane(a: &solidcraft_geom::Plane, b: &solidcraft_geom::Plane, scale: f64) -> bool {
|
||||
a.normal().dot(b.normal()) >= 1. - 1e-8 && (b.origin - a.origin).dot(a.normal()).abs() <= 1e-7 * (1. + scale)
|
||||
@@ -599,7 +641,9 @@ impl Document {
|
||||
|
||||
/// A target for flat face `index` of `body` by its exact history name
|
||||
/// ([`FaceCertificate::Lineage`]), when that name follows exact history, names a feature of
|
||||
/// the design and finds this face (with its other pieces, all on one oriented plane).
|
||||
/// the design and finds this face (with its other pieces, all on one oriented plane). Where
|
||||
/// the face's pieces no longer share a plane (Offset Face moved one of them), the piece by
|
||||
/// its own name: the face's name qualified by the faces around it (`F2:top#[F2:side:l4,…]`).
|
||||
pub(crate) fn lineage_face_target(
|
||||
&self,
|
||||
body: &ModelBody,
|
||||
@@ -610,17 +654,24 @@ impl Document {
|
||||
if !names.verified(index) {
|
||||
return None;
|
||||
}
|
||||
let name = crate::naming::strip_piece(names.faces.get(index)?).to_string();
|
||||
let origin = name.strip_prefix('F')?.split_once(':')?.0.parse::<u64>().ok()?;
|
||||
let full = names.faces.get(index)?;
|
||||
let stem = crate::naming::strip_piece(full);
|
||||
let origin = stem.strip_prefix('F')?.split_once(':')?.0.parse::<u64>().ok()?;
|
||||
let inverse = crate::mat_inverse(&body.source_transform)?;
|
||||
let target = crate::config::ConfigFaceTarget {
|
||||
body: body.definition_name().into(),
|
||||
origin_feature: origin,
|
||||
name,
|
||||
point: crate::apply_point(&inverse, point),
|
||||
certificate: Some(FaceCertificate::Lineage),
|
||||
};
|
||||
self.configuration_face_indices(body, &target).ok()?.contains(&index).then_some(target)
|
||||
let mut candidates = vec![stem];
|
||||
if full != stem {
|
||||
candidates.push(full);
|
||||
}
|
||||
candidates.into_iter().find_map(|name| {
|
||||
let target = crate::config::ConfigFaceTarget {
|
||||
body: body.definition_name().into(),
|
||||
origin_feature: origin,
|
||||
name: name.to_string(),
|
||||
point: crate::apply_point(&inverse, point),
|
||||
certificate: Some(FaceCertificate::Lineage),
|
||||
};
|
||||
self.configuration_face_indices(body, &target).ok()?.contains(&index).then_some(target)
|
||||
})
|
||||
}
|
||||
|
||||
/// The faces of `body` an exact history name finds: those named `name` or a piece of it, else
|
||||
@@ -628,8 +679,20 @@ impl Document {
|
||||
/// follow exact history and lie on one oriented plane.
|
||||
pub(crate) fn lineage_face_indices(&self, body: &ModelBody, name: &str) -> Result<Vec<usize>> {
|
||||
let names = crate::naming::names(body);
|
||||
let mut indices: Vec<usize> =
|
||||
names.faces.iter().enumerate().filter(|(_, face)| crate::naming::strip_piece(face) == name).map(|(index, _)| index).collect();
|
||||
// `face` is `name` itself or a piece of it (a piece of a piece, …).
|
||||
let piece_of = |face: &str| {
|
||||
let mut n = face;
|
||||
loop {
|
||||
if n == name {
|
||||
return true;
|
||||
}
|
||||
match crate::piece_names::parent_piece(n) {
|
||||
Some(parent) => n = parent,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut indices: Vec<usize> = names.faces.iter().enumerate().filter(|(_, face)| piece_of(face)).map(|(index, _)| index).collect();
|
||||
if indices.is_empty() {
|
||||
indices =
|
||||
names.info.iter().enumerate().filter(|(_, info)| info.aliases.iter().any(|alias| alias == name)).map(|(index, _)| index).collect();
|
||||
@@ -638,6 +701,16 @@ impl Document {
|
||||
return Err(DocError::Unknown(format!("face `{name}` on `{}` no longer exists", body.name)));
|
||||
}
|
||||
self.coplanar_pieces(body, &indices, |index| names.verified(index))?;
|
||||
// A piece by its own name stands for itself, which a piece of a face split on purpose
|
||||
// (Split Face) never does: that needs a fixed plane.
|
||||
if crate::naming::strip_piece(name) != name
|
||||
&& let Some(plane) = indices.first().and_then(|index| body.body.planar_face(*index))
|
||||
&& split_on_purpose(body, &plane)
|
||||
{
|
||||
return Err(DocError::Invalid(
|
||||
"this face was split on purpose (Split Face): pick an exact face piece's own plane with fixed:true".into(),
|
||||
));
|
||||
}
|
||||
Ok(indices)
|
||||
}
|
||||
|
||||
@@ -658,16 +731,12 @@ impl Document {
|
||||
Some(_) => return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into())),
|
||||
}
|
||||
}
|
||||
if let (Some(plane), true) = (first, indices.len() > 1) {
|
||||
let tolerance = 1e-6 * (1. + scale);
|
||||
let on_plane = |line: &Vec<solidcraft_geom::Vec3>| {
|
||||
!line.is_empty() && line.iter().all(|p| (*p - plane.origin).dot(plane.normal()).abs() <= tolerance)
|
||||
};
|
||||
if body.body.split_lines().is_some_and(|lines| lines.iter().any(on_plane)) {
|
||||
return Err(DocError::Invalid(
|
||||
"this face was split on purpose (Split Face): pick an exact face piece's own plane with fixed:true".into(),
|
||||
));
|
||||
}
|
||||
if let (Some(plane), true) = (first, indices.len() > 1)
|
||||
&& split_on_purpose(body, &plane)
|
||||
{
|
||||
return Err(DocError::Invalid(
|
||||
"this face was split on purpose (Split Face): pick an exact face piece's own plane with fixed:true".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -124,7 +124,12 @@ impl Document {
|
||||
target.validate()?;
|
||||
let prefix = format!("F{}:", target.origin_feature);
|
||||
let role = target.name.strip_prefix(&prefix).ok_or_else(|| DocError::Invalid("face lineage does not match its feature".into()))?;
|
||||
if crate::naming::strip_piece(&target.name) != target.name {
|
||||
// An exact history name may name a piece by the faces around it (`F2:top#[F2:side:l4,…]`),
|
||||
// never by a number (an older design's piece, or a tie the faces around it left).
|
||||
let numbered =
|
||||
target.name.match_indices('#').any(|(i, _)| target.name.get(i + 1..).is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())));
|
||||
let lineage_piece = matches!(target.certificate, Some(super::FaceCertificate::Lineage)) && !numbered;
|
||||
if crate::naming::strip_piece(&target.name) != target.name && !lineage_piece {
|
||||
return Err(DocError::Invalid("configure a face lineage, not a numbered split piece".into()));
|
||||
}
|
||||
let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?;
|
||||
|
||||
+163
-8
@@ -782,12 +782,17 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
refold: bool,
|
||||
},
|
||||
/// Convert a plate body (constant thickness from the face at `face`) to sheet metal.
|
||||
/// Convert a body to sheet metal from its fixed face at `face`: a plate of constant
|
||||
/// thickness or, with `bends`, a solid or shelled body whose faces reached from the fixed
|
||||
/// face across those edges become the sheet.
|
||||
SheetConvert {
|
||||
body: String,
|
||||
face: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
rule: Option<String>,
|
||||
/// Points on the sharp edges that become bends.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
bends: Vec<Vec3>,
|
||||
},
|
||||
/// Plastic boss: a round post on a face (draft, root fillet, hole, ribs).
|
||||
Boss {
|
||||
@@ -920,6 +925,18 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
},
|
||||
/// A surface body swept straight along the sketch normal from open curve chains and the
|
||||
/// boundaries of profiles: walls only, no caps.
|
||||
SurfaceExtrude {
|
||||
sketch: u64,
|
||||
/// Profiles whose boundary loops are swept (none: only `curves`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
profiles: Option<ProfileSel>,
|
||||
/// Sketch curve ids, grouped into chains of curves that meet end to end.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
curves: Vec<String>,
|
||||
extent: Extent,
|
||||
},
|
||||
/// A planar surface body extended outward by `distance` past the edges through `edges`.
|
||||
SurfaceExtend {
|
||||
body: String,
|
||||
@@ -989,16 +1006,23 @@ pub enum FilletStyle {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
smooth: bool,
|
||||
},
|
||||
/// The radius along each edge as a clamped B-spline of the position from the end nearest
|
||||
/// `start` (0) to the other (1): `radius` is its first control radius and `radii` the rest,
|
||||
/// its degree what `knots` leave (knots = control radii + degree + 1).
|
||||
Spline { start: Vec3, knots: Vec<f64>, radii: Vec<String> },
|
||||
}
|
||||
|
||||
/// A fillet radius too large for the geometry around its edges: refused (`Exact`, the default),
|
||||
/// or reduced to the largest that works for all of them (`Reduce`), with a warning.
|
||||
/// reduced to the largest that works for all of them (`Reduce`), or tapered along each edge it
|
||||
/// doesn't fit to the room there, down to zero where a face closes to a point (`Taper`); a
|
||||
/// warning says what was done.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FilletFit {
|
||||
#[default]
|
||||
Exact,
|
||||
Reduce,
|
||||
Taper,
|
||||
}
|
||||
|
||||
impl FilletFit {
|
||||
@@ -1011,6 +1035,13 @@ impl FilletStyle {
|
||||
pub fn is_constant(&self) -> bool {
|
||||
*self == FilletStyle::Constant
|
||||
}
|
||||
|
||||
/// The spline of a [`FilletStyle::Spline`] with its control radii `values` (the first is
|
||||
/// the feature's `radius`).
|
||||
pub fn spline(knots: &[f64], values: Vec<f64>) -> solidcraft_kernel::RadiusSpline {
|
||||
let degree = knots.len().saturating_sub(values.len() + 1);
|
||||
solidcraft_kernel::RadiusSpline { degree, knots: knots.to_vec(), radii: values }
|
||||
}
|
||||
}
|
||||
|
||||
/// A face picked by a point on it, on a named body.
|
||||
@@ -1206,6 +1237,7 @@ impl FeatureKind {
|
||||
use FeatureKind as K;
|
||||
match self {
|
||||
K::Extrude { sketch, .. }
|
||||
| K::SurfaceExtrude { sketch, .. }
|
||||
| K::Revolve { sketch, .. }
|
||||
| K::Emboss { sketch, .. }
|
||||
| K::Rib { sketch, .. }
|
||||
@@ -1337,6 +1369,7 @@ impl FeatureKind {
|
||||
FeatureKind::Align { .. } => "AlignFeature",
|
||||
FeatureKind::Remove { .. } => "RemoveFeature",
|
||||
FeatureKind::Patch { .. } => "PatchFeature",
|
||||
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrudeFeature",
|
||||
FeatureKind::Stitch { .. } => "StitchFeature",
|
||||
FeatureKind::Thicken { .. } => "ThickenFeature",
|
||||
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
|
||||
@@ -1403,6 +1436,7 @@ impl FeatureKind {
|
||||
FeatureKind::Align { .. } => "Align",
|
||||
FeatureKind::Remove { .. } => "Remove",
|
||||
FeatureKind::Patch { .. } => "Patch",
|
||||
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrude",
|
||||
FeatureKind::Stitch { .. } => "Stitch",
|
||||
FeatureKind::Thicken { .. } => "Thicken",
|
||||
FeatureKind::SurfaceTrim { .. } => "Trim",
|
||||
@@ -1423,7 +1457,9 @@ impl FeatureKind {
|
||||
| FeatureKind::Sweep { sketch, profiles, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles, .. }
|
||||
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sk), profiles, .. } => vec![(*sk, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sk), profiles, .. } | FeatureKind::SurfaceExtrude { sketch: sk, profiles: Some(profiles), .. } => {
|
||||
vec![(*sk, profiles)]
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } => sections.iter_mut().filter(|s| s.point.is_none()).map(|s| (s.sketch, &mut s.profiles)).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
@@ -1437,7 +1473,9 @@ impl FeatureKind {
|
||||
| FeatureKind::Sweep { sketch, profiles, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles, .. }
|
||||
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sketch), profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sketch), profiles, .. } | FeatureKind::SurfaceExtrude { sketch, profiles: Some(profiles), .. } => {
|
||||
vec![(*sketch, profiles)]
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } => {
|
||||
sections.iter().filter(|section| section.point.is_none()).map(|section| (section.sketch, §ion.profiles)).collect()
|
||||
}
|
||||
@@ -1460,6 +1498,8 @@ impl FeatureKind {
|
||||
FeatureKind::Thread { face, .. } => vec![face],
|
||||
FeatureKind::Emboss { face: Some(face), .. } => vec![&mut face.at],
|
||||
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
|
||||
// With bend edges the fixed face is found by name (a plate keeps its point).
|
||||
FeatureKind::SheetConvert { face, bends, .. } if !bends.is_empty() => vec![face],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1483,7 +1523,7 @@ impl FeatureKind {
|
||||
| FeatureKind::ConstructionPlane { plane }
|
||||
| FeatureKind::Split { plane, .. }
|
||||
| FeatureKind::SplitFace { plane, .. } => plane_exprs(plane, &mut v),
|
||||
FeatureKind::Extrude { extent, .. } => {
|
||||
FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } => {
|
||||
v.push(&extent.distance);
|
||||
if let Some(d) = &extent.distance2 {
|
||||
v.push(d);
|
||||
@@ -1504,9 +1544,13 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::Fillet { radius, style, .. } => {
|
||||
v.push(radius);
|
||||
if let FilletStyle::Variable { radius2, mid, .. } = style {
|
||||
v.push(radius2);
|
||||
v.extend(mid.iter().map(|(_, r)| r.as_str()));
|
||||
match style {
|
||||
FilletStyle::Variable { radius2, mid, .. } => {
|
||||
v.push(radius2);
|
||||
v.extend(mid.iter().map(|(_, r)| r.as_str()));
|
||||
}
|
||||
FilletStyle::Spline { radii, .. } => v.extend(radii.iter().map(String::as_str)),
|
||||
FilletStyle::Constant | FilletStyle::Chord => {}
|
||||
}
|
||||
}
|
||||
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
|
||||
@@ -1777,6 +1821,10 @@ pub struct Document {
|
||||
/// Browser groups (folders of bodies, sketches or construction planes).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub browser_groups: Vec<BrowserGroup>,
|
||||
/// Named folders used to organise features in the timeline. They do not affect evaluation
|
||||
/// order; feature ids remain the source of truth when features are renamed or reordered.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub timeline_groups: Vec<TimelineGroup>,
|
||||
/// Browser item order per component folder (`"<component>/<folder>"` → item keys).
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub browser_order: std::collections::BTreeMap<String, Vec<String>>,
|
||||
@@ -1843,6 +1891,66 @@ pub struct BrowserGroup {
|
||||
pub items: Vec<String>,
|
||||
}
|
||||
|
||||
/// A saved feature-history folder. Folders may nest, and a feature can belong to at most one folder.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TimelineGroup {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// The containing feature folder. `None` places the folder at the timeline root.
|
||||
#[serde(default)]
|
||||
pub parent: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub features: Vec<u64>,
|
||||
}
|
||||
|
||||
/// The first feature-history folder whose own and nested features do not form one
|
||||
/// uninterrupted run in the timeline.
|
||||
pub fn non_contiguous_timeline_group(features: &[Feature], groups: &[TimelineGroup]) -> Option<u64> {
|
||||
let positions: std::collections::BTreeMap<u64, usize> = features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
|
||||
let mut stats = std::collections::BTreeMap::new();
|
||||
let mut children = std::collections::BTreeMap::new();
|
||||
for group in groups {
|
||||
let mut count = 0usize;
|
||||
let mut first = usize::MAX;
|
||||
let mut last = 0usize;
|
||||
for index in group.features.iter().filter_map(|feature| positions.get(feature)) {
|
||||
count = count.saturating_add(1);
|
||||
first = first.min(*index);
|
||||
last = last.max(*index);
|
||||
}
|
||||
stats.insert(group.id, (count, first, last));
|
||||
children.entry(group.id).or_insert(0usize);
|
||||
if let Some(parent) = group.parent {
|
||||
let child_count = children.entry(parent).or_insert(0);
|
||||
*child_count = child_count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
let parents: std::collections::BTreeMap<u64, Option<u64>> = groups.iter().map(|group| (group.id, group.parent)).collect();
|
||||
let mut pending: Vec<u64> = groups.iter().filter(|group| children.get(&group.id) == Some(&0)).map(|group| group.id).collect();
|
||||
while let Some(id) = pending.pop() {
|
||||
let (count, first, last) = stats.get(&id).copied().unwrap_or_default();
|
||||
if count > 0 && last.saturating_sub(first).saturating_add(1) != count {
|
||||
return Some(id);
|
||||
}
|
||||
if let Some(parent) = parents.get(&id).copied().flatten() {
|
||||
if let Some((parent_count, parent_first, parent_last)) = stats.get_mut(&parent) {
|
||||
*parent_count = parent_count.saturating_add(count);
|
||||
if count > 0 {
|
||||
*parent_first = (*parent_first).min(first);
|
||||
*parent_last = (*parent_last).max(last);
|
||||
}
|
||||
}
|
||||
if let Some(remaining) = children.get_mut(&parent) {
|
||||
*remaining = remaining.saturating_sub(1);
|
||||
if *remaining == 0 {
|
||||
pending.push(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn default_format() -> String {
|
||||
crate::format::format_string(crate::format::FORMAT)
|
||||
}
|
||||
@@ -1883,6 +1991,7 @@ impl Document {
|
||||
canvases: Vec::new(),
|
||||
param_comments: Default::default(),
|
||||
browser_groups: Vec::new(),
|
||||
timeline_groups: Vec::new(),
|
||||
browser_order: Default::default(),
|
||||
named_views: Vec::new(),
|
||||
visibility: Visibility::default(),
|
||||
@@ -2067,6 +2176,40 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// The deepest timeline folder spanning the insertion slot between `marker - 1` and
|
||||
/// `marker`. A slot at a folder's outer boundary is not inside that folder.
|
||||
fn timeline_group_at_insertion(&self, marker: usize) -> Option<u64> {
|
||||
let before = self.features.get(marker.checked_sub(1)?)?.id;
|
||||
let after = self.features.get(marker)?.id;
|
||||
let direct = |feature| self.timeline_groups.iter().find(|group| group.features.contains(&feature)).map(|group| group.id);
|
||||
let parents: std::collections::BTreeMap<u64, Option<u64>> = self.timeline_groups.iter().map(|group| (group.id, group.parent)).collect();
|
||||
|
||||
let mut before_ancestors = std::collections::BTreeSet::new();
|
||||
let mut current = direct(before);
|
||||
let mut steps = 0usize;
|
||||
while let Some(id) = current {
|
||||
if !before_ancestors.insert(id) || steps >= self.timeline_groups.len() {
|
||||
return None;
|
||||
}
|
||||
current = parents.get(&id).copied().flatten();
|
||||
steps = steps.saturating_add(1);
|
||||
}
|
||||
|
||||
current = direct(after);
|
||||
steps = 0;
|
||||
while let Some(id) = current {
|
||||
if before_ancestors.contains(&id) {
|
||||
return Some(id);
|
||||
}
|
||||
if steps >= self.timeline_groups.len() {
|
||||
return None;
|
||||
}
|
||||
current = parents.get(&id).copied().flatten();
|
||||
steps = steps.saturating_add(1);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Insert a feature at the marker (or the end) and return its id.
|
||||
pub fn add_feature(&mut self, kind: FeatureKind, name: Option<&str>) -> Result<u64> {
|
||||
if self.features.len() >= MAX_FEATURES {
|
||||
@@ -2098,6 +2241,7 @@ impl Document {
|
||||
shell_piece_order: false,
|
||||
kind,
|
||||
};
|
||||
let insertion_group = self.marker.and_then(|marker| self.timeline_group_at_insertion(marker));
|
||||
match self.marker {
|
||||
Some(m) if m < self.features.len() => {
|
||||
self.features.insert(m, f);
|
||||
@@ -2105,6 +2249,14 @@ impl Document {
|
||||
}
|
||||
_ => self.features.push(f),
|
||||
}
|
||||
if let Some(group_id) = insertion_group {
|
||||
let positions: std::collections::BTreeMap<u64, usize> =
|
||||
self.features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
|
||||
if let Some(group) = self.timeline_groups.iter_mut().find(|group| group.id == group_id) {
|
||||
group.features.push(id);
|
||||
group.features.sort_by_key(|feature| positions.get(feature).copied().unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
self.name_feature_inputs(id);
|
||||
Ok(id)
|
||||
}
|
||||
@@ -2119,6 +2271,9 @@ impl Document {
|
||||
.rules
|
||||
.retain(|rule| !rule.references_features(&gone) && !rule.parameter_references().iter().any(|name| gone_params.contains(name)));
|
||||
self.features.retain(|f| !gone.contains(&f.id));
|
||||
for group in &mut self.timeline_groups {
|
||||
group.features.retain(|feature| !gone.contains(feature));
|
||||
}
|
||||
// Configuration columns of deleted features go with them.
|
||||
let keep: Vec<bool> = self
|
||||
.configs
|
||||
|
||||
+236
-57
@@ -265,7 +265,8 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
|
||||
|
||||
/// A constant fillet of `edges`; with [`FilletFit::Reduce`](crate::FilletFit::Reduce), a radius too
|
||||
/// large for the geometry around them is reduced where it doesn't fit, to the largest that works
|
||||
/// for each group of edges sharing a face; the radius per edge is returned.
|
||||
/// for each group of edges sharing a face; the radius per edge is returned. With
|
||||
/// [`FilletFit::Taper`](crate::FilletFit::Taper) it tapers along each edge it doesn't fit.
|
||||
fn fillet_fitted(
|
||||
body: &kernel::Body,
|
||||
edges: &[Vec3],
|
||||
@@ -278,6 +279,7 @@ fn fillet_fitted(
|
||||
Some((used, b)) => Ok((b, used.iter().any(|u| *u < r).then_some(used))),
|
||||
None => Err(kernel::KernelError::TooLarge(why)),
|
||||
},
|
||||
Err(kernel::KernelError::TooLarge(_)) if fit == crate::FilletFit::Taper => kernel::fillet_tapered(body, edges, r).map(|(b, _)| (b, None)),
|
||||
other => other.map(|b| (b, None)),
|
||||
}
|
||||
}
|
||||
@@ -296,6 +298,30 @@ fn reduced_note(used: &[f64], asked: f64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The warning of a fillet that tapered on some of its edges (a taper per edge, None where the
|
||||
/// radius is constant), naming the smallest radius a taper ends at.
|
||||
fn tapered_note(tapers: &[Option<kernel::Taper>]) -> Option<String> {
|
||||
let ends: Vec<f64> = tapers.iter().flatten().flat_map(|t| [t.spline.radii.first(), t.spline.radii.last()]).flatten().copied().collect();
|
||||
let low = ends.into_iter().reduce(f64::min)?;
|
||||
let n = tapers.iter().flatten().count();
|
||||
Some(format!("the radius tapers to the room on {n} of {} edges, down to {} mm", tapers.len(), show_mm(low)))
|
||||
}
|
||||
|
||||
/// Blend `edges` one at a time with `blend`, each blend's lineage over the one before: the
|
||||
/// result and the lineage of what the last blend started from.
|
||||
fn one_by_one(input: Traced, edges: &[Vec3], blend: impl Fn(&kernel::Body, Vec3) -> kernel::Result<kernel::Body>) -> Result<(kernel::Body, Traced)> {
|
||||
let mut b = input;
|
||||
let mut from = None;
|
||||
for p in edges {
|
||||
let next = Traced::step(blend(&b.body, *p)?, &[&b]);
|
||||
from = Some(std::mem::replace(&mut b, next));
|
||||
}
|
||||
match from {
|
||||
Some(prev) => Ok((b.body, prev)),
|
||||
None => Err(DocError::Invalid("no edges to fillet".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add `note` to a feature's warning.
|
||||
fn append_warning(warning: &mut Option<String>, note: String) {
|
||||
*warning = Some(match warning.take() {
|
||||
@@ -322,6 +348,11 @@ impl ModelState {
|
||||
self.fitted.get(&feature)?.iter().fold(None, |acc, r| Some(acc.map_or((*r, *r), |(lo, hi): (f64, f64)| (lo.min(*r), hi.max(*r)))))
|
||||
}
|
||||
|
||||
/// Each edge fillet `feature` tapered (a point on it) with the radius spline it was given.
|
||||
pub fn tapered(&self, feature: u64) -> Option<&[(Vec3, kernel::Taper)]> {
|
||||
self.tapered.get(&feature).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Each edge fillet `feature` blended (a point on it) with the radius it used, when it
|
||||
/// reduced a radius too large for some of its edges.
|
||||
pub fn fitted_edges(&self, feature: u64) -> Option<Vec<(Vec3, f64)>> {
|
||||
@@ -353,6 +384,9 @@ pub struct ModelState {
|
||||
/// The radius per edge a fillet used when it reduced a radius too large for some of its edges
|
||||
/// (by feature id, see `FilletFit::Reduce`).
|
||||
fitted: BTreeMap<u64, Vec<f64>>,
|
||||
/// The radius spline of each edge a fillet tapered (a point on the edge with it, by feature
|
||||
/// id, see `FilletFit::Taper`).
|
||||
tapered: BTreeMap<u64, Vec<(Vec3, kernel::Taper)>>,
|
||||
}
|
||||
|
||||
impl ModelState {
|
||||
@@ -384,6 +418,16 @@ impl ModelState {
|
||||
r.to_bits().hash(&mut h);
|
||||
}
|
||||
}
|
||||
for (feature, tapers) in &self.tapered {
|
||||
feature.hash(&mut h);
|
||||
for (p, t) in tapers {
|
||||
let s = &t.spline;
|
||||
for x in [p.x, p.y, p.z, t.start.x, t.start.y, t.start.z].iter().chain(&s.knots).chain(&s.radii) {
|
||||
x.to_bits().hash(&mut h);
|
||||
}
|
||||
s.degree.hash(&mut h);
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
|
||||
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
|
||||
h.finish()
|
||||
@@ -459,6 +503,8 @@ impl ModelState {
|
||||
pub struct EvalCache {
|
||||
map: std::collections::HashMap<u64, CachedEval>,
|
||||
order: std::collections::VecDeque<u64>,
|
||||
/// What the stored results cost together.
|
||||
bytes: usize,
|
||||
pub hits: usize,
|
||||
}
|
||||
|
||||
@@ -469,11 +515,20 @@ struct CachedEval {
|
||||
boolean_diagnostics: kernel::BooleanDiagnostics,
|
||||
ms: f64,
|
||||
state: Arc<ModelState>,
|
||||
/// What the state costs, worked out once when it was stored (see
|
||||
/// [`ModelState::retained_bytes`]).
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
/// Results kept (oldest dropped first).
|
||||
const CACHE_ENTRIES: usize = 4096;
|
||||
|
||||
/// Bytes of results kept (oldest dropped first). A cached result is as big as the model it
|
||||
/// holds, so bounding the cache by entry count alone let a long editing session keep every
|
||||
/// superseded state: the entries nothing else refers to any more are exactly the ones a byte
|
||||
/// budget drops.
|
||||
const CACHE_BYTES: usize = 64 << 20;
|
||||
|
||||
impl EvalCache {
|
||||
fn get(&mut self, key: u64) -> Option<CachedEval> {
|
||||
let r = self.map.get(&key).cloned();
|
||||
@@ -483,18 +538,43 @@ impl EvalCache {
|
||||
r
|
||||
}
|
||||
fn put(&mut self, key: u64, e: CachedEval) {
|
||||
if self.map.insert(key, e).is_none() {
|
||||
self.order.push_back(key);
|
||||
let bytes = e.bytes;
|
||||
match self.map.insert(key, e) {
|
||||
Some(old) => self.bytes = self.bytes.saturating_sub(old.bytes),
|
||||
None => self.order.push_back(key),
|
||||
}
|
||||
while self.order.len() > CACHE_ENTRIES {
|
||||
if let Some(k) = self.order.pop_front() {
|
||||
self.map.remove(&k);
|
||||
self.bytes = self.bytes.saturating_add(bytes);
|
||||
// One result is always kept, so a single state larger than the budget cannot empty the
|
||||
// cache and force a full recompute on every undo.
|
||||
while self.order.len() > CACHE_ENTRIES || (self.bytes > CACHE_BYTES && self.order.len() > 1) {
|
||||
if let Some(k) = self.order.pop_front()
|
||||
&& let Some(old) = self.map.remove(&k)
|
||||
{
|
||||
self.bytes = self.bytes.saturating_sub(old.bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SolvedSketch {
|
||||
/// Rough heap footprint (see [`ModelState::retained_bytes`]).
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
let entities = self.sketch.points.len() + self.sketch.curves.len() + self.sketch.constraints.len() + self.sketch.links.len();
|
||||
let loops: usize = self.profiles.iter().map(|p| p.region.outer.segs.len() + p.region.holes.iter().map(|h| h.segs.len()).sum::<usize>()).sum();
|
||||
entities * 192 + loops * 64 + 512
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelState {
|
||||
/// Rough heap footprint, for the evaluation cache's byte budget. The bodies' B-rep
|
||||
/// topology is what makes a kept timeline snapshot expensive, so it dominates; sketches
|
||||
/// and the rest are counted from their entity counts.
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
let bodies: usize = self.bodies.iter().map(|b| b.body.retained_bytes()).sum();
|
||||
let sketches: usize = self.sketches.iter().map(|s| s.retained_bytes()).sum();
|
||||
bodies + sketches + self.sheets.len() * 4096 + self.threads.len() * 192 + self.construct.len() * 192
|
||||
}
|
||||
|
||||
pub fn body(&self, name: &str) -> Option<&ModelBody> {
|
||||
self.bodies.iter().find(|b| b.name == name)
|
||||
}
|
||||
@@ -1048,6 +1128,7 @@ impl Model {
|
||||
warning: warning.clone(),
|
||||
boolean_diagnostics: boolean_diagnostics.clone(),
|
||||
ms,
|
||||
bytes: state.retained_bytes(),
|
||||
state: state.clone(),
|
||||
},
|
||||
);
|
||||
@@ -1872,6 +1953,63 @@ fn hole_spots_raw(st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The cutting tools of a hole feature at every spot, each with its exact geometry when it is a
|
||||
/// straight cylinder (see [`hole_tools_and_prisms`]).
|
||||
fn hole_feature_tools(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> Result<Vec<(Traced, Option<more::PrimitivePrism>)>> {
|
||||
let FeatureKind::Hole { diameter, depth, hole, to, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) };
|
||||
let (spots, dir) = hole_spots(st, f)?;
|
||||
if spots.is_empty() || spots.len() > 10_000 {
|
||||
return Err(DocError::Invalid("a hole needs 1…10000 points".into()));
|
||||
}
|
||||
let mut tools = Vec::new();
|
||||
for at in spots {
|
||||
let depth = hole_depth_to(st, &at, dir, to, depth)?;
|
||||
tools.extend(hole_tools_and_prisms(vals, st, &at, dir, diameter, &depth, hole)?);
|
||||
}
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// A hole drilled square into sheet metal whose tools are straight cylinders (a plain hole, a
|
||||
/// counterbore) is machined like the equivalent extrude cut, so the sheet keeps it as a cut-out
|
||||
/// or pocket of its flat pattern (#560). Returns the sheet bodies it machined; none, with the
|
||||
/// model as it was, when a tool that meets a sheet can't be kept that way (the ordinary cut then
|
||||
/// refuses it).
|
||||
fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option<more::PrimitivePrism>)]) -> Result<Vec<String>> {
|
||||
if st.sheets.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let saved = (st.sheets.clone(), st.bodies.clone());
|
||||
let mut handled: Vec<String> = Vec::new();
|
||||
for (tool, prism) in tools {
|
||||
let tool = &tool.body;
|
||||
let done = match prism {
|
||||
Some(prism) => more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
if done.is_empty() {
|
||||
let mut meets = false;
|
||||
for sheet in &st.sheets {
|
||||
if let Some(mb) = st.body(&sheet.body)
|
||||
&& kernel::boolean(&mb.body, tool, BoolOp::Intersect)?.is_some()
|
||||
{
|
||||
meets = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if meets {
|
||||
(st.sheets, st.bodies) = saved;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
}
|
||||
for name in done {
|
||||
if !handled.contains(&name) {
|
||||
handled.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
/// The cutting tools of one hole at `position`, drilling along `dir`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn hole_tools(
|
||||
@@ -1882,7 +2020,24 @@ fn hole_tools(
|
||||
diameter: &str,
|
||||
depth: &Option<String>,
|
||||
hole: &HoleKind,
|
||||
) -> Result<Vec<Body>> {
|
||||
) -> Result<Vec<Traced>> {
|
||||
Ok(hole_tools_and_prisms(vals, st, position, dir, diameter, depth, hole)?.into_iter().map(|(tool, _)| tool).collect())
|
||||
}
|
||||
|
||||
/// [`hole_tools`], each with its exact geometry when it is a straight cylinder along the axis
|
||||
/// (a plain hole, a counterbore, a drill point left in the air past a through hole), so sheet
|
||||
/// metal can keep it as a flat cut-out or pocket. Each tool's faces are named by the part of the
|
||||
/// hole it cuts (`bore`, `cbore`, `csink`: `F7:cbore:base` is a counterbore's floor).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn hole_tools_and_prisms(
|
||||
vals: &BTreeMap<String, Value>,
|
||||
st: &ModelState,
|
||||
position: &Vec3,
|
||||
dir: Vec3,
|
||||
diameter: &str,
|
||||
depth: &Option<String>,
|
||||
hole: &HoleKind,
|
||||
) -> Result<Vec<(Traced, Option<more::PrimitivePrism>)>> {
|
||||
let r = val(vals, diameter, Kind::Length)? / 2.0;
|
||||
if !(r > 1e-6) {
|
||||
return Err(DocError::Invalid("hole diameter must be positive".into()));
|
||||
@@ -1905,12 +2060,15 @@ fn hole_tools(
|
||||
seam.set(seam.get() + 0.613);
|
||||
Region2 { outer: solidcraft_geom::Loop2::circle_from(Vec2::ZERO, rad, seam.get()), holes: vec![] }
|
||||
};
|
||||
let cyl = |rad: f64, lo: f64, hi: f64| -> Result<Body> {
|
||||
kernel::extrude(&plane, &[disc(rad)], lo, hi)?.pop().ok_or_else(|| DocError::Invalid("hole tool".into()))
|
||||
let cyl = |rad: f64, lo: f64, hi: f64, part: &str| -> Result<(Traced, Option<more::PrimitivePrism>)> {
|
||||
let tool = kernel::extrude(&plane, &[disc(rad)], lo, hi)?.pop().ok_or_else(|| DocError::Invalid("hole tool".into()))?;
|
||||
let axis = plane.normal();
|
||||
let prism = more::PrimitivePrism::Cylinder { base: *position + axis * lo, axis, radius: rad, height: hi - lo };
|
||||
Ok((crate::lineage::part_tool(tool, part), Some(prism)))
|
||||
};
|
||||
let mut tools = Vec::new();
|
||||
match hole {
|
||||
HoleKind::Simple => tools.push(cyl(r, bottom, top)?),
|
||||
HoleKind::Simple => tools.push(cyl(r, bottom, top, "bore")?),
|
||||
HoleKind::Drilled { tip_angle } => {
|
||||
let half = val(vals, tip_angle, Kind::Angle)? / 2.0;
|
||||
// A hole exactly as deep as the material: the point is in the air past the exit
|
||||
@@ -1927,9 +2085,13 @@ fn hole_tools(
|
||||
let rp = Plane::new(*position, side, up).ok_or_else(|| DocError::Invalid("hole axis".into()))?;
|
||||
let pts = [Vec2::new(0.0, top), Vec2::new(r, top), Vec2::new(r, bottom), Vec2::new(0.0, bottom - tip)];
|
||||
let region = Region2 { outer: solidcraft_geom::Loop2::polygon(&pts), holes: vec![] };
|
||||
tools.extend(kernel::revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?);
|
||||
tools.extend(
|
||||
kernel::revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?
|
||||
.into_iter()
|
||||
.map(|tool| (crate::lineage::part_tool(tool, "bore"), None)),
|
||||
);
|
||||
} else {
|
||||
tools.push(cyl(r, bottom, top)?);
|
||||
tools.push(cyl(r, bottom, top, "bore")?);
|
||||
}
|
||||
}
|
||||
HoleKind::Counterbore { cb_diameter, cb_depth } => {
|
||||
@@ -1938,8 +2100,8 @@ fn hole_tools(
|
||||
return Err(DocError::Invalid("the counterbore must be wider than the hole and shallower than it".into()));
|
||||
}
|
||||
// Wide and shallow first: the narrow hole then crosses the pocket floor.
|
||||
tools.push(cyl(rc, -dc, top)?);
|
||||
tools.push(cyl(r, bottom, top)?);
|
||||
tools.push(cyl(rc, -dc, top, "cbore")?);
|
||||
tools.push(cyl(r, bottom, top, "bore")?);
|
||||
}
|
||||
HoleKind::Countersink { cs_diameter, cs_angle } => {
|
||||
let (rc, half) = (val(vals, cs_diameter, Kind::Length)? / 2.0, val(vals, cs_angle, Kind::Angle)? / 2.0);
|
||||
@@ -1951,8 +2113,8 @@ fn hole_tools(
|
||||
let r_end = r * 0.9;
|
||||
let len = (r_top - r_end) / half.tan();
|
||||
let top_plane = plane.offset(top);
|
||||
tools.push(kernel::extrude_tapered(&top_plane, &disc(r_top), len, -1.0, -half)?);
|
||||
tools.push(cyl(r, bottom, top)?);
|
||||
tools.push((crate::lineage::part_tool(kernel::extrude_tapered(&top_plane, &disc(r_top), len, -1.0, -half)?, "csink"), None));
|
||||
tools.push(cyl(r, bottom, top, "bore")?);
|
||||
}
|
||||
}
|
||||
Ok(tools)
|
||||
@@ -2104,10 +2266,6 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc
|
||||
}
|
||||
|
||||
/// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state.
|
||||
fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -> Result<Vec<Body>> {
|
||||
Ok(feature_tools_with_constructor(doc, vals, f, st, &mut None)?.into_iter().map(|t| t.body).collect())
|
||||
}
|
||||
|
||||
fn feature_tools_with_constructor(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
@@ -2181,12 +2339,7 @@ fn feature_tools_with_constructor(
|
||||
};
|
||||
// To Object gives a signed distance from the start: one side only.
|
||||
let direction = if extent.to.is_some() { crate::Direction::Positive } else { extent.direction };
|
||||
let (a, b) = match (&extent.distance2, direction) {
|
||||
(Some(d2), _) => (-val(vals, d2, Kind::Length)?, d),
|
||||
(None, crate::Direction::Positive) => (0.0, d),
|
||||
(None, crate::Direction::Negative) => (-d, 0.0),
|
||||
(None, crate::Direction::Symmetric) => (-d, d),
|
||||
};
|
||||
let (a, b) = extent_sides(vals, extent, d, direction)?;
|
||||
// A negative distance flips the side.
|
||||
let (mut lo, mut hi) = if a <= b { (a, b) } else { (b, a) };
|
||||
// To a curved (or slanted) face: run right through the target body, then keep what
|
||||
@@ -2417,7 +2570,8 @@ fn feature_tools_with_constructor(
|
||||
let id = pt.point.as_deref().unwrap_or_default();
|
||||
let q = ps.sketch.points.iter().find(|p| p.id == id).ok_or_else(|| DocError::Unknown(format!("point `{id}` in {}", ps.name)))?;
|
||||
let body = kernel::loft_to_point(&ss.plane, &r.outer, ps.plane.to_world(q.pos))?;
|
||||
Ok(vec![crate::lineage::ProfileRoles::swept(None, None).tool(body, None)])
|
||||
// Its sides are named by the profile's curves, its base `start`.
|
||||
Ok(vec![crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None)])
|
||||
}
|
||||
FeatureKind::Coil { base, axis, diameter, pitch, turns, section_size, section, position, start_angle, clockwise, .. } => {
|
||||
let base = &point(vals, base)?;
|
||||
@@ -2460,6 +2614,7 @@ fn feature_tools_with_constructor(
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } => {
|
||||
let mut secs = Vec::new();
|
||||
let mut sketches = Vec::new();
|
||||
for sec in sections {
|
||||
let ss = st
|
||||
.sketch(sec.sketch)
|
||||
@@ -2467,11 +2622,13 @@ fn feature_tools_with_constructor(
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, &sec.profiles)?);
|
||||
let [r] = ®ions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
|
||||
secs.push((ss.plane, r.outer.clone()));
|
||||
sketches.push(ss);
|
||||
}
|
||||
// Its caps are the first and last sections; which section's curve a side follows is
|
||||
// not recorded, so the sides are named from geometry.
|
||||
// Its caps are the first and last sections, its sides named by the curves they run
|
||||
// between.
|
||||
let body = kernel::loft(&secs)?;
|
||||
Ok(vec![crate::lineage::ProfileRoles::swept(None, None).tool(body, None)])
|
||||
let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() };
|
||||
Ok(vec![roles.tool(body)])
|
||||
}
|
||||
FeatureKind::Sweep { sketch, profiles, path_sketch, path, .. } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
|
||||
@@ -2536,18 +2693,7 @@ fn feature_tools_with_constructor(
|
||||
FeatureKind::Torus { center, major, minor, .. } => {
|
||||
Ok(unnamed(vec![kernel::torus(point(vals, center)?, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?]))
|
||||
}
|
||||
FeatureKind::Hole { diameter, depth, hole, to, .. } => {
|
||||
let (spots, dir) = hole_spots(st, f)?;
|
||||
if spots.is_empty() || spots.len() > 10_000 {
|
||||
return Err(DocError::Invalid("a hole needs 1…10000 points".into()));
|
||||
}
|
||||
let mut tools = Vec::new();
|
||||
for at in spots {
|
||||
let depth = hole_depth_to(st, &at, dir, to, depth)?;
|
||||
tools.extend(hole_tools(vals, st, &at, dir, diameter, &depth, hole)?);
|
||||
}
|
||||
Ok(unnamed(tools))
|
||||
}
|
||||
FeatureKind::Hole { .. } => Ok(hole_feature_tools(vals, st, f)?.into_iter().map(|(tool, _)| tool).collect()),
|
||||
_ => Err(DocError::Invalid(format!("{} cannot be patterned or mirrored yet", f.name))),
|
||||
}
|
||||
}
|
||||
@@ -2741,6 +2887,17 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<k
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Where an extrude of distance `d` starts and ends along the sketch normal, before any start
|
||||
/// offset: one side, the other, both (symmetric), or a second distance on the other side.
|
||||
pub(crate) fn extent_sides(vals: &BTreeMap<String, Value>, extent: &crate::Extent, d: f64, direction: crate::Direction) -> Result<(f64, f64)> {
|
||||
Ok(match (&extent.distance2, direction) {
|
||||
(Some(d2), _) => (-val(vals, d2, Kind::Length)?, d),
|
||||
(None, crate::Direction::Positive) => (0.0, d),
|
||||
(None, crate::Direction::Negative) => (-d, 0.0),
|
||||
(None, crate::Direction::Symmetric) => (-d, d),
|
||||
})
|
||||
}
|
||||
|
||||
/// Operation and targets of a feature that makes tools.
|
||||
fn feature_op(f: &Feature) -> (Operation, Vec<String>) {
|
||||
match &f.kind {
|
||||
@@ -2997,7 +3154,8 @@ fn replay(
|
||||
},
|
||||
_ => kernel::fillet(&mb.body, &moved, r)?,
|
||||
};
|
||||
let nb = Traced::step(nb, &[&mb.traced()]);
|
||||
// A copy blends edges the pattern's own copies made: named after the body so far.
|
||||
let nb = mb.traced().then(nb);
|
||||
if let Some(slot) = st.bodies.get_mut(ti) {
|
||||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||||
}
|
||||
@@ -3011,13 +3169,18 @@ fn replay(
|
||||
// a blind hole's depth counts from the face at the copy, not from the bottom of the
|
||||
// original hole under the original spot.
|
||||
let (spots, dir) = hole_spots_raw(st, src)?;
|
||||
for m in mats {
|
||||
for (k, m) in mats.iter().enumerate() {
|
||||
let d = crate::apply_vector(m, dir).normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
|
||||
for p in &spots {
|
||||
let at = hole_on_surface(st, crate::apply_point(m, *p), d);
|
||||
let to = to.map(|t| crate::apply_point(m, t));
|
||||
let depth = hole_depth_to(st, &at, d, &to, depth)?;
|
||||
copies.extend(hole_tools(vals, st, &at, d, diameter, &depth, hole)?.into_iter().map(Traced::unnamed));
|
||||
// Each copy's faces are named as the hole's, with the instance.
|
||||
copies.extend(
|
||||
hole_tools(vals, st, &at, d, diameter, &depth, hole)?
|
||||
.into_iter()
|
||||
.map(|t| Traced { lineage: t.lineage.map(|l| l.copied(src.id, k + 1)), body: t.body }),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -3352,7 +3515,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::Stitch { .. }
|
||||
| FeatureKind::Thicken { .. }
|
||||
| FeatureKind::SurfaceTrim { .. }
|
||||
| FeatureKind::SurfaceExtend { .. } => surface::eval(doc, vals, f, st),
|
||||
| FeatureKind::SurfaceExtend { .. }
|
||||
| FeatureKind::SurfaceExtrude { .. } => surface::eval(doc, vals, f, st),
|
||||
FeatureKind::Emboss { .. }
|
||||
| FeatureKind::Rib { .. }
|
||||
| FeatureKind::ReplaceFace { .. }
|
||||
@@ -3419,8 +3583,14 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
FeatureKind::Hole { thread, diameter, .. } => {
|
||||
let (spots, dir) = hole_spots(st, f)?;
|
||||
let tools = feature_tools(doc, vals, f, st)?;
|
||||
apply_op(st, f, tools, Operation::Cut, &[])?;
|
||||
let tools = hole_feature_tools(vals, st, f)?;
|
||||
let handled = sheet_hole_cut(st, f, &tools)?;
|
||||
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
|
||||
if handled.is_empty() {
|
||||
apply_traced(st, f, tools, Operation::Cut, &[])?;
|
||||
} else {
|
||||
apply_beside_sheet_cut(st, f, tools, Operation::Cut, &[], &handled)?;
|
||||
}
|
||||
if let Some(d) = thread {
|
||||
// A point on each hole's wall, a little below the entry.
|
||||
let r = val(vals, diameter, Kind::Length)? / 2.0;
|
||||
@@ -3752,17 +3922,26 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
for (t, e) in mid {
|
||||
pts.push((*t, val(vals, e, Kind::Length)?));
|
||||
}
|
||||
// One edge at a time: each blend's lineage over the one before.
|
||||
let mut b = input;
|
||||
let mut from = None;
|
||||
for p in &edges {
|
||||
let next = Traced::step(kernel::fillet_variable_points(&b.body, *p, *start, &pts, *smooth)?, &[&b]);
|
||||
from = Some(std::mem::replace(&mut b, next));
|
||||
one_by_one(input, &edges, |b, p| kernel::fillet_variable_points(b, p, *start, &pts, *smooth))?
|
||||
}
|
||||
FeatureKind::Fillet { style: crate::FilletStyle::Spline { start, knots, radii }, .. } => {
|
||||
let mut values = vec![r];
|
||||
for e in radii {
|
||||
values.push(val(vals, e, Kind::Length)?);
|
||||
}
|
||||
match from {
|
||||
Some(prev) => (b.body, prev),
|
||||
None => return Err(DocError::Invalid("no edges to fillet".into())),
|
||||
let law = crate::FilletStyle::spline(knots, values);
|
||||
one_by_one(input, &edges, |b, p| kernel::fillet_variable_spline(b, p, *start, &law))?
|
||||
}
|
||||
FeatureKind::Fillet { fit: crate::FilletFit::Taper, .. } => {
|
||||
let (b, tapers) = kernel::fillet_tapered(&mb.body, &edges, r)?;
|
||||
if let Some(note) = tapered_note(&tapers) {
|
||||
append_warning(warning, note);
|
||||
}
|
||||
let tapered: Vec<(Vec3, kernel::Taper)> = edges.iter().copied().zip(tapers).filter_map(|(p, t)| Some((p, t?))).collect();
|
||||
if !tapered.is_empty() {
|
||||
st.tapered.insert(f.id, tapered);
|
||||
}
|
||||
(b, input)
|
||||
}
|
||||
FeatureKind::Fillet { fit, .. } => {
|
||||
let (b, used) = fillet_fitted(&mb.body, &edges, r, *fit)?;
|
||||
|
||||
+66
-30
@@ -300,6 +300,17 @@ fn replace_sheet_body(mb: &mut ModelBody, body: Body) {
|
||||
*mb = next;
|
||||
}
|
||||
|
||||
/// Put a converted sheet in the place of the body it was made from.
|
||||
fn put_sheet(st: &mut ModelState, sh: sheet::SheetBody) -> Result<()> {
|
||||
let solid = sh.solid()?;
|
||||
if let Some(b) = st.bodies.iter_mut().find(|b| b.name == sh.body) {
|
||||
replace_sheet_body(b, solid);
|
||||
}
|
||||
st.sheets.retain(|s| s.body != sh.body);
|
||||
st.sheets.push(sh);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> {
|
||||
let sheet = st.sheets.get(idx).cloned().ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
let solid = sheet.solid()?;
|
||||
@@ -340,20 +351,17 @@ fn on_sheet_material(st: &ModelState, i: usize, p: Vec3) -> bool {
|
||||
offsets.iter().any(|&dx| offsets.iter().any(|&dy| offsets.iter().any(|&dz| mesh.contains(p + Vec3::new(dx, dy, dz)))))
|
||||
}
|
||||
|
||||
/// A region mirrored in the sketch x axis (y → −y); curves other than lines and arcs as polylines.
|
||||
/// A region mirrored in the sketch x axis (y → −y), exactly: free-form curves through their
|
||||
/// control points.
|
||||
fn mirror_y(r: &Region2) -> Region2 {
|
||||
let f = |p: Vec2| Vec2::new(p.x, -p.y);
|
||||
let m = |l: &Loop2| {
|
||||
let segs: Vec<Seg2> = l
|
||||
.segs
|
||||
.iter()
|
||||
.flat_map(|s| match *s {
|
||||
Seg2::Line { a, b } => vec![Seg2::Line { a: f(a), b: f(b) }],
|
||||
Seg2::Arc { center, radius, start, sweep } => vec![Seg2::Arc { center: f(center), radius, start: -start, sweep: -sweep }],
|
||||
other => {
|
||||
let pts: Vec<Vec2> = (0..=16).map(|k| f(other.point_at(k as f64 / 16.0))).collect();
|
||||
pts.windows(2).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect()
|
||||
}
|
||||
.map(|s| match *s {
|
||||
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center: f(center), radius, start: -start, sweep: -sweep },
|
||||
other => sheet::map_controls(&other, f),
|
||||
})
|
||||
.collect();
|
||||
Loop2 { segs }.ccw()
|
||||
@@ -549,7 +557,34 @@ pub(super) fn sheet_eval(
|
||||
}
|
||||
rebuild_sheet(st, i)
|
||||
}
|
||||
FeatureKind::SheetConvert { body, face, rule } => {
|
||||
// With bend edges: a solid or shelled body whose faces become the sheet.
|
||||
FeatureKind::SheetConvert { body, face, rule, bends } if !bends.is_empty() => {
|
||||
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let r = doc.sheet_rule(rule.as_deref());
|
||||
let rv = doc.rule_values(vals, &r)?;
|
||||
// The bend edges by name first; a name that is gone keeps its point, with a warning.
|
||||
let mut picks = Vec::with_capacity(bends.len());
|
||||
for (i, p) in bends.iter().enumerate() {
|
||||
let name = f.edge_names.get(i).filter(|n| !n.is_empty());
|
||||
picks.push(match name.map(|n| (n, crate::naming::locate_edge(&mb, n, *p))) {
|
||||
Some((n, Ok(hit))) => {
|
||||
if let Some(note) = hit.note(n) {
|
||||
append_warning(warning, note);
|
||||
}
|
||||
hit.point
|
||||
}
|
||||
Some((n, Err(_))) => {
|
||||
append_warning(warning, format!("edge `{n}` no longer exists; using the edge at the picked point"));
|
||||
*p
|
||||
}
|
||||
None => *p,
|
||||
});
|
||||
}
|
||||
let mut sh = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
|
||||
tag_flanges(&mut sh, 0, f.id);
|
||||
put_sheet(st, sh)
|
||||
}
|
||||
FeatureKind::SheetConvert { body, face, rule, .. } => {
|
||||
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick a planar face of the plate".into()))?;
|
||||
let tol = (mb.body.size() * 1e-3).max(1e-3);
|
||||
@@ -568,20 +603,16 @@ pub(super) fn sheet_eval(
|
||||
let area = region.area().abs();
|
||||
let vol = kernel::measure(&mb.body)?.volume;
|
||||
if (area * t - vol).abs() > 1e-3 * vol.max(1.0) {
|
||||
return Err(DocError::Invalid("not supported yet: converting bodies that are not a plate of even thickness".into()));
|
||||
return Err(DocError::Invalid(
|
||||
"not supported yet: converting bodies that are not a plate of even thickness without bend edges (`bends`)".into(),
|
||||
));
|
||||
}
|
||||
let r = doc.sheet_rule(rule.as_deref());
|
||||
let mut rv = doc.rule_values(vals, &r)?;
|
||||
rv.thickness = t;
|
||||
let mut sh = sheet::base_flange(body, &r.name, &rv, plane, Region2 { outer: region.outer.clone(), holes: Vec::new() });
|
||||
sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect();
|
||||
let solid = sh.solid()?;
|
||||
if let Some(b) = st.bodies.iter_mut().find(|b| &b.name == body) {
|
||||
replace_sheet_body(b, solid);
|
||||
}
|
||||
st.sheets.retain(|s| &s.body != body);
|
||||
st.sheets.push(sh);
|
||||
Ok(())
|
||||
put_sheet(st, sh)
|
||||
}
|
||||
_ => Err(DocError::Invalid(format!("{} is not a sheet metal feature", f.name))),
|
||||
}
|
||||
@@ -718,6 +749,7 @@ pub(super) fn sheet_primitive_cut(f: &Feature, st: &mut ModelState, tool: &Body,
|
||||
}
|
||||
|
||||
/// A primitive tool's exact geometry, for [`sheet_primitive_cut`].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) enum PrimitivePrism {
|
||||
/// An axis-aligned box between two corners.
|
||||
Box { lo: Vec3, hi: Vec3 },
|
||||
@@ -1117,11 +1149,12 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
let base = Plane::new(*position - z * delta, x, z.cross(x)).ok_or_else(|| DocError::Invalid("boss frame".into()))?;
|
||||
let r_in = r0 + delta * dr.tan();
|
||||
let disc = Region2 { outer: Loop2::circle(Vec2::ZERO, r_in), holes: Vec::new() };
|
||||
let mut tools = if dr.abs() > 1e-12 {
|
||||
let post = if dr.abs() > 1e-12 {
|
||||
vec![kernel::extrude_tapered(&base, &disc, h + delta, 1.0, -dr)?]
|
||||
} else {
|
||||
kernel::extrude(&base, &[disc], 0.0, h + delta)?
|
||||
};
|
||||
let mut tools: Vec<Traced> = post.into_iter().map(|b| crate::lineage::part_tool(b, "post")).collect();
|
||||
if let Some(n) = ribs {
|
||||
let n = val(vals, n, Kind::Unitless)?.round();
|
||||
if !(0.0..=64.0).contains(&n) {
|
||||
@@ -1156,19 +1189,19 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
]),
|
||||
holes: Vec::new(),
|
||||
};
|
||||
tools.extend(kernel::extrude(&rp, &[rect], -delta, hr)?);
|
||||
tools.extend(kernel::extrude(&rp, &[rect], -delta, hr)?.into_iter().map(|b| crate::lineage::part_tool(b, &format!("rib{k}"))));
|
||||
}
|
||||
}
|
||||
apply_op(st, f, tools, Operation::Join, std::slice::from_ref(&target))?;
|
||||
apply_traced(st, f, tools, Operation::Join, std::slice::from_ref(&target))?;
|
||||
// The root fillet: rounds the circle where the post meets the face.
|
||||
if rf > 1e-9 {
|
||||
let i = st.bodies.iter().position(|b| b.name == target).ok_or_else(|| DocError::Invalid("boss body".into()))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("boss body".into())) };
|
||||
let mut warn = None;
|
||||
let edges = resolve_edges(&mb.body, &[*position + x * r0], &[], &mut warn)?;
|
||||
let nb = kernel::fillet(&mb.body, &edges, rf)?;
|
||||
let nb = mb.traced().then(kernel::fillet(&mb.body, &edges, rf)?);
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, nb, mb.feature);
|
||||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||||
}
|
||||
}
|
||||
if let Some(hd) = hole_diameter {
|
||||
@@ -1182,7 +1215,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
}
|
||||
let hp = Plane::new(*position, x, z.cross(x)).ok_or_else(|| DocError::Invalid("hole frame".into()))?;
|
||||
let tool = kernel::extrude(&hp, &[Region2 { outer: Loop2::circle(Vec2::ZERO, rh), holes: Vec::new() }], h - depth, h + delta)?;
|
||||
apply_op(st, f, tool, Operation::Cut, std::slice::from_ref(&target))?;
|
||||
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "hole")).collect();
|
||||
apply_traced(st, f, tool, Operation::Cut, std::slice::from_ref(&target))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1215,7 +1249,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
&& let Ok(b) = kernel::raise_band(&mb.body, *face, w, h, *outside)
|
||||
&& let Some(slot) = st.bodies.iter_mut().find(|x| x.name == target)
|
||||
{
|
||||
*slot = ModelBody::new(target.clone(), b, slot.feature);
|
||||
*slot = ModelBody::traced_new(target.clone(), Traced::step(b, &[&mb.traced()]), slot.feature);
|
||||
return Ok(());
|
||||
}
|
||||
// The band along the chosen edge, inside the rim face. A groove's cutter reaches past
|
||||
@@ -1234,11 +1268,11 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
};
|
||||
let delta = (h * 0.05).clamp(1e-3, 0.5);
|
||||
if *groove {
|
||||
let tool = kernel::extrude(&plane, &[band], -(h + g), delta)?;
|
||||
apply_op(st, f, tool, Operation::Cut, std::slice::from_ref(&target))
|
||||
let tool = kernel::extrude(&plane, &[band], -(h + g), delta)?.into_iter().map(|b| crate::lineage::part_tool(b, "groove")).collect();
|
||||
apply_traced(st, f, tool, Operation::Cut, std::slice::from_ref(&target))
|
||||
} else {
|
||||
let tool = kernel::extrude(&plane, &[band], -delta, h)?;
|
||||
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
let tool = kernel::extrude(&plane, &[band], -delta, h)?.into_iter().map(|b| crate::lineage::part_tool(b, "lip")).collect();
|
||||
apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
}
|
||||
}
|
||||
FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, body } => {
|
||||
@@ -1261,7 +1295,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
let sgn = if pl.normal().dot(y) >= 0.0 { 1.0 } else { -1.0 };
|
||||
let (lo, hi) = if sgn > 0.0 { (0.0, w) } else { (-w, 0.0) };
|
||||
let tool = kernel::extrude(&pl, &[Region2 { outer: lp, holes: Vec::new() }], lo, hi)?;
|
||||
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "arm")).collect();
|
||||
apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
}
|
||||
FeatureKind::Rest { position, direction, along, width, length, height, draft, thickness, body } => {
|
||||
let target = body_under(st, body, *position)?;
|
||||
@@ -1310,7 +1345,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
} else {
|
||||
kernel::extrude(&base, &[region], 0.0, h + delta)?
|
||||
};
|
||||
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "rest")).collect();
|
||||
apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
}
|
||||
_ => Err(DocError::Invalid(format!("{} is not a plastic feature", f.name))),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Evaluation of the surface features: Patch, Stitch, Thicken and Trim (kernel `surfaces`).
|
||||
//! Evaluation of the surface features: Patch, Stitch, Thicken, Trim, Extend and Surface Extrude
|
||||
//! (kernel `surfaces`).
|
||||
|
||||
use super::*;
|
||||
use solidcraft_geom::Seg2;
|
||||
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
match &f.kind {
|
||||
@@ -84,6 +86,83 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
)?;
|
||||
if extent.through_all || extent.taper.is_some() || extent.to.is_some() || extent.from.is_some() {
|
||||
return Err(DocError::Invalid("not supported yet: a surface extrude through all, to or from an object, or with a taper".into()));
|
||||
}
|
||||
let d = val(vals, &extent.distance, Kind::Length)?;
|
||||
let off = extent.start_offset.as_deref().map(|e| val(vals, e, Kind::Length)).transpose()?.unwrap_or(0.0);
|
||||
let (a, b) = extent_sides(vals, extent, d, extent.direction)?;
|
||||
let mut chains: Vec<Vec<Seg2>> = Vec::new();
|
||||
if let Some(sel) = profiles {
|
||||
for r in solidcraft_sketch::merge_regions(&select_profiles(&ss, sel)?) {
|
||||
chains.extend(std::iter::once(r.outer).chain(r.holes).map(|lp| lp.segs));
|
||||
}
|
||||
}
|
||||
chains.extend(curve_chains(&ss, curves)?);
|
||||
if chains.is_empty() {
|
||||
return Err(DocError::Invalid("pick the profiles or curves to extrude".into()));
|
||||
}
|
||||
let body = kernel::extrude_sheet(&ss.plane, &chains, a + off, b + off)?;
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::new(name, body, f.id));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(DocError::Invalid("not a surface feature".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Most sketch curves one feature chains.
|
||||
const MAX_CHAIN_CURVES: usize = 1000;
|
||||
|
||||
/// The sketch curves `ids` (free-form ones exactly where they can be) grouped into chains of segments that meet end to end, each oriented
|
||||
/// to run one way. Curves are joined greedily at their free ends: where three meet, one of them
|
||||
/// starts a chain of its own.
|
||||
fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
|
||||
if ids.len() > MAX_CHAIN_CURVES {
|
||||
return Err(DocError::Invalid(format!("at most {MAX_CHAIN_CURVES} curves")));
|
||||
}
|
||||
let mut pieces: std::collections::VecDeque<Vec<Seg2>> = ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?;
|
||||
Ok(ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect();
|
||||
let reversed = |p: Vec<Seg2>| -> Vec<Seg2> { p.iter().rev().map(Seg2::reversed).collect() };
|
||||
let near = |p: Vec2, q: Vec2| p.dist(q) <= 1e-6;
|
||||
let mut chains = Vec::new();
|
||||
while let Some(mut chain) = pieces.pop_front() {
|
||||
while let (Some(head), Some(tail)) = (chain.first().map(Seg2::start), chain.last().map(Seg2::end)) {
|
||||
if near(head, tail) {
|
||||
break;
|
||||
}
|
||||
let ends = |p: &Vec<Seg2>| p.first().map(Seg2::start).zip(p.last().map(Seg2::end));
|
||||
let Some(i) = pieces.iter().position(|p| ends(p).is_some_and(|(s, e)| near(s, tail) || near(e, tail) || near(s, head) || near(e, head)))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let Some(p) = pieces.remove(i) else { break };
|
||||
let Some((s, e)) = ends(&p) else { continue };
|
||||
if near(s, tail) {
|
||||
chain.extend(p);
|
||||
} else if near(e, tail) {
|
||||
chain.extend(reversed(p));
|
||||
} else if near(e, head) {
|
||||
chain = p.into_iter().chain(chain).collect();
|
||||
} else {
|
||||
chain = reversed(p).into_iter().chain(chain).collect();
|
||||
}
|
||||
}
|
||||
chains.push(chain);
|
||||
}
|
||||
Ok(chains)
|
||||
}
|
||||
|
||||
@@ -350,6 +350,17 @@ pub fn is_literal(s: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the expression a bare number without a unit, like `20` or `-1.5`?
|
||||
pub fn is_bare_number(s: &str) -> bool {
|
||||
is_literal(s) && s.trim().trim_start_matches(['-', '+']).trim().parse::<f64>().is_ok()
|
||||
}
|
||||
|
||||
/// The length unit of a design with these units: `mm` unless they name another length unit.
|
||||
pub fn length_unit_or_mm(units: &str) -> &str {
|
||||
let u = units.trim();
|
||||
if unit_info(u).is_some_and(|(k, _)| k == Kind::Length) { u } else { "mm" }
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
toks: Vec<Tok>,
|
||||
pos: usize,
|
||||
@@ -942,5 +953,7 @@ mod tests {
|
||||
assert_eq!(rename_reference("10 mm", "mm", "x"), "10 mm");
|
||||
assert!(is_literal("20") && is_literal("-1.5 in") && is_literal("3 deg"));
|
||||
assert!(!is_literal("d1") && !is_literal("2 * 3") && !is_literal("2 width"));
|
||||
assert!(is_bare_number(" -1.5 ") && is_bare_number("+2") && !is_bare_number("20 mm") && !is_bare_number("(2)"));
|
||||
assert_eq!((length_unit_or_mm(" in "), length_unit_or_mm("deg"), length_unit_or_mm("")), ("in", "mm", "mm"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,13 +73,44 @@
|
||||
//! certificate: the target's `name`, e.g. a cut wall `F5:side:l1` or a pattern copy
|
||||
//! `F4:top@F6.2`), where no construction certificate names it alone. Anchors with
|
||||
//! construction certificates resolve as before.
|
||||
//! - 17: a `lineage` face name can be a piece's own, qualified by the faces around it
|
||||
//! (`F2:top#[F2:side:l4,F4:side:l4]`), where Offset Face moved one piece of a face off the
|
||||
//! others' plane; never a numbered piece. Sheet face names can also name a rim, the sheet's
|
||||
//! thickness along a panel's straight edge (`sheet:<body>:<panel>:rim.<edge>`, the edge a base
|
||||
//! sketch line's id, `edge<k>`, or a flange's `far`, `side0` or `side1`), or a flat end of a
|
||||
//! bend (`rim.bend0`, `rim.bend1`). Version 16 designs need no transformation; the version
|
||||
//! stamp makes 0.8.0 (which reads up to 16) ask for an update instead of meeting names it
|
||||
//! can't resolve.
|
||||
//! Convert to Sheet Metal can take bend edges (`bends` in the feature: points on the sharp
|
||||
//! edges of a solid or shelled body that become bends). A conversion without `bends` is the
|
||||
//! plate conversion it always was and saves as before; 0.8.0 would drop the bend edges and
|
||||
//! refuse the body as no plate.
|
||||
//! A sketch's link to a projected edge can also keep the edge's persistent name (`edge_name`,
|
||||
//! beside `face_name`) and is then found by it, wherever the edge went. A link without it is
|
||||
//! the edge nearest its point, as it always was, and takes the name when its sketch is next
|
||||
//! edited.
|
||||
//! A fillet's `fit` can be `taper` (the radius follows the room along each edge it doesn't
|
||||
//! fit). Fillets with `exact` or `reduce` save as before; 0.8.0 would refuse the unknown fit.
|
||||
//! Feature-history folders are also first saved in 17 (`timeline_groups`: stable folder id,
|
||||
//! name, optional parent folder and feature ids). Version 16 designs have no saved timeline
|
||||
//! folders and open with the empty default.
|
||||
//! A fillet's `type` can be `spline` (the radius along each edge a B-spline: `start`, `knots`,
|
||||
//! and the control radii after `radius` in `radii`). Other fillets save as before; 0.8.0 would
|
||||
//! refuse the unknown type.
|
||||
//! Under the same version, a sheet metal rule's corner relief shape can also be `tear` (no
|
||||
//! relief: each bend is cut straight from the bends' corner to where its flange's end
|
||||
//! starts); a rule that says `notch`, `round` or `rectangular` builds what it did.
|
||||
//! A sketch's view options can also say `hide_construction` (the Sketch Palette's
|
||||
//! Construction Geometries); a sketch that shows its construction geometry saves as before.
|
||||
//! A `SurfaceExtrude` feature (Surface Extrude: `sketch`, optional `profiles`, `curves` and
|
||||
//! `extent`, walls only) is first saved in 17; 0.8.0 would refuse the unknown feature.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 16;
|
||||
pub const FORMAT: u32 = 17;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solidcraft/{n}")
|
||||
@@ -96,7 +127,7 @@ pub fn version_of(s: &str) -> Option<u32> {
|
||||
|
||||
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
||||
type JsonStep = fn(&mut Value) -> Result<()>;
|
||||
const JSON_STEPS: [JsonStep; 15] = [
|
||||
const JSON_STEPS: [JsonStep; 16] = [
|
||||
|_| Ok(()),
|
||||
v2_to_v3,
|
||||
v3_to_v4,
|
||||
@@ -112,6 +143,7 @@ const JSON_STEPS: [JsonStep; 15] = [
|
||||
v13_to_v14,
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
];
|
||||
|
||||
/// Stored face and edge names were worked out from geometry before format 14; they get the
|
||||
@@ -345,9 +377,43 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
if d.features.len() > crate::document::MAX_FEATURES
|
||||
|| d.params.len() > crate::document::MAX_PARAMS
|
||||
|| d.components.len() > crate::document::MAX_FEATURES
|
||||
|| d.timeline_groups.len() > crate::document::MAX_FEATURES
|
||||
|| d.timeline_groups.iter().any(|group| group.features.len() > crate::document::MAX_FEATURES)
|
||||
{
|
||||
return Err(DocError::Invalid("document too large".into()));
|
||||
}
|
||||
let feature_ids: std::collections::BTreeSet<_> = d.features.iter().map(|feature| feature.id).collect();
|
||||
let mut group_ids = std::collections::BTreeSet::new();
|
||||
let mut grouped_features = std::collections::BTreeSet::new();
|
||||
for group in &d.timeline_groups {
|
||||
if group.name.trim().is_empty() || group.name.len() > 128 || !group_ids.insert(group.id) {
|
||||
return Err(DocError::Invalid("damaged design: invalid timeline folder".into()));
|
||||
}
|
||||
if group.parent.is_some_and(|parent| parent == group.id) {
|
||||
return Err(DocError::Invalid("damaged design: timeline folder cannot contain itself".into()));
|
||||
}
|
||||
if group.features.iter().any(|feature| !feature_ids.contains(feature) || !grouped_features.insert(*feature)) {
|
||||
return Err(DocError::Invalid("damaged design: invalid feature in a timeline folder".into()));
|
||||
}
|
||||
}
|
||||
for group in &d.timeline_groups {
|
||||
if let Some(parent) = group.parent {
|
||||
if !group_ids.contains(&parent) {
|
||||
return Err(DocError::Invalid("damaged design: timeline folder has an unknown parent".into()));
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut current = Some(parent);
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
return Err(DocError::Invalid("damaged design: cyclic timeline folders".into()));
|
||||
}
|
||||
current = d.timeline_groups.iter().find(|candidate| candidate.id == id).and_then(|candidate| candidate.parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(id) = crate::non_contiguous_timeline_group(&d.features, &d.timeline_groups) {
|
||||
return Err(DocError::Invalid(format!("damaged design: timeline folder {id} is not contiguous")));
|
||||
}
|
||||
for o in &d.occurrences {
|
||||
if !rigid(&o.transform) {
|
||||
return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name)));
|
||||
|
||||
@@ -160,6 +160,27 @@ impl Traced {
|
||||
let lineage = lineages.and_then(|l| Lineage::step(&result, &l));
|
||||
Traced { body: result, lineage }
|
||||
}
|
||||
|
||||
/// `result`, made by one kernel operation from this body alone. Where that operation's
|
||||
/// history can't be composed with this body's (it blends an edge the same feature made: a
|
||||
/// boss's root fillet), this body is a stage of the feature, its faces and edges named as a
|
||||
/// whole first.
|
||||
pub fn then(&self, result: Body) -> Traced {
|
||||
let step = Traced::step(result.clone(), &[self]);
|
||||
if step.lineage.is_some() {
|
||||
return step;
|
||||
}
|
||||
let lineage = self.lineage.as_ref().and_then(|l| {
|
||||
let history = result.history()?.clone();
|
||||
if history.operands != 1 {
|
||||
return None;
|
||||
}
|
||||
let body = crate::ModelBody::new(String::new(), self.body.clone(), 0);
|
||||
let stage = Arc::new(Operand::Stage(Box::new(Stage { body, lineage: l.clone(), names: OnceLock::new() })));
|
||||
Some(Lineage { history, operands: vec![stage] })
|
||||
});
|
||||
Traced { body: result, lineage }
|
||||
}
|
||||
}
|
||||
|
||||
/// How the bodies a feature builds from a profile name their faces: what each profile segment's
|
||||
@@ -186,7 +207,7 @@ impl<'a> ProfileRoles<'a> {
|
||||
let segments: Vec<&solidcraft_geom::Seg2> =
|
||||
self.region.map(|r| std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).collect()).unwrap_or_default();
|
||||
let curves = self.sketch.map(crate::config::ExactSketchCurves::new);
|
||||
let curve = |segment: usize| -> Option<String> { curves.as_ref()?.find(segments.get(segment)?).map(|id| format!("side:{id}")) };
|
||||
let curve = |segment: usize| -> Option<String> { curves.as_ref()?.find_along(segments.get(segment)?).map(|id| format!("side:{id}")) };
|
||||
let roles = history
|
||||
.faces
|
||||
.iter()
|
||||
@@ -228,6 +249,64 @@ impl<'a> ProfileRoles<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a loft names its faces: each side by the sketch curves of the sections it runs between
|
||||
/// (`side:<curve>` for the first section's, `side:s<k>.<curve>` for section `k`'s), named after
|
||||
/// the section with the most segments (the one each side spans exactly one segment of) and
|
||||
/// answering to the others; its caps `start` and `end`.
|
||||
pub(crate) struct SectionRoles<'a> {
|
||||
/// Each section's sketch and the loop lofted through it, in the order given.
|
||||
pub sections: Vec<(&'a SolvedSketch, &'a solidcraft_geom::Loop2)>,
|
||||
}
|
||||
|
||||
impl SectionRoles<'_> {
|
||||
/// The loft `body`, its faces named by their roles where its history gives them.
|
||||
pub fn tool(&self, body: Body) -> Traced {
|
||||
let roles = body.history().filter(|h| h.operands == 0).map(|h| self.of(h));
|
||||
Traced::made(body, roles)
|
||||
}
|
||||
|
||||
fn of(&self, history: &History) -> Vec<Vec<String>> {
|
||||
// Each profile segment (numbered through the sections in order): its section and role.
|
||||
let mut segments: Vec<(usize, Option<String>)> = Vec::new();
|
||||
for (k, (sketch, lp)) in self.sections.iter().enumerate() {
|
||||
let curves = crate::config::ExactSketchCurves::new(sketch);
|
||||
for seg in &lp.segs {
|
||||
let role = curves.find_along(seg).map(|id| if k == 0 { format!("side:{id}") } else { format!("side:s{k}.{id}") });
|
||||
segments.push((k, role));
|
||||
}
|
||||
}
|
||||
let lead = self.sections.iter().enumerate().max_by_key(|(k, (_, lp))| (lp.segs.len(), std::cmp::Reverse(*k))).map_or(0, |(k, _)| k);
|
||||
history
|
||||
.faces
|
||||
.iter()
|
||||
.map(|face| {
|
||||
let mut roles: Vec<(bool, usize, String)> = Vec::new();
|
||||
for origin in &face.origins {
|
||||
let (k, role) = match *origin {
|
||||
FaceOrigin::Cap { end } => (0, Some(if end { "end" } else { "start" }.to_string())),
|
||||
FaceOrigin::Profile { segment } => match segments.get(segment) {
|
||||
Some((k, role)) => (*k, role.clone()),
|
||||
None => (0, None),
|
||||
},
|
||||
_ => (0, None),
|
||||
};
|
||||
// One unknown origin leaves the face unknown: never a partial guess.
|
||||
let Some(role) = role else { return Vec::new() };
|
||||
roles.push((k != lead, k, role));
|
||||
}
|
||||
roles.sort();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (.., role) in roles {
|
||||
if !out.contains(&role) {
|
||||
out.push(role);
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A box primitive, its faces named by the side they face (`+x`, `-z`, …), as its construction
|
||||
/// records them.
|
||||
pub(crate) fn box_tool(body: Body) -> Traced {
|
||||
@@ -260,6 +339,23 @@ pub(crate) fn cylinder_tool(body: Body) -> Traced {
|
||||
Traced::made(body, roles)
|
||||
}
|
||||
|
||||
/// A tool a feature builds without a sketch (a boss's post, a rib, a lip's band), its faces named
|
||||
/// by the part it makes: `<part>:top` (its end cap), `<part>:base` (its start cap) and
|
||||
/// `<part>:side:<k>` (swept from segment `k` of its profile), as its construction records them.
|
||||
pub(crate) fn part_tool(body: Body, part: &str) -> Traced {
|
||||
let roles = body.history().filter(|h| h.operands == 0).map(|h| {
|
||||
h.faces
|
||||
.iter()
|
||||
.map(|f| match f.origins.as_slice() {
|
||||
[FaceOrigin::Cap { end }] => vec![format!("{part}:{}", if *end { "top" } else { "base" })],
|
||||
[FaceOrigin::Profile { segment }] => vec![format!("{part}:side:{segment}")],
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Traced::made(body, roles)
|
||||
}
|
||||
|
||||
/// Faces named by their place in a body built as a whole (an imported part's faces in the
|
||||
/// file's order): `face:<k>`.
|
||||
pub(crate) fn numbered_tool(body: Body) -> Traced {
|
||||
|
||||
@@ -30,6 +30,8 @@ pub fn legacy_name(name: &str) -> Option<&str> {
|
||||
/// Where a stored edge name was found.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EdgeHit {
|
||||
/// The edge (its index in the body's mesh).
|
||||
pub edge: usize,
|
||||
/// A point on the edge: the reference's own point when it lies on it, else the edge's middle.
|
||||
pub point: Vec3,
|
||||
/// The named edge is not one edge any more: the piece nearest the point was taken.
|
||||
@@ -62,7 +64,7 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
|
||||
let exact = |i: usize| -> Result<EdgeHit, String> {
|
||||
let on = edge_near(b, &m, i, near);
|
||||
mid(i)
|
||||
.map(|p| EdgeHit { point: if on { near } else { p }, split: false, verified: verified(i), on_point: on, legacy: false })
|
||||
.map(|p| EdgeHit { edge: i, point: if on { near } else { p }, split: false, verified: verified(i), on_point: on, legacy: false })
|
||||
.ok_or_else(|| "edge".into())
|
||||
};
|
||||
if let Some(i) = names.iter().position(|n| n == name) {
|
||||
@@ -84,7 +86,7 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
|
||||
.iter()
|
||||
.filter_map(|i| mid(*i).map(|p| (p, *i)))
|
||||
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
|
||||
.map(|(p, i)| EdgeHit { point: p, split: true, verified: verified(i), on_point: false, legacy: false })
|
||||
.map(|(p, i)| EdgeHit { edge: i, point: p, split: true, verified: verified(i), on_point: false, legacy: false })
|
||||
.ok_or_else(|| format!("edge `{name}` no longer exists"))
|
||||
}
|
||||
|
||||
@@ -104,7 +106,8 @@ fn nearest_on_face(m: &Mesh, face: usize, p: Vec3) -> Option<(f64, Vec3)> {
|
||||
let Some([a, b, c]) = m.tri(t) else { continue };
|
||||
let q = closest(p, a, b, c);
|
||||
let d = q.dist(p);
|
||||
if best.is_none_or(|x| d < x.0) {
|
||||
// A non-finite candidate must never become the best: nothing compares less than NaN.
|
||||
if d.is_finite() && best.is_none_or(|x| d < x.0) {
|
||||
best = Some((d, q));
|
||||
}
|
||||
}
|
||||
@@ -136,15 +139,15 @@ fn legacy_locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit,
|
||||
if let Some(i) = names.iter().position(|n| n == name) {
|
||||
let on = edge_near(b, &m, i, near);
|
||||
return edge_mid(&m, i)
|
||||
.map(|p| EdgeHit { point: if on { near } else { p }, split: false, verified: false, on_point: on, legacy: true })
|
||||
.map(|p| EdgeHit { edge: i, point: if on { near } else { p }, split: false, verified: false, on_point: on, legacy: true })
|
||||
.ok_or(());
|
||||
}
|
||||
let stem = strip_edge_piece(name);
|
||||
(0..names.len())
|
||||
.filter(|i| names.get(*i).is_some_and(|n| strip_edge_piece(n) == stem))
|
||||
.filter_map(|i| edge_mid(&m, i))
|
||||
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
|
||||
.map(|p| EdgeHit { point: p, split: true, verified: false, on_point: false, legacy: true })
|
||||
.filter_map(|i| edge_mid(&m, i).map(|p| (p, i)))
|
||||
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
|
||||
.map(|(p, i)| EdgeHit { edge: i, point: p, split: true, verified: false, on_point: false, legacy: true })
|
||||
.ok_or(())
|
||||
}
|
||||
|
||||
@@ -317,7 +320,7 @@ fn legacy_locate<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name:
|
||||
let Some([a, bb, c]) = m.tri(t) else { continue };
|
||||
let q = closest(p, a, bb, c);
|
||||
let d = q.dist(p) - if exact { 1e-9 } else { 0.0 };
|
||||
if best.is_none_or(|x| d < x.0) {
|
||||
if d.is_finite() && best.is_none_or(|x| d < x.0) {
|
||||
best = Some((d, bi, *f as usize, q, !exact, tol));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,8 @@ fn samples(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The point of triangle `abc` nearest `p`. A zero-length edge (a cone's tip repeats its apex
|
||||
/// vertex) has no interior to project onto, so its branch is skipped rather than dividing 0 by 0.
|
||||
pub(crate) fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
let (ab, ac, ap) = (b - a, c - a, p - a);
|
||||
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
|
||||
@@ -126,7 +128,7 @@ pub(crate) fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
return b;
|
||||
}
|
||||
let vc = d1 * d4 - d3 * d2;
|
||||
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
|
||||
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 && d1 - d3 > 0.0 {
|
||||
return a + ab * (d1 / (d1 - d3));
|
||||
}
|
||||
let cp = p - c;
|
||||
@@ -135,11 +137,11 @@ pub(crate) fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
return c;
|
||||
}
|
||||
let vb = d5 * d2 - d1 * d6;
|
||||
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
|
||||
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 && d2 - d6 > 0.0 {
|
||||
return a + ac * (d2 / (d2 - d6));
|
||||
}
|
||||
let va = d3 * d6 - d5 * d4;
|
||||
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
|
||||
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 && (d4 - d3) + (d5 - d6) > 0.0 {
|
||||
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
|
||||
}
|
||||
let den = va + vb + vc;
|
||||
@@ -753,6 +755,20 @@ pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A cone's tip repeats its apex vertex, so a triangle can have a zero-length edge. The nearest
|
||||
/// point on it must still be a real point (it used to be NaN, which broke picking the tip).
|
||||
#[test]
|
||||
fn closest_point_on_a_degenerate_triangle_is_finite() {
|
||||
let (a, c) = (Vec3::new(0., 0., 0.), Vec3::new(2., 0., 0.));
|
||||
for (b, c) in [(a, c), (c, c), (a, a)] {
|
||||
for p in [Vec3::new(1., 1., 0.), Vec3::new(-1., 0.5, 0.), Vec3::new(3., -2., 1.), a] {
|
||||
let q = closest(p, a, b, c);
|
||||
assert!(q.x.is_finite() && q.y.is_finite() && q.z.is_finite(), "{a:?} {b:?} {c:?} {p:?} -> {q:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(closest(Vec3::new(1., 1., 0.), a, a, c), Vec3::new(1., 0., 0.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_face_votes_collectively_for_pieces_of_one_source() {
|
||||
let body = ModelBody::new("Input".into(), solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(4., 1., 1.)).unwrap(), 1);
|
||||
|
||||
@@ -54,7 +54,7 @@ impl FeatureKind {
|
||||
}
|
||||
}
|
||||
},
|
||||
FeatureKind::Extrude { extent, .. } => {
|
||||
FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } => {
|
||||
v.push(("Distance", &mut extent.distance, L));
|
||||
if let Some(d) = &mut extent.distance2 {
|
||||
v.push(("Distance 2", d, L));
|
||||
@@ -83,6 +83,11 @@ impl FeatureKind {
|
||||
v.push(("End radius", radius2, L));
|
||||
v.extend(mid.iter_mut().map(|(_, r)| ("Mid radius", r, L)));
|
||||
}
|
||||
crate::FilletStyle::Spline { radii, .. } => {
|
||||
v.push(("Start radius", radius, L));
|
||||
let n = radii.len();
|
||||
v.extend(radii.iter_mut().enumerate().map(|(i, r)| (if i + 1 == n { "End radius" } else { "Control radius" }, r, L)));
|
||||
}
|
||||
},
|
||||
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
|
||||
v.push(("Distance", distance, L));
|
||||
@@ -466,7 +471,7 @@ impl Document {
|
||||
/// in (feature inputs are stored in mm otherwise). Anything else is returned unchanged.
|
||||
pub fn with_design_unit(&self, e: &str, kind: Kind) -> String {
|
||||
let u = self.units.trim();
|
||||
let bare = expr::is_literal(e) && e.trim().trim_start_matches(['-', '+']).trim().parse::<f64>().is_ok();
|
||||
let bare = expr::is_bare_number(e);
|
||||
if kind == Kind::Length && bare && !u.is_empty() && u != "mm" && expr::unit_info(u).is_some_and(|(k, _)| k == Kind::Length) {
|
||||
format!("{} {u}", e.trim())
|
||||
} else {
|
||||
|
||||
@@ -10,11 +10,19 @@ use std::sync::{
|
||||
};
|
||||
|
||||
/// Shared between the worker evaluating a design and the window showing its progress.
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Debug)]
|
||||
pub struct Progress {
|
||||
cancelled: AtomicBool,
|
||||
done: AtomicUsize,
|
||||
total: AtomicUsize,
|
||||
/// The feature the evaluation waits at until cancelled ([`Progress::hold_at`]).
|
||||
hold: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Default for Progress {
|
||||
fn default() -> Self {
|
||||
Progress { cancelled: AtomicBool::new(false), done: AtomicUsize::new(0), total: AtomicUsize::new(0), hold: AtomicUsize::new(usize::MAX) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
@@ -25,6 +33,11 @@ impl Progress {
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Make the watched evaluation wait before feature `index` until it is cancelled: tests
|
||||
/// use it to act while a load is certainly still running, whatever the machine's speed.
|
||||
pub fn hold_at(&self, index: usize) {
|
||||
self.hold.store(index, Ordering::Relaxed);
|
||||
}
|
||||
/// Features evaluated so far and the timeline's length (0 before evaluation starts).
|
||||
pub fn features(&self) -> (usize, usize) {
|
||||
(self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed))
|
||||
@@ -85,6 +98,12 @@ impl Evaluation {
|
||||
let Some(cur) = c.as_ref() else { return false };
|
||||
if self.outer {
|
||||
cur.progress.done.store(index, Ordering::Relaxed);
|
||||
while index == cur.progress.hold.load(Ordering::Relaxed) && !cur.progress.is_cancelled() {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}
|
||||
cur.progress.is_cancelled()
|
||||
})
|
||||
|
||||
+96
-19
@@ -1,8 +1,9 @@
|
||||
//! Resolving sketch links against the model: projecting edges, faces, body silhouettes, other
|
||||
//! sketches and the origin onto a sketch plane, and intersecting bodies with it.
|
||||
//!
|
||||
//! Model geometry is found again on every recompute from the reference point stored in the
|
||||
//! link (like fillet edges). Edge polylines from the tessellation lie exactly on their curves,
|
||||
//! Model geometry is found again on every recompute: a face or an edge by the persistent name
|
||||
//! its link keeps, anything else (and a link saved without a name) from the reference point
|
||||
//! stored in the link. Edge polylines from the tessellation lie exactly on their curves,
|
||||
//! so lines and circular arcs are recovered exactly by fitting; anything else (an ellipse from
|
||||
//! a tilted circle, a spline edge) becomes a chain of lines.
|
||||
|
||||
@@ -35,6 +36,9 @@ pub struct Resolved {
|
||||
/// Exact face selected during this resolution, before its closest point is
|
||||
/// projected onto an edge shared with another face. Computed metadata only.
|
||||
pub face_index: Option<usize>,
|
||||
/// The edge an `Edge` reference resolved to (its index in the body's mesh), whose
|
||||
/// persistent name the link keeps ([`resolved_edge_name`]). Computed metadata only.
|
||||
pub edge_index: Option<usize>,
|
||||
}
|
||||
|
||||
/// Resolutions of body-based references, keyed by the reference, the sketch plane and the
|
||||
@@ -98,13 +102,9 @@ fn resolve_uncached(
|
||||
let lost = |what: &str| DocError::Invalid(format!("{what} no longer exists"));
|
||||
let mut moved = false;
|
||||
let mut face_index = None;
|
||||
let mut edge_index = None;
|
||||
let mut source = src.clone();
|
||||
let mut wires: Vec<Vec<Vec3>> = Vec::new();
|
||||
// Included curves that leave the sketch plane stay 3D.
|
||||
let off_plane = |pts: &[Vec3]| {
|
||||
let size = pts.iter().fold(1.0_f64, |m, p| m.max(p.len()));
|
||||
pts.iter().any(|p| plane.height(*p).abs() > size * 1e-7)
|
||||
};
|
||||
let geom = match src {
|
||||
LinkSource::Edge { body, at } => {
|
||||
let (bi, mesh) = body_mesh(st, body, *at)?;
|
||||
@@ -116,15 +116,8 @@ fn resolve_uncached(
|
||||
moved = d > (size * 2e-3).max(1e-3);
|
||||
let name = st.bodies.get(bi).map(|b| b.name.clone()).unwrap_or_else(|| body.clone());
|
||||
source = LinkSource::Edge { body: name, at: q };
|
||||
let pts = mesh.edges.get(e).cloned().unwrap_or_default();
|
||||
match kind {
|
||||
LinkKind::Intersect => section_of_polyline(plane, &pts),
|
||||
LinkKind::Include if off_plane(&pts) => {
|
||||
wires.push(pts);
|
||||
Vec::new()
|
||||
}
|
||||
_ => fit_world(plane, &pts),
|
||||
}
|
||||
edge_index = Some(e);
|
||||
edge_geom(plane, kind, mesh.edges.get(e).cloned().unwrap_or_default(), &mut wires)
|
||||
}
|
||||
LinkSource::Face { body, at } => {
|
||||
let (bi, mesh) = body_mesh(st, body, *at)?;
|
||||
@@ -188,7 +181,7 @@ fn resolve_uncached(
|
||||
}
|
||||
LinkKind::Include => {
|
||||
let pts: Vec<Vec3> = ss.sketch.polyline(ci).iter().map(|p| ss.plane.to_world(*p)).collect();
|
||||
if off_plane(&pts) {
|
||||
if off_plane(plane, &pts) {
|
||||
wires.push(pts);
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -273,7 +266,34 @@ fn resolve_uncached(
|
||||
if geom.is_empty() && wires.is_empty() {
|
||||
return Err(DocError::Invalid(format!("the {} projects to nothing on this sketch plane", src.describe())));
|
||||
}
|
||||
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved, face_index })
|
||||
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved, face_index, edge_index })
|
||||
}
|
||||
|
||||
/// Whether a curve (world points) leaves the sketch plane: included, it then stays 3D.
|
||||
fn off_plane(plane: &Plane, pts: &[Vec3]) -> bool {
|
||||
let size = pts.iter().fold(1.0_f64, |m, p| m.max(p.len()));
|
||||
pts.iter().any(|p| plane.height(*p).abs() > size * 1e-7)
|
||||
}
|
||||
|
||||
/// What a body edge (its points) gives a sketch: its section with the sketch plane, itself as a
|
||||
/// 3D curve (into `wires`) when it is included and leaves the plane, else its projection.
|
||||
fn edge_geom(plane: &Plane, kind: LinkKind, pts: Vec<Vec3>, wires: &mut Vec<Vec<Vec3>>) -> Vec<LinkGeom> {
|
||||
match kind {
|
||||
LinkKind::Intersect => section_of_polyline(plane, &pts),
|
||||
LinkKind::Include if off_plane(plane, &pts) => {
|
||||
wires.push(pts);
|
||||
Vec::new()
|
||||
}
|
||||
_ => fit_world(plane, &pts),
|
||||
}
|
||||
}
|
||||
|
||||
/// The persistent name of the edge an `Edge` reference resolved to, for its link to keep and
|
||||
/// be found by from then on. None on a mesh body, whose edges have no names.
|
||||
pub fn resolved_edge_name(st: &ModelState, r: &Resolved) -> Option<String> {
|
||||
let (LinkSource::Edge { body, .. }, Some(index)) = (&r.source, r.edge_index) else { return None };
|
||||
let body = st.body(body).filter(|body| !body.body.is_mesh())?;
|
||||
crate::naming::edge_names(body).get(index).filter(|name| !name.is_empty()).cloned()
|
||||
}
|
||||
|
||||
/// Re-resolve every link of a sketch (in place). Returns warnings for lost, moved or rebuilt
|
||||
@@ -282,6 +302,36 @@ fn lost(reference: &str) -> DocError {
|
||||
DocError::Unknown(format!("{reference} no longer exists"))
|
||||
}
|
||||
|
||||
/// The edge a link's stored name finds, and the body it is on: the link's body, or, when no
|
||||
/// body has that name any more (it was joined into another), the body on which the name finds
|
||||
/// the edge nearest the link's point.
|
||||
fn locate_named_edge(st: &ModelState, body: &str, name: &str, at: Vec3) -> Option<(String, crate::naming::EdgeHit)> {
|
||||
let hit = |b: &crate::ModelBody| crate::naming::locate_edge(b, name, at).ok().map(|hit| (b.name.clone(), hit));
|
||||
match st.body(body) {
|
||||
Some(b) => hit(b),
|
||||
None => st.bodies.iter().filter(|b| !b.body.is_mesh()).filter_map(hit).min_by(|a, b| a.1.point.dist(at).total_cmp(&b.1.point.dist(at))),
|
||||
}
|
||||
}
|
||||
|
||||
/// An edge found by its persistent name: edge `index` of `body`, with the link's point `near`
|
||||
/// moved onto it.
|
||||
fn resolve_named_edge(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str, index: usize, near: Vec3) -> Result<Resolved> {
|
||||
let target = st.body(body).ok_or_else(|| lost("named projection body"))?;
|
||||
let pts = target.mesh().edges.get(index).cloned().unwrap_or_default();
|
||||
let at = pts
|
||||
.windows(2)
|
||||
.filter_map(|w| Some(closest_on_segment(near, *w.first()?, *w.get(1)?)))
|
||||
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
|
||||
.ok_or_else(|| lost("named projection edge"))?;
|
||||
let mut wires = Vec::new();
|
||||
let geom = edge_geom(plane, kind, pts, &mut wires);
|
||||
if geom.is_empty() && wires.is_empty() {
|
||||
return Err(DocError::Invalid("named edge projects to nothing on this sketch plane".into()));
|
||||
}
|
||||
let source = LinkSource::Edge { body: body.into(), at };
|
||||
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved: false, face_index: None, edge_index: Some(index) })
|
||||
}
|
||||
|
||||
fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str, index: usize, at: Vec3) -> Result<Resolved> {
|
||||
let target = st.body(body).ok_or_else(|| lost("named projection body"))?;
|
||||
let mesh = target.mesh();
|
||||
@@ -305,7 +355,7 @@ fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str
|
||||
if geom.is_empty() {
|
||||
return Err(DocError::Invalid("named face projects to nothing on this sketch plane".into()));
|
||||
}
|
||||
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index })
|
||||
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index, edge_index: None })
|
||||
}
|
||||
|
||||
fn projection_face_edges(body: &crate::ModelBody, mesh: &Mesh, index: u32, plane: &Plane) -> Result<Vec<usize>> {
|
||||
@@ -392,6 +442,7 @@ pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body
|
||||
source: LinkSource::Face { body: body.name.clone(), at: point },
|
||||
moved: false,
|
||||
face_index: if indices.len() == 1 { indices.first().copied() } else { None },
|
||||
edge_index: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -519,6 +570,22 @@ fn refresh_links_in(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// An edge the link keeps by name is that edge of its body, wherever it went. When no
|
||||
// edge has the name any more the link is lost: no other edge is taken for it.
|
||||
let mut named_edge = None;
|
||||
if let (Some(name), LinkSource::Edge { body, at }) = (&l.edge_name, &source) {
|
||||
match locate_named_edge(st, body, name, *at) {
|
||||
Some((body, hit)) => {
|
||||
warn.extend(hit.note(name).map(|note| format!("projected {note}")));
|
||||
named_edge = Some((body, hit.edge, *at));
|
||||
}
|
||||
None => {
|
||||
sk.set_link_lost(&l.id, true);
|
||||
warn.push(format!("projected reference lost: edge `{name}` no longer exists; last geometry retained"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let resolved = if l.auto_face {
|
||||
anchor
|
||||
.filter(|anchor| {
|
||||
@@ -528,11 +595,21 @@ fn refresh_links_in(
|
||||
.and_then(|anchor| resolve_anchored_face(doc, st, plane, l.kind, anchor))
|
||||
} else if let Some((body, index, at)) = named_face {
|
||||
resolve_named_face(st, plane, l.kind, &body, index, at)
|
||||
} else if let Some((body, index, at)) = named_edge {
|
||||
resolve_named_edge(st, plane, l.kind, &body, index, at)
|
||||
} else {
|
||||
resolve(doc, vals, st, plane, l.kind, &source)
|
||||
};
|
||||
match resolved {
|
||||
Ok(r) => {
|
||||
// An edge link saved without a name takes the name of the edge it was found
|
||||
// on. A sketch that is edited stores it; evaluation leaves the design as it is.
|
||||
if l.edge_name.is_none()
|
||||
&& let Some(name) = resolved_edge_name(st, &r)
|
||||
&& let Some(link) = sk.links.iter_mut().find(|link| link.id == l.id)
|
||||
{
|
||||
link.edge_name = Some(name);
|
||||
}
|
||||
sk.set_link_source(&l.id, r.source);
|
||||
let _ = sk.set_link_wires(&l.id, &r.wires);
|
||||
match sk.update_link(&l.id, &r.geom) {
|
||||
|
||||
+1166
-104
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,416 @@
|
||||
//! Convert to Sheet Metal with bend edges: the faces of a solid, or the walls of a shelled one,
|
||||
//! become one sheet.
|
||||
//!
|
||||
//! The planar faces reached from the fixed face across the bend edges are the sheet's panels,
|
||||
//! all on one skin of it (the one the fixed face is on). A body with a parallel face the same
|
||||
//! small distance behind the fixed face and behind the faces at every bend is a shell: it keeps
|
||||
//! its walls and their thickness. Any other body's faces become the outside of the sheet, with
|
||||
//! the rule's thickness taken into the body. A bend edge stays its bend's virtual sharp: the
|
||||
//! panels on either side are set back from it by (R + T)·tan(θ/2) where the skin is the bend's
|
||||
//! outside and R·tan(θ/2) where it is the inside, so the body keeps its envelope. An edge between
|
||||
//! two panels that is no bend is cut open: each panel ends square to the sheet, half the rule's
|
||||
//! gap from the plane halfway between the two.
|
||||
//!
|
||||
//! The result is the model every sheet metal feature builds ([`SheetBody`]): the fixed face is
|
||||
//! the base panel and every other panel a flange on its parent's edge, its outline taken through
|
||||
//! the inverse of its fold. Unfold, the flat pattern, the bend table and cuts then work as on a
|
||||
//! sheet made from flanges.
|
||||
//!
|
||||
//! Not supported yet: curved faces (rolled sheet, a filleted edge as a bend), faces with curved
|
||||
//! edges or holes, loops of bends, two faces that meet along more than one edge.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Vec2, Vec3};
|
||||
use solidcraft_kernel::{Body, FaceSurface, face_surface};
|
||||
|
||||
use super::{RuleValues, SheetBody, SheetFlange, base_flange, polys_cross, signed_area, snap};
|
||||
use crate::{DocError, ModelBody, Result, apply_point, mat_inverse};
|
||||
|
||||
const MAX_BENDS: usize = 1000;
|
||||
|
||||
fn err<T>(msg: impl Into<String>) -> Result<T> {
|
||||
Err(DocError::Invalid(msg.into()))
|
||||
}
|
||||
|
||||
fn at(p: Vec3) -> String {
|
||||
format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z)
|
||||
}
|
||||
|
||||
/// A planar face of the body: its outward normal and its outline, counter-clockwise about the
|
||||
/// normal. Edge `i` runs from corner `i` to the next one, along face `across[i]`.
|
||||
struct Face {
|
||||
n: Vec3,
|
||||
pts: Vec<Vec3>,
|
||||
across: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
/// A face of the sheet: the panel it hangs off (none for the fixed face), and the bend between
|
||||
/// the two: its angle (positive toward the material, as a flange's) and its setback on this skin.
|
||||
struct Panel {
|
||||
face: Face,
|
||||
parent: Option<usize>,
|
||||
angle: f64,
|
||||
setback: f64,
|
||||
}
|
||||
|
||||
/// Area of a loop in space, positive when it runs counter-clockwise about `n`.
|
||||
fn area_about(pts: &[Vec3], n: Vec3) -> f64 {
|
||||
let Some(&o) = pts.first() else { return 0.0 };
|
||||
pts.windows(2).map(|w| (w[0] - o).cross(w[1] - o).dot(n)).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
/// Turn a loop's per-edge values round with its corners (edge `i` starts at corner `i`).
|
||||
fn turn<T>(per_edge: &mut [T]) {
|
||||
per_edge.reverse();
|
||||
if !per_edge.is_empty() {
|
||||
per_edge.rotate_left(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Face `f` of the body as a panel outline. Only a planar face bounded by one loop of straight
|
||||
/// edges can be one.
|
||||
fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result<Face> {
|
||||
let Some(FaceSurface::Plane { normal: n, .. }) = face_surface(b, f) else {
|
||||
return err("not supported yet: curved faces in a converted sheet (only planar faces convert)");
|
||||
};
|
||||
// Its edges, each with the face on its other side.
|
||||
let mut edges = Vec::new();
|
||||
for (line, faces) in mesh.edges.iter().zip(&mesh.edge_faces) {
|
||||
if !faces.iter().any(|g| *g as usize == f) {
|
||||
continue;
|
||||
}
|
||||
let (Some(&p), Some(&q)) = (line.first(), line.last()) else { continue };
|
||||
if p.dist(q) <= tol || line.iter().any(|x| x.dist_to_segment(p, q) > tol) {
|
||||
return err("not supported yet: faces with curved edges in a converted sheet");
|
||||
}
|
||||
edges.push((p, q, faces.iter().map(|g| *g as usize).find(|g| *g != f)));
|
||||
}
|
||||
// Chained head to tail from the first one, they close one loop.
|
||||
let Some((start, mut end, across)) = edges.pop() else { return err("a face of the body has no edges") };
|
||||
let (mut pts, mut tags) = (vec![start], vec![across]);
|
||||
while end.dist(start) > tol {
|
||||
let Some(k) = edges.iter().position(|e| e.0.dist(end) <= tol || e.1.dist(end) <= tol) else {
|
||||
return err("the outline of a face of the body does not close");
|
||||
};
|
||||
let (p, q, across) = edges.swap_remove(k);
|
||||
pts.push(end);
|
||||
tags.push(across);
|
||||
end = if p.dist(end) <= tol { q } else { p };
|
||||
}
|
||||
if !edges.is_empty() {
|
||||
return err("not supported yet: faces with holes in a converted sheet (cut them after converting)");
|
||||
}
|
||||
// A straight run along one neighbour is one edge, however the body's edges divide it (a
|
||||
// shell's outer corners are divided at the height of its floor).
|
||||
let m = pts.len();
|
||||
let corner = |i: usize| -> bool {
|
||||
let before = (i + m - 1) % m;
|
||||
let (Some(&a), Some(&p), Some(&b)) = (pts.get(before), pts.get(i), pts.get((i + 1) % m)) else { return true };
|
||||
tags.get(before) != tags.get(i) || p.dist_to_segment(a, b) > tol
|
||||
};
|
||||
let keep: Vec<bool> = (0..m).map(corner).collect();
|
||||
let (mut pts, mut tags): (Vec<Vec3>, Vec<Option<usize>>) = pts.into_iter().zip(tags).zip(keep).filter(|(_, kept)| *kept).map(|(x, _)| x).unzip();
|
||||
if area_about(&pts, n) < 0.0 {
|
||||
pts.reverse();
|
||||
turn(&mut tags);
|
||||
}
|
||||
Ok(Face { n, pts, across: tags })
|
||||
}
|
||||
|
||||
/// The face straight through the material behind planar face `f`, when it is parallel to it,
|
||||
/// and how far behind it lies.
|
||||
fn behind(b: &Body, mesh: &Mesh, f: usize) -> Option<(usize, f64)> {
|
||||
let Some(FaceSurface::Plane { normal: n, point: o }) = face_surface(b, f) else { return None };
|
||||
// From the middle of the face's largest triangle.
|
||||
let area = |t: &[Vec3; 3]| (t[1] - t[0]).cross(t[2] - t[0]).len();
|
||||
let tri = mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g as usize == f).filter_map(|(t, _)| mesh.tri(t));
|
||||
let [p, q, r] = tri.max_by(|x, y| area(x).total_cmp(&area(y)))?;
|
||||
let (_, hit) = mesh.raycast((p + q + r) * (1.0 / 3.0), n * -1.0)?;
|
||||
let g = *mesh.tri_face.get(hit)? as usize;
|
||||
let Some(FaceSurface::Plane { normal, point }) = face_surface(b, g) else { return None };
|
||||
(normal.dot(n) < -1.0 + 1e-9).then_some((g, (o - point).dot(n)))
|
||||
}
|
||||
|
||||
/// How far a panel's edge along a rip moves in, so that the panel, cut square to the sheet,
|
||||
/// stays half the gap from the plane halfway between it and the panel `y` across the rip.
|
||||
/// `e` is the edge's direction round panel `x`.
|
||||
fn rip_cut(x: &Face, y: &Face, e: Vec3, t: f64, gap: f64) -> Result<f64> {
|
||||
// Into each panel from the edge, square to the edge.
|
||||
let (wx, wy) = (x.n.cross(e), e.cross(y.n));
|
||||
let mid = (wx - wy).normalized().filter(|m| wx.dot(*m) > 1e-9);
|
||||
let Some(mid) = mid else { return err("two faces of the sheet lie on each other along the edge between them") };
|
||||
// The sheet's other face lies `t` behind the panel: nearer that plane, or further from it.
|
||||
let lean = -x.n.dot(mid) * t;
|
||||
Ok((gap / 2.0 - lean.min(0.0)) / wx.dot(mid))
|
||||
}
|
||||
|
||||
/// A counter-clockwise outline with each edge moved in by its own amount. Moved edges meet
|
||||
/// where their lines cross; two edges in one line that move by different amounts are joined by
|
||||
/// a square step. Returns the corners and, per edge, the edge it was (none for a step).
|
||||
fn inset(pts: &[Vec2], by: &[f64]) -> Result<(Vec<Vec2>, Vec<Option<usize>>)> {
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
return err("a face of the sheet has no outline");
|
||||
}
|
||||
let moved = |i: usize| -> Option<(Vec2, Vec2)> {
|
||||
let (a, b) = (*pts.get(i % n)?, *pts.get((i + 1) % n)?);
|
||||
let d = (b - a).normalized()?;
|
||||
Some((a + Vec2::new(-d.y, d.x) * *by.get(i % n)?, d))
|
||||
};
|
||||
let (mut out, mut from) = (Vec::new(), Vec::new());
|
||||
for i in 0..n {
|
||||
let (Some((p0, d0)), Some((p1, d1))) = (moved(i + n - 1), moved(i)) else { return err("a face of the sheet has an edge without length") };
|
||||
let cross = d0.cross(d1);
|
||||
if cross.abs() > 1e-9 {
|
||||
out.push(p0 + d0 * ((p1 - p0).cross(d1) / cross));
|
||||
} else if d0.dot(d1) > 0.0 {
|
||||
let end = p0 + d0 * (p1 - p0).dot(d0);
|
||||
if end.dist(p1) > 1e-9 {
|
||||
out.push(end);
|
||||
from.push(None);
|
||||
}
|
||||
out.push(p1);
|
||||
} else {
|
||||
return err("the outline of a face of the sheet doubles back on itself");
|
||||
}
|
||||
from.push(Some(i));
|
||||
}
|
||||
// Every edge still runs the way it did, and the outline is still one simple loop.
|
||||
let m = out.len();
|
||||
let kept = (0..m).all(|j| match (from.get(j).copied().flatten().and_then(moved), out.get(j), out.get((j + 1) % m)) {
|
||||
(Some((_, d)), Some(&a), Some(&b)) => (b - a).dot(d) > 1e-9,
|
||||
_ => true,
|
||||
});
|
||||
if !kept || !(signed_area(&out) > 0.0) || polys_cross(&out, &out) {
|
||||
return err("the bends' setbacks and the gaps at the cut edges use up an edge of a face");
|
||||
}
|
||||
Ok((out, from))
|
||||
}
|
||||
|
||||
/// Give flange `i` its panel: `outline` in flat coordinates, counter-clockwise, its edge at the
|
||||
/// bend first trimmed to the end of the bend zone.
|
||||
fn set_panel(sh: &mut SheetBody, i: usize, outline: &[Vec2]) {
|
||||
let ba = sh.flanges.get(i).map_or(0.0, |f| sh.allowance(f));
|
||||
let Some(f) = sh.flanges.get_mut(i) else { return };
|
||||
// Its corners along the bend (u) and past the bend zone (v); those at the zone exactly on it.
|
||||
let uv: Vec<(f64, f64)> =
|
||||
outline.iter().map(|q| ((*q - f.p).dot(f.d), (*q - f.p).dot(f.n) - ba)).map(|(u, v)| (u, if v.abs() < 1e-6 { 0.0 } else { v })).collect();
|
||||
f.leg = uv.iter().fold(0.0, |m, (_, v)| m.max(*v));
|
||||
let level = |h: f64| -> Vec<f64> { uv.iter().filter(|(_, v)| (v - h).abs() < 1e-6).map(|(u, _)| *u).collect() };
|
||||
match (level(0.0).as_slice(), level(f.leg).as_slice()) {
|
||||
// Two corners at the bend and two on a far edge parallel to it: a flange's own panel,
|
||||
// with its run-ons past the bend's ends and its slanted ends.
|
||||
(&[a, b], &[c, d]) if uv.len() == 4 && f.leg > 1e-6 => {
|
||||
let zero = |x: f64| if x.abs() < 1e-12 { 0.0 } else { x };
|
||||
f.ext = (zero(-a.min(b)), zero(a.max(b) - f.len));
|
||||
f.slope = (zero((a.min(b) - c.min(d)) / f.leg), zero((c.max(d) - a.max(b)) / f.leg));
|
||||
}
|
||||
_ => f.shape = Some(uv.iter().map(|(u, v)| f.p + f.d * *u + f.n * (ba + *v)).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Names of the edges of `body` at the bend points, to find them again after the body changes.
|
||||
/// A point on no edge gets none: the conversion then says so, instead of bending the edge that
|
||||
/// happens to be nearest.
|
||||
pub fn bend_edge_names(st: &crate::ModelState, body: &str, pts: &[Vec3]) -> Vec<String> {
|
||||
let Some(b) = st.body(body) else { return Vec::new() };
|
||||
let (mesh, near) = (b.mesh(), (b.body.size() * 2e-3).max(1e-3));
|
||||
let on = |p: &Vec3| mesh.edges.iter().any(|e| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) <= near));
|
||||
pts.iter().zip(crate::naming::edge_names_at(b, pts)).map(|(p, name)| if on(p) { name } else { String::new() }).collect()
|
||||
}
|
||||
|
||||
/// Convert body `mb` to a sheet under the rule `rule` with values `rv`: `face` is a point on the
|
||||
/// fixed face and `bends` are points on the sharp edges that become bends (the body's
|
||||
/// coordinates, mm). See the module docs.
|
||||
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<SheetBody> {
|
||||
if bends.is_empty() || bends.len() > MAX_BENDS {
|
||||
return err(format!("pick 1…{MAX_BENDS} bend edges"));
|
||||
}
|
||||
if !(rv.gap >= 0.0 && rv.bend_radius >= 0.0) {
|
||||
return err("the rule's gap and bend radius must not be negative");
|
||||
}
|
||||
let (b, mesh) = (&mb.body, mb.mesh());
|
||||
let size = b.size();
|
||||
if !(size.is_finite() && size > 0.0) {
|
||||
return err("the body has no size");
|
||||
}
|
||||
// Points closer than `tol` are one corner; a pick lies within `near` of what it picks.
|
||||
let (tol, near) = ((size * 1e-6).max(1e-6), (size * 2e-3).max(1e-3));
|
||||
let Some((_, f0)) = crate::appearance::nearest_face(mb, face).filter(|(d, _)| *d <= near) else {
|
||||
return err(format!("no face of the body at the fixed face point {}", at(face)));
|
||||
};
|
||||
let fixed = outline(b, &mesh, f0, tol).map_err(|e| DocError::Invalid(format!("the fixed face can't be a face of the sheet: {e}")))?;
|
||||
// The two faces along each bend edge.
|
||||
let mut picks = Vec::with_capacity(bends.len());
|
||||
for p in bends {
|
||||
let dist = |e: &Vec<Vec3>| e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||||
let edge = mesh.edges.iter().zip(&mesh.edge_faces).map(|(e, faces)| (dist(e), faces)).min_by(|x, y| x.0.total_cmp(&y.0));
|
||||
match edge.filter(|(d, _)| *d <= near).map(|(_, faces)| faces.as_slice()) {
|
||||
Some(&[x, y]) if x != y => picks.push((*p, (x as usize, y as usize))),
|
||||
Some(_) => return err(format!("the edge at the bend point {} does not lie between two faces", at(*p))),
|
||||
None => return err(format!("no edge of the body at the bend point {}", at(*p))),
|
||||
}
|
||||
}
|
||||
// A shell: the same small distance of material behind the fixed face and the faces at every
|
||||
// bend. It keeps its thickness, and a bend picked on one skin is that bend on the other too.
|
||||
let thin = behind(b, &mesh, f0).map(|(_, d)| d).filter(|d| *d > 0.0 && d * d * 16.0 <= area_about(&fixed.pts, fixed.n));
|
||||
let wall = |f: usize, d: f64| behind(b, &mesh, f).filter(|(_, e)| (e - d).abs() <= 1e-6 * d.max(1.0)).map(|(g, _)| g);
|
||||
let backs: Vec<Option<(usize, usize)>> = picks.iter().map(|(_, (x, y))| thin.and_then(|d| Some((wall(*x, d)?, wall(*y, d)?)))).collect();
|
||||
let shell = thin.filter(|_| backs.iter().all(Option::is_some));
|
||||
let t = shell.map_or(rv.thickness, |d| (d * 1e9).round() / 1e9);
|
||||
let r = rv.bend_radius;
|
||||
let joined = |x: usize, y: usize| mesh.edge_faces.iter().any(|faces| faces.contains(&(x as u32)) && faces.contains(&(y as u32)));
|
||||
let mut todo: Vec<(Vec3, Vec<(usize, usize)>)> = Vec::with_capacity(picks.len());
|
||||
for (&(p, pair), back) in picks.iter().zip(&backs) {
|
||||
let other = back.filter(|(x, y)| shell.is_some() && joined(*x, *y));
|
||||
todo.push((p, std::iter::once(pair).chain(other).collect()));
|
||||
}
|
||||
// The panels: the faces reached from the fixed face across the bends, each hanging off the
|
||||
// face it was reached from.
|
||||
let mut panels = BTreeMap::from([(f0, Panel { face: fixed, parent: None, angle: 0.0, setback: 0.0 })]);
|
||||
let mut order = vec![f0];
|
||||
loop {
|
||||
let waiting = todo.len();
|
||||
let mut rest = Vec::new();
|
||||
for (p, pairs) in todo {
|
||||
let reached = pairs.iter().find(|(x, y)| panels.contains_key(x) || panels.contains_key(y));
|
||||
let Some(&(x, y)) = reached else {
|
||||
rest.push((p, pairs));
|
||||
continue;
|
||||
};
|
||||
let (from, to) = if panels.contains_key(&x) { (x, y) } else { (y, x) };
|
||||
if let Some(seen) = panels.get(&to) {
|
||||
// The same bend picked twice is one bend; any other edge between two panels
|
||||
// would close a ring of bends, which no flat sheet folds into.
|
||||
if seen.parent == Some(from) || panels.get(&from).is_some_and(|k| k.parent == Some(to)) {
|
||||
continue;
|
||||
}
|
||||
return err(format!("the bend at {} closes a loop of bends (leave one of its edges unpicked: it is cut open)", at(p)));
|
||||
}
|
||||
let child = outline(b, &mesh, to, tol)
|
||||
.map_err(|e| DocError::Invalid(format!("the face across the bend at {} can't be a face of the sheet: {e}", at(p))))?;
|
||||
let Some(parent) = panels.get(&from) else { continue };
|
||||
let theta = parent.face.n.dot(child.n).clamp(-1.0, 1.0).acos();
|
||||
if !(theta > 1e-6 && theta < std::f64::consts::PI - 1e-6) {
|
||||
return err(format!("the faces at the bend at {} must meet at an angle between 0 and 180°", at(p)));
|
||||
}
|
||||
// Up: the child rises on the material side of its parent, which is behind its face.
|
||||
let o = parent.face.pts.first().copied().unwrap_or_default();
|
||||
let lift = child.pts.iter().map(|q| (*q - o).dot(parent.face.n)).fold(0.0, |m: f64, h| if h.abs() > m.abs() { h } else { m });
|
||||
let (angle, sharp) = if lift < 0.0 { (theta, r + t) } else { (-theta, r) };
|
||||
panels.insert(to, Panel { face: child, parent: Some(from), angle, setback: sharp * (theta / 2.0).tan() });
|
||||
order.push(to);
|
||||
}
|
||||
todo = rest;
|
||||
if todo.is_empty() || todo.len() == waiting {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some((p, _)) = todo.first() {
|
||||
return err(format!("the bend at {} does not connect to the fixed face through the other bends", at(*p)));
|
||||
}
|
||||
// The flat frame: on the fixed face, x along its longest edge, z into the material.
|
||||
let Some(base) = panels.get(&f0) else { return err("fixed face") };
|
||||
let corners = base.face.pts.iter().zip(base.face.pts.iter().cycle().skip(1));
|
||||
let longest = corners.max_by(|x, y| x.0.dist(*x.1).total_cmp(&y.0.dist(*y.1)));
|
||||
let frame = longest
|
||||
.and_then(|(a, b)| Some((*a, (*b - *a).normalized()?)))
|
||||
.and_then(|(a, x)| Plane::new(a, x, x.cross(base.face.n)))
|
||||
.ok_or_else(|| DocError::Invalid("the fixed face has no plane".into()))?;
|
||||
let mut sh = base_flange(&mb.name, rule, &RuleValues { thickness: t, ..*rv }, frame, Region2::default());
|
||||
// Lay the panels out, parents first: each is its face, seen through the inverse of its
|
||||
// fold, with its edges moved in by a bend's setback or a rip's cut.
|
||||
let mut queue: Vec<(usize, Option<usize>)> = vec![(f0, None)];
|
||||
let mut next = 0;
|
||||
while let Some(&(pf, fl)) = queue.get(next) {
|
||||
next += 1;
|
||||
let Some(panel) = panels.get(&pf) else { continue };
|
||||
let inv = mat_inverse(&sh.world(fl)).ok_or_else(|| DocError::Invalid("sheet panel frame".into()))?;
|
||||
let n = panel.face.pts.len();
|
||||
let (mut pts, mut by) = (Vec::with_capacity(n), Vec::with_capacity(n));
|
||||
for (i, p) in panel.face.pts.iter().enumerate() {
|
||||
let q = apply_point(&inv, *p);
|
||||
if !(q.z.abs() <= tol) {
|
||||
return err(format!("the face at {} does not lie where its bend puts it ({:.6} mm off)", at(*p), q.z));
|
||||
}
|
||||
pts.push(Vec2::new(q.x, q.y));
|
||||
let e = panel.face.pts.get((i + 1) % n).and_then(|b| (*b - *p).normalized()).unwrap_or(Vec3::X);
|
||||
by.push(match panel.face.across.get(i).copied().flatten().and_then(|g| Some((g, panels.get(&g)?))) {
|
||||
Some((g, _)) if panel.parent == Some(g) => panel.setback,
|
||||
Some((_, other)) if other.parent == Some(pf) => other.setback,
|
||||
Some((_, other)) => rip_cut(&panel.face, &other.face, e, t, rv.gap)?,
|
||||
None => 0.0,
|
||||
});
|
||||
}
|
||||
let mut across = panel.face.across.clone();
|
||||
if signed_area(&pts) < 0.0 {
|
||||
pts.reverse();
|
||||
turn(&mut by);
|
||||
turn(&mut across);
|
||||
}
|
||||
let first = panel.face.pts.first().copied().unwrap_or_default();
|
||||
let (trimmed, from) = inset(&pts, &by).map_err(|e| DocError::Invalid(format!("{e} (the face at {})", at(first))))?;
|
||||
match fl {
|
||||
None => sh.base.outer = Loop2::polygon(&trimmed.iter().map(|q| snap(*q)).collect::<Vec<_>>()),
|
||||
Some(i) => set_panel(&mut sh, i, &trimmed),
|
||||
}
|
||||
// Its children: flanges on the trimmed edges they bend at.
|
||||
let m = trimmed.len();
|
||||
for &g in &order {
|
||||
let Some(child) = panels.get(&g).filter(|c| c.parent == Some(pf)) else { continue };
|
||||
let runs: Vec<usize> =
|
||||
(0..m).filter(|j| from.get(*j).copied().flatten().and_then(|i| across.get(i).copied().flatten()) == Some(g)).collect();
|
||||
let (a, b) = match runs.as_slice() {
|
||||
&[j] => (trimmed.get(j).copied().map(snap), trimmed.get((j + 1) % m).copied().map(snap)),
|
||||
_ => return err("not supported yet: two faces of the sheet that meet along more than one edge"),
|
||||
};
|
||||
let (Some(a), Some(b)) = (a, b) else { continue };
|
||||
let Some(d) = (b - a).normalized() else { return err("a bend edge has no length") };
|
||||
sh.flanges.push(SheetFlange {
|
||||
parent: fl,
|
||||
p: a,
|
||||
d,
|
||||
len: a.dist(b),
|
||||
n: Vec2::new(d.y, -d.x),
|
||||
angle: child.angle,
|
||||
radius: r,
|
||||
leg: 0.0,
|
||||
ext: (0.0, 0.0),
|
||||
slope: (0.0, 0.0),
|
||||
shape: None,
|
||||
tag: String::new(),
|
||||
tear: (false, false),
|
||||
});
|
||||
queue.push((g, Some(sh.flanges.len() - 1)));
|
||||
}
|
||||
}
|
||||
Ok(sh)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn edges_move_in_by_their_own_amounts_and_step_along_one_line() {
|
||||
let v = Vec2::new;
|
||||
let square = [v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)];
|
||||
let (p, from) = inset(&square, &[1.0, 0.0, 2.0, 0.0]).unwrap();
|
||||
assert_eq!(p, vec![v(0.0, 1.0), v(10.0, 1.0), v(10.0, 8.0), v(0.0, 8.0)]);
|
||||
assert_eq!(from, vec![Some(0), Some(1), Some(2), Some(3)]);
|
||||
// Two edges in one line that move by different amounts: a square step between them,
|
||||
// so 5 x 2 goes from the 10 x 10.
|
||||
let split = [v(0.0, 0.0), v(5.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)];
|
||||
let (p, from) = inset(&split, &[0.0, 2.0, 0.0, 0.0, 0.0]).unwrap();
|
||||
assert_eq!(p, vec![v(0.0, 0.0), v(5.0, 0.0), v(5.0, 2.0), v(10.0, 2.0), v(10.0, 10.0), v(0.0, 10.0)]);
|
||||
assert_eq!(from, vec![Some(0), None, Some(1), Some(2), Some(3), Some(4)]);
|
||||
// A slanted end keeps its line: the corner slides along it (2 up a 45° edge is 2 along).
|
||||
let slanted = [v(0.0, 0.0), v(10.0, 0.0), v(15.0, 5.0), v(0.0, 5.0)];
|
||||
let (p, _) = inset(&slanted, &[2.0, 0.0, 0.0, 0.0]).unwrap();
|
||||
assert!(p[1].dist(v(12.0, 2.0)) < 1e-12 && p[0].dist(v(0.0, 2.0)) < 1e-12, "{p:?}");
|
||||
// Setbacks longer than the face leave nothing of it.
|
||||
assert!(inset(&square, &[6.0, 0.0, 6.0, 0.0]).is_err());
|
||||
assert!(inset(&square, &[f64::NAN, 0.0, 0.0, 0.0]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -386,6 +386,25 @@ fn timeline_clones_share_meshes_even_when_cloned_before_tessellation() {
|
||||
assert!(!a.mesh().triangles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_inserted_at_a_marker_inside_a_timeline_folder_joins_it() {
|
||||
let mut doc = Document::new("folder insertion");
|
||||
let first = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("First")).unwrap();
|
||||
let second =
|
||||
doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("Second")).unwrap();
|
||||
doc.timeline_groups.push(TimelineGroup { id: 100, name: "Pair".into(), parent: None, features: vec![first, second] });
|
||||
doc.marker = Some(1);
|
||||
|
||||
let inserted =
|
||||
doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("Inserted")).unwrap();
|
||||
|
||||
assert_eq!(doc.features.iter().map(|feature| feature.id).collect::<Vec<_>>(), vec![first, inserted, second]);
|
||||
assert_eq!(doc.timeline_groups[0].features, vec![first, inserted, second]);
|
||||
assert_eq!(doc.marker, Some(2));
|
||||
assert!(non_contiguous_timeline_group(&doc.features, &doc.timeline_groups).is_none());
|
||||
assert!(Document::from_json(&doc.to_json()).is_ok(), "the inserted feature must leave a reopenable design");
|
||||
}
|
||||
|
||||
/// A construction plane that is its own side resolves through the model and back into the
|
||||
/// document. The depth count has to survive that hop, or the recursion overflows the stack and
|
||||
/// aborts the process, which no panic guard can intercept.
|
||||
|
||||
@@ -5,6 +5,35 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Runs in PR CI as well as the heavier fixed-seed suite. Two pattern copies are
|
||||
/// disconnected, and one cylinder overlaps the block by half its volume.
|
||||
#[test]
|
||||
fn partial_face_contact_pattern_has_analytic_material() {
|
||||
// Repeated construction exposes re-imprinting a cylinder through both of its
|
||||
// side faces, which can create a redundant arc along the first imprint.
|
||||
for _ in 0..8 {
|
||||
check_partial_face_contact_pattern();
|
||||
}
|
||||
}
|
||||
|
||||
fn check_partial_face_contact_pattern() {
|
||||
let s = replay(include_str!("../../../examples/regressions/partial_face_contact_pattern.json"));
|
||||
let (mut volume, mut area, mut shells) = (0.0, 0.0, 0);
|
||||
for b in &s.model.state().bodies {
|
||||
assert!(b.body.validity().is_empty(), "{:?}", b.body.validity());
|
||||
let m = b.body.tessellate(1e-5).unwrap().measure();
|
||||
volume += m.volume;
|
||||
area += m.area;
|
||||
shells += b.body.shell_count();
|
||||
}
|
||||
let expected_volume = 25.0 * 47.5 * 37.5 + 3.5 * std::f64::consts::PI * 25.0 * 2.5;
|
||||
let expected_area = 2.0 * (25.0 * 47.5 + 25.0 * 37.5 + 47.5 * 37.5) + 237.5 * std::f64::consts::PI - 25.0;
|
||||
assert!((volume - expected_volume).abs() < 0.02, "{volume} expected {expected_volume}");
|
||||
assert!((area - expected_area).abs() < 0.02, "{area} expected {expected_area}");
|
||||
assert_eq!(shells, 3);
|
||||
println!("#674 volume {volume:.6} / {expected_volume:.6} mm³; area {area:.6} / {expected_area:.6} mm²; {shells} shells");
|
||||
}
|
||||
|
||||
fn replay(script: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(script).unwrap();
|
||||
@@ -153,3 +182,36 @@ fn ribs_flush_with_a_window_change_nothing_away_from_them() {
|
||||
let bounds = s.model.state().body("Lid").unwrap().body.tessellate(0.01).unwrap().bounds();
|
||||
assert!((bounds.max.z - 14.1).abs() < 1e-9, "{}", bounds.max.z);
|
||||
}
|
||||
|
||||
/// #679: one sketch of a 2 × 2 grid of 10 mm squares; three cells extruded up 10 mm (XO/XX) and
|
||||
/// three down 10 mm, joined (XX/OX). The two L's meet back to back over two whole cells, along
|
||||
/// an edge and at the centre: one solid of 6 cells × 10 mm × 100 mm².
|
||||
#[test]
|
||||
fn ls_extruded_up_and_down_from_one_grid_join() {
|
||||
let mut s = Session::default();
|
||||
let run = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Grid"}));
|
||||
for (p0, p1) in [([-10, -10], [0, 0]), ([0, -10], [10, 0]), ([-10, 0], [0, 10]), ([0, 0], [10, 10])] {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let cells = |pts: &[[f64; 2]]| Value::Array(pts.iter().map(|p| json!({"point": p})).collect());
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Grid", "profiles": cells(&[[-5., 5.], [-5., -5.], [5., -5.]]), "distance": 10, "body_name": "Logo"}),
|
||||
);
|
||||
let reply = run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Grid", "profiles": cells(&[[-5., 5.], [5., 5.], [5., -5.]]), "distance": "10 mm", "direction": "negative", "operation": "join"}),
|
||||
);
|
||||
assert!(reply["warning"].as_str().is_none_or(|w| !w.contains("tool displacement")), "{reply}");
|
||||
let st = s.model.state();
|
||||
assert_eq!(st.bodies.len(), 1);
|
||||
let body = &st.body("Logo").unwrap().body;
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
assert!(body.validity().is_empty(), "{:?}", body.validity());
|
||||
let v = volume(&s, "Logo");
|
||||
assert!((v - 6000.0).abs() < 1e-6, "{v}");
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for f in feats {
|
||||
let mut kind = f.kind.clone();
|
||||
kind.remap_refs(&sketches, &names);
|
||||
// A body made by an earlier copy is the one this copy acts on (#613).
|
||||
kind.remap_bodies(&bodies);
|
||||
if let FeatureKind::Sketch { plane, .. } = &mut kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
|
||||
{
|
||||
@@ -208,4 +210,47 @@ mod tests {
|
||||
let mut t = Session::default();
|
||||
assert!(t.execute("timeline.paste", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_at_a_marker_inside_a_timeline_folder_joins_the_folder() {
|
||||
let mut s = Session::default();
|
||||
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Source", "SourceBody", 40)] {
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
|
||||
);
|
||||
}
|
||||
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First", "Second"]}))["group"].as_u64().unwrap();
|
||||
run(&mut s, "timeline.copy", json!({"features": ["Source"]}));
|
||||
run(&mut s, "timeline.roll_to", json!({"position": 1}));
|
||||
|
||||
let pasted = run(&mut s, "timeline.paste", json!({}))["pasted"][0]["id"].as_u64().unwrap();
|
||||
let folder = s.doc.timeline_groups.iter().find(|folder| folder.id == group).unwrap();
|
||||
let first = s.doc.find_feature("First").unwrap().id;
|
||||
let second = s.doc.find_feature("Second").unwrap().id;
|
||||
assert_eq!(folder.features, vec![first, pasted, second]);
|
||||
assert!(solidcraft_doc::Document::from_json(&s.doc.to_json()).is_ok(), "pasting inside a folder must leave a reopenable design");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_fillet_rounds_the_copied_body_it_names() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 10, "operation": "new", "body_name": "Block", "name": "Block"}));
|
||||
run(&mut s, "solid.fillet", json!({"body": "Block", "radius": 2, "edges": [[20, 0, 10], [20, 20, 10]], "name": "Round"}));
|
||||
let one = volume(&mut s);
|
||||
assert!(one < 8000.0 - 1.0, "the fillet removes material: {one}");
|
||||
run(&mut s, "timeline.copy", json!({"features": ["S", "Block", "Round"]}));
|
||||
let r = run(&mut s, "timeline.paste", json!({"translate": [0, 40, 0]}));
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
let round = r["pasted"][2]["name"].as_str().unwrap_or_default().to_string();
|
||||
let def = run(&mut s, "timeline.inspect", json!({"feature": round}));
|
||||
assert_eq!(def["definition"]["body"], "Block (copy)", "{def}");
|
||||
let m = run(&mut s, "inspect.measure", json!({}));
|
||||
assert_eq!(m["body_count"], 2, "{m}");
|
||||
assert!((volume(&mut s) - 2.0 * one).abs() < 1e-6, "both blocks are rounded: {m}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ fn user_defined_material_survives_save_and_reopen() {
|
||||
run(&mut s, "material.create", json!({"name": "Leather", "density_g_cm3": 0.86, "appearance": "Rubber - Black"}));
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Tab"], "material": "Leather"}));
|
||||
run(&mut s, "file.save", json!({"path": path}));
|
||||
assert!(s.doc.to_json().contains("\"solidcraft/16\""));
|
||||
assert!(s.doc.to_json().contains("\"solidcraft/17\""));
|
||||
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
|
||||
@@ -25,11 +25,28 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.key("Delete")
|
||||
.params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"),
|
||||
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name (references to it follow)"),
|
||||
CommandSpec::new("timeline.group", "New Feature Folder", group)
|
||||
.icon("folder")
|
||||
.doc_only()
|
||||
.params("name?; features?: [id|name]; parent?: folder id (features leave any other timeline folder)"),
|
||||
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup)
|
||||
.doc_only()
|
||||
.params("group: id | groups: [ids] (features remain in the timeline)"),
|
||||
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).doc_only().params("group: id, name"),
|
||||
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group)
|
||||
.doc_only()
|
||||
.params("features?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
|
||||
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).params(
|
||||
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot; atomically changes folder membership and moves all selected contents as one block",
|
||||
),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
|
||||
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
|
||||
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
|
||||
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder)
|
||||
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
|
||||
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).params(
|
||||
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; refused when a feature that worked would fail",
|
||||
),
|
||||
CommandSpec::new("timeline.redefine", "Edit Feature", redefine)
|
||||
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"),
|
||||
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents)
|
||||
@@ -187,6 +204,302 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"feature": id, "name": name}))
|
||||
}
|
||||
|
||||
fn timeline_group_id(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
timeline_group_id_field(s, p, "group", cmd)
|
||||
}
|
||||
|
||||
fn timeline_group_id_field(s: &Session, p: &Value, field: &str, cmd: &str) -> Result<u64> {
|
||||
let id = p.get(field).and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{field}` must be a timeline folder id")))?;
|
||||
if s.doc.timeline_groups.iter().any(|group| group.id == id) { Ok(id) } else { Err(bad(cmd, format!("no timeline folder {id}"))) }
|
||||
}
|
||||
|
||||
fn group_features(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<Vec<u64>> {
|
||||
let values = match p.get("features") {
|
||||
Some(Value::Array(values)) => values,
|
||||
None if !required => return Ok(Vec::new()),
|
||||
_ => return Err(bad(cmd, "`features` must be a list of feature ids or names")),
|
||||
};
|
||||
if values.is_empty() && required {
|
||||
return Err(bad(cmd, "`features` must list at least one feature"));
|
||||
}
|
||||
if values.len() > 10_000 {
|
||||
return Err(bad(cmd, "too many features"));
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for value in values {
|
||||
let id = feature_id(s, Some(value), cmd)?;
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn group_ids(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<Vec<u64>> {
|
||||
let values = match p.get("groups") {
|
||||
Some(Value::Array(values)) => values,
|
||||
None if !required => return Ok(Vec::new()),
|
||||
None => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
|
||||
_ => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
|
||||
};
|
||||
if values.is_empty() && required {
|
||||
return Err(bad(cmd, "`groups` must list at least one folder"));
|
||||
}
|
||||
if values.len() > 10_000 {
|
||||
return Err(bad(cmd, "too many folders"));
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for value in values {
|
||||
let id = value.as_u64().ok_or_else(|| bad(cmd, "`groups` must contain folder ids"))?;
|
||||
if !s.doc.timeline_groups.iter().any(|group| group.id == id) {
|
||||
return Err(bad(cmd, format!("no timeline folder {id}")));
|
||||
}
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn group_move_would_cycle(s: &Session, target: u64, sources: &[u64]) -> bool {
|
||||
let sources: std::collections::BTreeSet<u64> = sources.iter().copied().collect();
|
||||
let parents: std::collections::BTreeMap<u64, Option<u64>> = s.doc.timeline_groups.iter().map(|group| (group.id, group.parent)).collect();
|
||||
let mut current = Some(target);
|
||||
let mut steps = 0usize;
|
||||
while let Some(id) = current {
|
||||
if sources.contains(&id) {
|
||||
return true;
|
||||
}
|
||||
steps += 1;
|
||||
if steps > s.doc.timeline_groups.len() {
|
||||
return true;
|
||||
}
|
||||
current = parents.get(&id).copied().flatten();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn group_feature_ids(doc: &solidcraft_doc::Document, roots: &[u64]) -> std::collections::BTreeSet<u64> {
|
||||
let by_id: std::collections::BTreeMap<u64, &solidcraft_doc::TimelineGroup> = doc.timeline_groups.iter().map(|group| (group.id, group)).collect();
|
||||
let mut children = std::collections::BTreeMap::<u64, Vec<u64>>::new();
|
||||
for group in &doc.timeline_groups {
|
||||
if let Some(parent) = group.parent {
|
||||
children.entry(parent).or_default().push(group.id);
|
||||
}
|
||||
}
|
||||
let mut pending = roots.to_vec();
|
||||
let mut visited = std::collections::BTreeSet::new();
|
||||
let mut features = std::collections::BTreeSet::new();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !visited.insert(id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(group) = by_id.get(&id) {
|
||||
features.extend(group.features.iter().copied());
|
||||
pending.extend(children.get(&id).into_iter().flatten().copied());
|
||||
}
|
||||
}
|
||||
features
|
||||
}
|
||||
|
||||
fn sort_group_features(doc: &mut solidcraft_doc::Document) {
|
||||
let positions: std::collections::BTreeMap<u64, usize> = doc.features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
|
||||
for group in &mut doc.timeline_groups {
|
||||
group.features.sort_by_key(|feature| positions.get(feature).copied().unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
fn require_contiguous_groups(doc: &solidcraft_doc::Document, cmd: &str) -> Result<()> {
|
||||
let Some(id) = solidcraft_doc::non_contiguous_timeline_group(&doc.features, &doc.timeline_groups) else { return Ok(()) };
|
||||
let name = doc.timeline_groups.iter().find(|group| group.id == id).map(|group| group.name.as_str()).unwrap_or("unknown");
|
||||
Err(bad(cmd, format!("timeline folder `{name}` must contain one contiguous run of features")))
|
||||
}
|
||||
|
||||
fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.group";
|
||||
let mut features = group_features(s, p, false, cmd)?;
|
||||
let groups = group_ids(s, p, false, cmd)?;
|
||||
let name = match str_(p, "name").map(str::trim).filter(|name| !name.is_empty()) {
|
||||
Some(name) if name.len() <= 128 => name.to_string(),
|
||||
Some(_) => return Err(bad(cmd, "`name` must be 1…128 characters")),
|
||||
None => format!("Folder{}", s.doc.timeline_groups.len() + 1),
|
||||
};
|
||||
let id = s
|
||||
.doc
|
||||
.timeline_groups
|
||||
.iter()
|
||||
.map(|group| group.id)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| bad(cmd, "no more timeline folder ids are available"))?;
|
||||
let parent = match p.get("parent") {
|
||||
Some(Value::Null) | None => None,
|
||||
Some(_) => Some(timeline_group_id_field(s, p, "parent", cmd)?),
|
||||
};
|
||||
if let Some(parent) = parent
|
||||
&& group_move_would_cycle(s, parent, &groups)
|
||||
{
|
||||
return Err(bad(cmd, "a new folder cannot contain an ancestor of its parent"));
|
||||
}
|
||||
let nested = group_feature_ids(&s.doc, &groups);
|
||||
features.retain(|feature| !nested.contains(feature));
|
||||
let mut next = (*s.doc).clone();
|
||||
for group in &mut next.timeline_groups {
|
||||
group.features.retain(|feature| !features.contains(feature));
|
||||
if groups.contains(&group.id) {
|
||||
group.parent = Some(id);
|
||||
}
|
||||
}
|
||||
next.timeline_groups.push(solidcraft_doc::TimelineGroup { id, name: name.clone(), parent, features });
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"group": id, "name": name, "parent": parent, "groups": groups}))
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.ungroup";
|
||||
let ids = match p.get("groups") {
|
||||
Some(Value::Array(values)) if !values.is_empty() && values.len() <= 10_000 => {
|
||||
let mut ids = Vec::new();
|
||||
for value in values {
|
||||
let id = value.as_u64().ok_or_else(|| bad(cmd, "`groups` must contain timeline folder ids"))?;
|
||||
if !s.doc.timeline_groups.iter().any(|group| group.id == id) {
|
||||
return Err(bad(cmd, format!("no timeline folder {id}")));
|
||||
}
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
Some(Value::Array(_)) => return Err(bad(cmd, "`groups` must list 1…10000 timeline folder ids")),
|
||||
Some(_) => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
|
||||
None => vec![timeline_group_id(s, p, cmd)?],
|
||||
};
|
||||
let parents: std::collections::BTreeMap<u64, Option<u64>> =
|
||||
s.doc.timeline_groups.iter().filter(|group| ids.contains(&group.id)).map(|group| (group.id, group.parent)).collect();
|
||||
let surviving_parent = |mut parent: Option<u64>| {
|
||||
let mut steps = 0usize;
|
||||
while let Some(id) = parent.filter(|id| ids.contains(id)) {
|
||||
parent = parents.get(&id).copied().flatten();
|
||||
steps += 1;
|
||||
if steps > parents.len() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
parent
|
||||
};
|
||||
let mut next = (*s.doc).clone();
|
||||
let moved_features: Vec<(Option<u64>, Vec<u64>)> = next
|
||||
.timeline_groups
|
||||
.iter()
|
||||
.filter(|group| ids.contains(&group.id))
|
||||
.map(|group| (surviving_parent(group.parent), group.features.clone()))
|
||||
.collect();
|
||||
for group in &mut next.timeline_groups {
|
||||
if ids.contains(&group.id) {
|
||||
continue;
|
||||
}
|
||||
group.parent = surviving_parent(group.parent);
|
||||
for (_, features) in moved_features.iter().filter(|(parent, _)| *parent == Some(group.id)) {
|
||||
group.features.extend(features.iter().copied());
|
||||
}
|
||||
}
|
||||
next.timeline_groups.retain(|group| !ids.contains(&group.id));
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"groups": ids}))
|
||||
}
|
||||
|
||||
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.rename_group";
|
||||
let id = timeline_group_id(s, p, cmd)?;
|
||||
let name = str_(p, "name")
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty() && name.len() <= 128)
|
||||
.ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
|
||||
if let Some(group) = s.doc_mut().timeline_groups.iter_mut().find(|group| group.id == id) {
|
||||
group.name = name.to_string();
|
||||
}
|
||||
Ok(json!({"group": id, "name": name}))
|
||||
}
|
||||
|
||||
fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.move_to_group";
|
||||
let features = group_features(s, p, false, cmd)?;
|
||||
let groups = group_ids(s, p, false, cmd)?;
|
||||
if features.is_empty() && groups.is_empty() {
|
||||
return Err(bad(cmd, "list at least one feature or folder"));
|
||||
}
|
||||
let target = match p.get("group") {
|
||||
Some(Value::Null) | None => None,
|
||||
Some(_) => Some(timeline_group_id(s, p, cmd)?),
|
||||
};
|
||||
if let Some(target) = target
|
||||
&& group_move_would_cycle(s, target, &groups)
|
||||
{
|
||||
return Err(bad(cmd, "a folder cannot be moved into itself or one of its descendants"));
|
||||
}
|
||||
let mut next = (*s.doc).clone();
|
||||
for group in &mut next.timeline_groups {
|
||||
group.features.retain(|feature| !features.contains(feature));
|
||||
if Some(group.id) == target {
|
||||
group.features.extend(features.iter().copied());
|
||||
}
|
||||
}
|
||||
for group in &mut next.timeline_groups {
|
||||
if groups.contains(&group.id) {
|
||||
group.parent = target;
|
||||
}
|
||||
}
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"features": features, "groups": groups, "group": target}))
|
||||
}
|
||||
|
||||
fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.move_many";
|
||||
let mut features = group_features(s, p, false, cmd)?;
|
||||
let groups = group_ids(s, p, false, cmd)?;
|
||||
if features.is_empty() && groups.is_empty() {
|
||||
return Err(bad(cmd, "list at least one feature or folder"));
|
||||
}
|
||||
let target = match p.get("group") {
|
||||
Some(Value::Null) | None => None,
|
||||
Some(_) => Some(timeline_group_id(s, p, cmd)?),
|
||||
};
|
||||
if let Some(target) = target
|
||||
&& group_move_would_cycle(s, target, &groups)
|
||||
{
|
||||
return Err(bad(cmd, "a folder cannot be moved into itself or one of its descendants"));
|
||||
}
|
||||
let nested = group_feature_ids(&s.doc, &groups);
|
||||
features.retain(|feature| !nested.contains(feature));
|
||||
let moving: Vec<u64> =
|
||||
s.doc.features.iter().filter(|feature| nested.contains(&feature.id) || features.contains(&feature.id)).map(|feature| feature.id).collect();
|
||||
let mut next = (*s.doc).clone();
|
||||
for group in &mut next.timeline_groups {
|
||||
group.features.retain(|feature| !features.contains(feature));
|
||||
if Some(group.id) == target {
|
||||
group.features.extend(features.iter().copied());
|
||||
}
|
||||
if groups.contains(&group.id) {
|
||||
group.parent = target;
|
||||
}
|
||||
}
|
||||
sort_group_features(&mut next);
|
||||
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
|
||||
let (next, ordered) = reorder_features(s, next, &moving, slot, cmd)?;
|
||||
if next != *s.doc {
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
Ok(json!({"features": ordered, "groups": groups, "group": target, "position": slot}))
|
||||
}
|
||||
|
||||
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = s.doc.features.len();
|
||||
let pos = match p.get("feature") {
|
||||
@@ -342,6 +655,12 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
nf.edge_names = solidcraft_doc::sheet::sheet_edge_names(&s.model.state_before(id), &points(a));
|
||||
}
|
||||
}
|
||||
// New bend edges for a conversion: the names of the body's edges there now.
|
||||
if let (FeatureKind::SheetConvert { bends: a, body, .. }, FeatureKind::SheetConvert { bends: b, body: was, .. }) = (&nf.kind, &f.kind)
|
||||
&& (a != b || body != was)
|
||||
{
|
||||
nf.edge_names = solidcraft_doc::sheet::bend_edge_names(&s.model.state_before(id), body, a);
|
||||
}
|
||||
// Profiles picked by index or point are kept by their curves, as when the feature was made.
|
||||
if nf.kind.clone().profile_sels_mut() != f.kind.clone().profile_sels_mut() {
|
||||
super::features::stable_profiles(s, &mut nf.kind)?;
|
||||
@@ -417,6 +736,8 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = next.features.remove(from);
|
||||
let name = f.name.clone();
|
||||
next.features.insert(to, f);
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
// Everything that evaluated before must still evaluate.
|
||||
let mut after = solidcraft_doc::Model::new();
|
||||
after.evaluate(&next);
|
||||
@@ -429,16 +750,78 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"feature": id, "position": to}))
|
||||
}
|
||||
|
||||
fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.reorder_many";
|
||||
let ids = group_features(s, p, true, cmd)?;
|
||||
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
|
||||
let (next, ordered) = reorder_features(s, (*s.doc).clone(), &ids, slot, cmd)?;
|
||||
if next != *s.doc {
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
Ok(json!({"features": ordered, "position": slot.min(s.doc.features.len())}))
|
||||
}
|
||||
|
||||
fn reorder_features(
|
||||
s: &Session,
|
||||
mut next: solidcraft_doc::Document,
|
||||
ids: &[u64],
|
||||
slot: usize,
|
||||
cmd: &str,
|
||||
) -> Result<(solidcraft_doc::Document, Vec<u64>)> {
|
||||
let n = next.features.len();
|
||||
let slot = slot.min(n);
|
||||
let selected: std::collections::BTreeSet<u64> = ids.iter().copied().collect();
|
||||
let ordered: Vec<u64> = next.features.iter().filter(|feature| selected.contains(&feature.id)).map(|feature| feature.id).collect();
|
||||
let before_slot = next.features.iter().take(slot).filter(|feature| selected.contains(&feature.id)).count();
|
||||
let insert_at = slot.saturating_sub(before_slot).min(n.saturating_sub(ordered.len()));
|
||||
|
||||
let mut moving = Vec::new();
|
||||
next.features.retain(|feature| {
|
||||
if selected.contains(&feature.id) {
|
||||
moving.push(feature.clone());
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
let unchanged = s.doc.features.iter().map(|feature| feature.id).eq(next.features[..insert_at]
|
||||
.iter()
|
||||
.chain(moving.iter())
|
||||
.chain(next.features[insert_at..].iter())
|
||||
.map(|feature| feature.id));
|
||||
next.features.splice(insert_at..insert_at, moving);
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
if !unchanged {
|
||||
let mut after = solidcraft_doc::Model::new();
|
||||
after.evaluate(&next);
|
||||
let broken = newly_broken(&s.model, &after);
|
||||
if !broken.is_empty() {
|
||||
let list: Vec<String> = broken.iter().map(|(name, error)| format!("{name} ({error})")).collect();
|
||||
return Err(bad(cmd, format!("moving the selected features there breaks {}", list.join("; "))));
|
||||
}
|
||||
}
|
||||
Ok((next, ordered))
|
||||
}
|
||||
|
||||
fn input_keys(kind: &FeatureKind) -> Vec<(String, solidcraft_doc::expr::Kind)> {
|
||||
let mut keys: Vec<_> = kind.inputs().into_iter().map(|(label, _, unit)| (label.to_string(), unit)).collect();
|
||||
if let FeatureKind::Fillet { style, .. } = kind {
|
||||
if let Some((key, _)) = keys.first_mut() {
|
||||
*key = "fillet.radius".into();
|
||||
}
|
||||
if let solidcraft_doc::FilletStyle::Variable { mid, .. } = style {
|
||||
for ((key, _), (position, _)) in keys.iter_mut().skip(2).zip(mid) {
|
||||
*key = format!("fillet.mid.{}", position.to_bits());
|
||||
match style {
|
||||
solidcraft_doc::FilletStyle::Variable { mid, .. } => {
|
||||
for ((key, _), (position, _)) in keys.iter_mut().skip(2).zip(mid) {
|
||||
*key = format!("fillet.mid.{}", position.to_bits());
|
||||
}
|
||||
}
|
||||
solidcraft_doc::FilletStyle::Spline { .. } => {
|
||||
for (i, (key, _)) in keys.iter_mut().enumerate().skip(1) {
|
||||
*key = format!("fillet.control.{i}");
|
||||
}
|
||||
}
|
||||
solidcraft_doc::FilletStyle::Constant | solidcraft_doc::FilletStyle::Chord => {}
|
||||
}
|
||||
}
|
||||
keys
|
||||
|
||||
@@ -310,3 +310,163 @@ fn inserting_variable_fillet_control_preserves_parameters_at_existing_positions(
|
||||
assert_eq!(length["value"], 3.0);
|
||||
assert_eq!(width["value"], 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_folders_are_command_backed_saved_undoable_and_follow_feature_ids() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[10, 5, 10]], "radius": 1, "name": "Round"}));
|
||||
let base = s.doc.find_feature("Base").unwrap().id;
|
||||
let round = s.doc.find_feature("Round").unwrap().id;
|
||||
|
||||
let made = run(&mut s, "timeline.group", json!({"name": "Housing", "features": ["Base", round]}));
|
||||
let group = made["group"].as_u64().unwrap();
|
||||
assert_eq!(s.doc.timeline_groups[0].features, vec![base, round]);
|
||||
run(&mut s, "timeline.rename", json!({"feature": base, "name": "Foundation"}));
|
||||
assert_eq!(s.doc.timeline_groups[0].features, vec![base, round], "folders follow stable ids, not names");
|
||||
run(&mut s, "timeline.rename_group", json!({"group": group, "name": "Main housing"}));
|
||||
run(&mut s, "timeline.move_to_group", json!({"features": [round], "group": null}));
|
||||
assert_eq!(s.doc.timeline_groups[0].features, vec![base]);
|
||||
|
||||
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.timeline_groups, s.doc.timeline_groups);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.timeline_groups[0].features, vec![base, round]);
|
||||
run(&mut s, "timeline.delete", json!({"features": [base]}));
|
||||
assert_eq!(s.doc.timeline_groups[0].features, vec![round], "deleted features leave folders cleanly");
|
||||
run(&mut s, "timeline.ungroup", json!({"group": group}));
|
||||
assert!(s.doc.timeline_groups.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_reorders_selected_features_as_one_block_and_removes_folders_in_one_step() {
|
||||
let mut s = Session::default();
|
||||
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] {
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
|
||||
);
|
||||
}
|
||||
let names = |session: &Session| session.doc.features.iter().map(|feature| feature.name.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&s), vec!["First", "Second", "Third"]);
|
||||
|
||||
run(&mut s, "timeline.reorder_many", json!({"features": ["First", "Third"], "position": 3}));
|
||||
assert_eq!(names(&s), vec!["Second", "First", "Third"], "selected features keep their relative order");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(names(&s), vec!["First", "Second", "Third"]);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(names(&s), vec!["Second", "First", "Third"]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
|
||||
let first = run(&mut s, "timeline.group", json!({"name": "One", "features": ["First"]}))["group"].as_u64().unwrap();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.timeline_groups.is_empty(), "creating a folder is undoable");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.timeline_groups.first().map(|group| group.id), Some(first));
|
||||
let second = run(&mut s, "timeline.group", json!({"name": "Two", "features": ["Second"]}))["group"].as_u64().unwrap();
|
||||
run(&mut s, "timeline.ungroup", json!({"groups": [first, second]}));
|
||||
assert!(s.doc.timeline_groups.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.timeline_groups.len(), 2, "removing several folders is one undoable command");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.doc.timeline_groups.is_empty(), "removing folders is redoable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_folders_refuse_unknown_non_contiguous_and_split_contents() {
|
||||
let mut s = Session::default();
|
||||
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] {
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
|
||||
);
|
||||
}
|
||||
let before = s.doc.clone();
|
||||
let error = s.execute("timeline.group", &json!({"features": ["First", "Third"]})).unwrap_err().to_string();
|
||||
assert!(error.contains("contiguous"), "{error}");
|
||||
assert_eq!(s.doc, before);
|
||||
|
||||
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First", "Second"]}))["group"].as_u64().unwrap();
|
||||
let before = s.doc.clone();
|
||||
let error = s.execute("timeline.reorder_many", &json!({"features": ["First"], "position": 3})).unwrap_err().to_string();
|
||||
assert!(error.contains("contiguous"), "{error}");
|
||||
assert_eq!(s.doc, before, "a refused reorder must not split the folder");
|
||||
|
||||
for params in [json!({"features": [999_u64], "group": group}), json!({"groups": [999_u64], "group": group})] {
|
||||
let error = s.execute("timeline.move_to_group", ¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains("no feature") || error.contains("no timeline folder"), "{error}");
|
||||
}
|
||||
|
||||
let mut damaged: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
let first = s.doc.find_feature("First").unwrap().id;
|
||||
let third = s.doc.find_feature("Third").unwrap().id;
|
||||
damaged["timeline_groups"] = json!([{"id": 1, "name": "Broken", "features": [first, third]}]);
|
||||
let error = solidcraft_doc::Document::from_json(&damaged.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("not contiguous"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_move_many_is_atomic_and_one_undo_step() {
|
||||
let mut s = Session::default();
|
||||
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] {
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
|
||||
);
|
||||
}
|
||||
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First"]}))["group"].as_u64().unwrap();
|
||||
let before = (*s.doc).clone();
|
||||
run(&mut s, "timeline.move_many", json!({"features": ["Third"], "group": group, "position": 1}));
|
||||
assert_eq!(s.doc.features.iter().map(|feature| feature.name.as_str()).collect::<Vec<_>>(), vec!["First", "Third", "Second"]);
|
||||
assert_eq!(s.doc.timeline_groups[0].features.len(), 2);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc, before, "one undo restores both order and folder membership");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.timeline_groups[0].features.len(), 2);
|
||||
|
||||
let child = run(&mut s, "timeline.group", json!({"name": "Child", "features": ["Second"]}))["group"].as_u64().unwrap();
|
||||
let before_parent = (*s.doc).clone();
|
||||
let parent = run(&mut s, "timeline.group", json!({"name": "Parent", "groups": [group, child]}))["group"].as_u64().unwrap();
|
||||
assert!(s.doc.timeline_groups.iter().filter(|folder| folder.parent == Some(parent)).count() == 2);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc, before_parent, "creating a parent for selected folders is one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_timeline_move_many_keeps_folder_membership_and_order() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[10, 5, 10]], "radius": 1, "name": "Round"}));
|
||||
let group = run(&mut s, "timeline.group", json!({"name": "Destination"}))["group"].as_u64().unwrap();
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let error = s.execute("timeline.move_many", &json!({"features": ["Base"], "group": group, "position": 2})).unwrap_err().to_string();
|
||||
assert!(error.contains("breaks"), "{error}");
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo.len(), undo, "a refused atomic move adds no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_folders_can_nest_and_reject_cycles() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
|
||||
let root = run(&mut s, "timeline.group", json!({"name": "Root"}))["group"].as_u64().unwrap();
|
||||
let child = run(&mut s, "timeline.group", json!({"name": "Child", "parent": root}))["group"].as_u64().unwrap();
|
||||
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, Some(root));
|
||||
run(&mut s, "timeline.move_to_group", json!({"groups": [child], "group": null}));
|
||||
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, None);
|
||||
run(&mut s, "timeline.move_to_group", json!({"groups": [child], "group": root}));
|
||||
let error = s.execute("timeline.move_to_group", &json!({"groups": [root], "group": child})).unwrap_err().to_string();
|
||||
assert!(error.contains("itself") || error.contains("descendant"), "{error}");
|
||||
let before = s.doc.clone();
|
||||
let error = s.execute("timeline.move_many", &json!({"groups": [root], "group": child, "position": 0})).unwrap_err().to_string();
|
||||
assert!(error.contains("itself") || error.contains("descendant"), "{error}");
|
||||
assert_eq!(s.doc, before);
|
||||
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.timeline_groups, s.doc.timeline_groups);
|
||||
run(&mut s, "timeline.ungroup", json!({"group": root}));
|
||||
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, None, "deleting a folder promotes its children");
|
||||
}
|
||||
|
||||
@@ -20,7 +20,12 @@ fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
|
||||
/// The outward normal of the planar body face at `p`.
|
||||
pub(super) fn face_normal(s: &Session, p: Vec3) -> Option<Vec3> {
|
||||
let (m, f, d, _) = face_at(s, p)?;
|
||||
face_normal_in(s, p, None)
|
||||
}
|
||||
|
||||
/// The outward normal of the planar face at `p` of `body` (any body when `None`).
|
||||
pub(super) fn face_normal_in(s: &Session, p: Vec3, body: Option<&str>) -> Option<Vec3> {
|
||||
let (m, f, d, _) = face_at_in(s, p, body).ok().flatten()?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return None;
|
||||
@@ -52,12 +57,20 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
|
||||
Some(ns.iter().all(|n| n.dot(n0) > 1.0 - 1e-6))
|
||||
}
|
||||
|
||||
/// The body face under a point: (mesh, face index, distance).
|
||||
/// The body face under a point: (mesh, face index, distance, body).
|
||||
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64, String)> {
|
||||
face_at_in(s, p, None).ok().flatten()
|
||||
}
|
||||
|
||||
type FacePick = (std::sync::Arc<Mesh>, u32, f64, String);
|
||||
|
||||
/// The face under a point of `only` (any body when `None`). Where faces of several bodies are
|
||||
/// equally near (bodies touching flush), the pick is ambiguous: `Err` names those bodies.
|
||||
fn face_at_in(s: &Session, p: Vec3, only: Option<&str>) -> std::result::Result<Option<FacePick>, Vec<String>> {
|
||||
let st = s.model.state();
|
||||
let mut best: Option<(std::sync::Arc<Mesh>, u32, f64, String)> = None;
|
||||
let mut ambiguous = false;
|
||||
for b in &st.bodies {
|
||||
let mut best: Option<FacePick> = None;
|
||||
let mut tied: Vec<String> = Vec::new();
|
||||
for b in st.bodies.iter().filter(|b| only.is_none_or(|n| b.name == n)) {
|
||||
let m = b.mesh();
|
||||
let mut near: Option<(u32, f64)> = None;
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
@@ -70,13 +83,16 @@ fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64, Stri
|
||||
if let Some((f, d)) = near {
|
||||
if best.as_ref().is_none_or(|x| d < x.2 - 1e-8) {
|
||||
best = Some((m.clone(), f, d, b.name.clone()));
|
||||
ambiguous = false;
|
||||
tied.clear();
|
||||
} else if best.as_ref().is_some_and(|x| (d - x.2).abs() <= 1e-8) {
|
||||
ambiguous = true;
|
||||
tied.push(b.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if ambiguous { None } else { best }
|
||||
match best {
|
||||
Some(b) if !tied.is_empty() => Err(std::iter::once(b.3).chain(tied).collect()),
|
||||
b => Ok(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Circle through three points.
|
||||
@@ -97,11 +113,17 @@ fn xy(v: Vec2) -> Value {
|
||||
|
||||
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
|
||||
/// the sketch id and a point inside the face (sketch coordinates). The sketch is left active.
|
||||
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<(u64, Vec2)> {
|
||||
let (m, f, d, body) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?;
|
||||
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec2)> {
|
||||
let none = || match face_body {
|
||||
Some(b) => bad(cmd, format!("no face of `{b}` at `face`")),
|
||||
None => bad(cmd, "no body face at `face`"),
|
||||
};
|
||||
let (m, f, d, body) = face_at_in(s, point, face_body)
|
||||
.map_err(|names| bad(cmd, format!("faces of several bodies meet at `face` ({}): name one with `face_body`", names.join(", "))))?
|
||||
.ok_or_else(none)?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return Err(bad(cmd, "no body face at `face`"));
|
||||
return Err(none());
|
||||
}
|
||||
let tris: Vec<[Vec3; 3]> = m.triangles.iter().zip(&m.tri_face).filter(|(_, tf)| **tf == f).filter_map(|(t, _)| m.tri(t)).collect();
|
||||
let normal = tris.iter().find_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).ok_or_else(|| bad(cmd, "degenerate face"))?;
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
|
||||
use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, expr::Kind, point_expr};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::{CommandSpec, not_in_sketch};
|
||||
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec2, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
@@ -13,11 +13,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("extrude")
|
||||
.key("E")
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.sweep", "Sweep", sweep)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sweep")
|
||||
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("fillet")
|
||||
.key("F")
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning)"),
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
|
||||
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
|
||||
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
|
||||
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
||||
@@ -107,14 +107,14 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("presspull")
|
||||
.key("Q")
|
||||
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
|
||||
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place)"),
|
||||
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").enabled(not_in_sketch).params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place)"),
|
||||
];
|
||||
|
||||
/// Where an extrude starts and which way is positive: a point inside the (first) profile or
|
||||
/// face, and the unit normal.
|
||||
fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
|
||||
if let Some(fp) = p.get("face").and_then(vec3) {
|
||||
return Some((fp, super::face::face_normal(s, fp)?));
|
||||
return Some((fp, super::face::face_normal_in(s, fp, str_(p, "face_body"))?));
|
||||
}
|
||||
let sid = feature_sketch(s, p, "Extrude").ok()?;
|
||||
let st = s.model.state();
|
||||
@@ -394,6 +394,10 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
(Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), &points))
|
||||
}
|
||||
FeatureKind::SheetHem { edges, .. } => (Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), edges)),
|
||||
// A conversion's bend edges are edges of the body it converts.
|
||||
FeatureKind::SheetConvert { body, bends, .. } if !bends.is_empty() => {
|
||||
(Vec::new(), solidcraft_doc::sheet::bend_edge_names(&s.model.state(), body, bends))
|
||||
}
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let face_names = {
|
||||
@@ -495,11 +499,31 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
|
||||
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
|
||||
Some(fp) => {
|
||||
let (id, inside) = super::face::sketch_of_face(s, fp, cmd)?;
|
||||
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
(id, Some(ProfileSel::Points { points: vec![inside] }))
|
||||
}
|
||||
None => (feature_sketch(s, p, cmd)?, None),
|
||||
};
|
||||
let extent = extent_param(s, p, cmd)?;
|
||||
let kind = FeatureKind::Extrude {
|
||||
sketch,
|
||||
profiles: match face_profile {
|
||||
Some(f) => f,
|
||||
None => profiles(p, cmd)?,
|
||||
},
|
||||
extent,
|
||||
operation: match str_(p, "operation") {
|
||||
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
|
||||
_ => operation(p, cmd)?,
|
||||
},
|
||||
targets: string_list(p, "targets"),
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
}
|
||||
|
||||
/// An extrude's extent from its parameters: distance (or through all, or to an object), the
|
||||
/// side(s), a second distance, a start offset, a taper, and from an object.
|
||||
pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result<Extent> {
|
||||
let through_all = bool_(p, "through_all").unwrap_or(false) || str_(p, "extent").is_some_and(|e| e.eq_ignore_ascii_case("through_all"));
|
||||
let to_object = p.get("to").is_some();
|
||||
if to_object && p.get("to").and_then(vec3).is_none() {
|
||||
@@ -528,30 +552,17 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(d) = &start_offset {
|
||||
check_expr(s, d, Kind::Length, cmd, "start_offset")?;
|
||||
}
|
||||
let kind = FeatureKind::Extrude {
|
||||
sketch,
|
||||
profiles: match face_profile {
|
||||
Some(f) => f,
|
||||
None => profiles(p, cmd)?,
|
||||
},
|
||||
extent: Extent {
|
||||
distance,
|
||||
direction,
|
||||
distance2,
|
||||
start_offset,
|
||||
through_all,
|
||||
taper,
|
||||
to: p.get("to").and_then(vec3),
|
||||
to_offset: expr(p, "to_offset"),
|
||||
from: p.get("from").and_then(vec3),
|
||||
},
|
||||
operation: match str_(p, "operation") {
|
||||
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
|
||||
_ => operation(p, cmd)?,
|
||||
},
|
||||
targets: string_list(p, "targets"),
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
Ok(Extent {
|
||||
distance,
|
||||
direction,
|
||||
distance2,
|
||||
start_offset,
|
||||
through_all,
|
||||
taper,
|
||||
to: p.get("to").and_then(vec3),
|
||||
to_offset: expr(p, "to_offset"),
|
||||
from: p.get("from").and_then(vec3),
|
||||
})
|
||||
}
|
||||
|
||||
fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -559,7 +570,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A planar body face revolves like a profile (its edges projected into a sketch on it).
|
||||
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
|
||||
Some(fp) => {
|
||||
let (id, inside) = super::face::sketch_of_face(s, fp, cmd)?;
|
||||
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
s.active_sketch = None;
|
||||
(id, Some(ProfileSel::Points { points: vec![inside] }))
|
||||
}
|
||||
@@ -612,14 +623,36 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, kind)
|
||||
}
|
||||
|
||||
fn radius_expr(p: &Value, cmd: &str) -> Result<String> {
|
||||
fn radius_expr(s: &Session, p: &Value, cmd: &str) -> Result<String> {
|
||||
match (expr(p, "radius"), expr(p, "diameter")) {
|
||||
(Some(r), _) => Ok(r),
|
||||
(None, Some(d)) => Ok(format!("({d}) / 2")),
|
||||
(None, Some(d)) => {
|
||||
let [d] = length_operands(s, [(&d, &typed_length_unit(s))]);
|
||||
Ok(format!("({d}) / 2"))
|
||||
}
|
||||
_ => Err(bad(cmd, "needs `radius` or `diameter`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit a bare length typed into a command means: the design's units. (A point's bare
|
||||
/// coordinates, and a bare number already stored in a feature, are millimetres.)
|
||||
pub(super) fn typed_length_unit(s: &Session) -> String {
|
||||
solidcraft_doc::expr::length_unit_or_mm(&s.doc.units).to_string()
|
||||
}
|
||||
|
||||
/// The lengths a command works one expression out from (a radius from a diameter, a pitch from
|
||||
/// a height), each with the unit a bare number means where it came from. Bare numbers get that
|
||||
/// unit written in, so the expression keeps the meaning they had: inside a larger expression a
|
||||
/// bare number is read in the design's units of the moment, and changing them would resize the
|
||||
/// part (#716). Next to a unit-less expression that is not a bare number (`2 * 5`, a unit-less
|
||||
/// parameter), which is read in the design's units itself, they stay bare and are read with it,
|
||||
/// as before.
|
||||
pub(super) fn length_operands<const N: usize>(s: &Session, operands: [(&str, &str); N]) -> [String; N] {
|
||||
use solidcraft_doc::expr::is_bare_number;
|
||||
let follows = operands.iter().any(|(e, _)| !is_bare_number(e) && s.doc.eval(e, Kind::Unitless).is_ok());
|
||||
operands.map(|(e, unit)| if !follows && is_bare_number(e) { format!("{} {unit}", e.trim()) } else { e.to_string() })
|
||||
}
|
||||
|
||||
/// A placement point: `[x, y, z]` (or `{x, y, z}`) of numbers (mm) or length expressions;
|
||||
/// the origin when it is not given. Anything else is refused (never read as the origin).
|
||||
pub(super) fn point_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<PointExpr>> {
|
||||
@@ -664,11 +697,17 @@ fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let corner = match (point_param(s, p, "corner", cmd)?, point_param(s, p, "center", cmd)?) {
|
||||
(Some(c), _) => c,
|
||||
// The corner half a size back from the centre (numbers stay numbers).
|
||||
// The corner half a size back from the centre (numbers stay numbers, in mm). The centre's
|
||||
// bare numbers are millimetres, the sizes' the design's units.
|
||||
(None, Some([cx, cy, cz])) => {
|
||||
let unit = typed_length_unit(s);
|
||||
let mm_per_unit = solidcraft_doc::expr::unit_info(&unit).map_or(1.0, |(_, k)| k);
|
||||
let back = |c: String, size: &String| match (c.parse::<f64>(), size.parse::<f64>()) {
|
||||
(Ok(a), Ok(b)) => format!("{}", a - b / 2.0),
|
||||
_ => format!("({c}) - ({size}) / 2"),
|
||||
(Ok(a), Ok(b)) => format!("{}", a - b * mm_per_unit / 2.0),
|
||||
_ => {
|
||||
let [c, size] = length_operands(s, [(&c, "mm"), (size, &unit)]);
|
||||
format!("({c}) - ({size}) / 2")
|
||||
}
|
||||
};
|
||||
[back(cx, &l), back(cy, &w), back(cz, &h)]
|
||||
}
|
||||
@@ -679,7 +718,7 @@ fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.cylinder";
|
||||
let radius = radius_expr(p, cmd)?;
|
||||
let radius = radius_expr(s, p, cmd)?;
|
||||
let height = req_expr(cmd, p, "height")?;
|
||||
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
|
||||
check_expr(s, &height, Kind::Length, cmd, "height")?;
|
||||
@@ -690,7 +729,7 @@ fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.sphere";
|
||||
let radius = radius_expr(p, cmd)?;
|
||||
let radius = radius_expr(s, p, cmd)?;
|
||||
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
|
||||
let center = point_param(s, p, "center", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
|
||||
add_feature(s, p, FeatureKind::Sphere { center, radius, operation: operation(p, cmd)? })
|
||||
@@ -754,7 +793,9 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
FeatureKind::Chamfer { distance, .. } => ("distance", distance.clone()),
|
||||
_ => return Err(bad(cmd, "that face is not a fillet's or a chamfer's")),
|
||||
};
|
||||
let new = format!("({old}) + ({distance})");
|
||||
// A bare number stored in the feature is millimetres; the distance's, the design's units.
|
||||
let [old, more] = length_operands(s, [(&old, "mm"), (&distance, &typed_length_unit(s))]);
|
||||
let new = format!("({old}) + ({more})");
|
||||
let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?;
|
||||
let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?;
|
||||
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed"), "restored": out.get("restored")}));
|
||||
@@ -763,7 +804,13 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?;
|
||||
return (r.run)(s, &json!({"faces": [face], "distance": distance}));
|
||||
}
|
||||
let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (format!("-({distance})"), "negative", "cut") };
|
||||
// Pushed in: the extrude cuts the other way by as much (a bare number stays one, so it keeps
|
||||
// being read as a bare number typed into the extrude).
|
||||
let flipped = match distance.trim().parse::<f64>() {
|
||||
Ok(v) if solidcraft_doc::expr::is_bare_number(&distance) => format!("{}", -v),
|
||||
_ => format!("-({distance})"),
|
||||
};
|
||||
let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (flipped, "negative", "cut") };
|
||||
extrude(s, &json!({"face": face, "distance": dist, "direction": dir, "operation": op}))
|
||||
}
|
||||
|
||||
@@ -799,15 +846,41 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let smooth = p.get("smooth").and_then(Value::as_bool).unwrap_or(false);
|
||||
solidcraft_doc::FilletStyle::Variable { radius2, start, mid, smooth }
|
||||
}
|
||||
Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord or variable)"))),
|
||||
Some("spline") => {
|
||||
let start = p
|
||||
.get("start")
|
||||
.and_then(vec3)
|
||||
.ok_or_else(|| bad(cmd, "a spline fillet needs `start`: [x,y,z] near the end its positions start from"))?;
|
||||
let knots: Vec<f64> = p.get("knots").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_f64).collect();
|
||||
let mut radii = Vec::new();
|
||||
for r in p.get("radii").and_then(Value::as_array).into_iter().flatten().take(100) {
|
||||
let r = match r {
|
||||
Value::String(x) => x.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
_ => return Err(bad(cmd, "a control radius is a number or an expression")),
|
||||
};
|
||||
check_expr(s, &r, Kind::Length, cmd, "control radius")?;
|
||||
radii.push(r);
|
||||
}
|
||||
// All control radii (`radius` and `radii`) plus the degree (1…3) plus one.
|
||||
if radii.is_empty() || !(radii.len() + 3..=radii.len() + 5).contains(&knots.len()) {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"a spline fillet needs `radii` (the control radii after `radius`) and `knots` (as many as all control radii, the degree 1…3 and one more)",
|
||||
));
|
||||
}
|
||||
solidcraft_doc::FilletStyle::Spline { start, knots, radii }
|
||||
}
|
||||
Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord, variable or spline)"))),
|
||||
};
|
||||
let fit = match str_(p, "fit").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("exact") => solidcraft_doc::FilletFit::Exact,
|
||||
Some("reduce") => solidcraft_doc::FilletFit::Reduce,
|
||||
Some(o) => return Err(bad(cmd, format!("unknown fit `{o}` (exact or reduce)"))),
|
||||
Some("taper") => solidcraft_doc::FilletFit::Taper,
|
||||
Some(o) => return Err(bad(cmd, format!("unknown fit `{o}` (exact, reduce or taper)"))),
|
||||
};
|
||||
if fit == solidcraft_doc::FilletFit::Reduce && !style.is_constant() {
|
||||
return Err(bad(cmd, "`fit: reduce` takes a constant radius"));
|
||||
if !fit.is_exact() && !style.is_constant() {
|
||||
return Err(bad(cmd, "`fit: reduce` and `fit: taper` take a constant radius"));
|
||||
}
|
||||
let mut out = add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string), style, fit })?;
|
||||
// The radii the fillet used, when `fit: reduce` reduced it.
|
||||
@@ -1591,3 +1664,7 @@ mod stacked_face_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "solid_move_copy_tests.rs"]
|
||||
mod solid_move_copy_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "move_in_sketch_tests.rs"]
|
||||
mod move_in_sketch_tests;
|
||||
|
||||
@@ -6,7 +6,9 @@ use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, axis_dir_param, check_expr, feature_sketch, plane_param, point_param, profiles};
|
||||
use super::features::{
|
||||
add_feature, axis_dir_param, check_expr, feature_sketch, length_operands, plane_param, point_param, profiles, typed_length_unit,
|
||||
};
|
||||
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -49,10 +51,18 @@ fn coil(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
|
||||
let turns = expr(p, "revolutions").or_else(|| expr(p, "turns"));
|
||||
let (height, pitch) = (expr(p, "height"), expr(p, "pitch"));
|
||||
// The pitch or the revolutions from the height keep the units the lengths were given in.
|
||||
let unit = typed_length_unit(s);
|
||||
let (turns, pitch) = match (turns, height, pitch) {
|
||||
(Some(n), _, Some(pt)) => (n, pt),
|
||||
(Some(n), Some(h), None) => (n.clone(), format!("({h}) / ({n})")),
|
||||
(None, Some(h), Some(pt)) => (format!("({h}) / ({pt})"), pt),
|
||||
(Some(n), Some(h), None) => {
|
||||
let [h] = length_operands(s, [(&h, &unit)]);
|
||||
(n.clone(), format!("({h}) / ({n})"))
|
||||
}
|
||||
(None, Some(h), Some(pt)) => {
|
||||
let [h, by] = length_operands(s, [(&h, &unit), (&pt, &unit)]);
|
||||
(format!("({h}) / ({by})"), pt)
|
||||
}
|
||||
_ => return Err(bad(cmd, "give two of `revolutions`, `height` and `pitch`")),
|
||||
};
|
||||
check_expr(s, &turns, Kind::Unitless, cmd, "revolutions")?;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -164,3 +164,166 @@ fn a_piece_of_a_face_split_on_purpose_still_needs_a_fixed_plane() {
|
||||
let error = s.execute("sketch.create", &json!({"plane": {"face": [2.5, 5, 10]}})).unwrap_err().to_string();
|
||||
assert!(error.contains("fixed:true"), "{error}");
|
||||
}
|
||||
|
||||
/// Offset Face raises one piece of a split top: the pieces no longer share a plane, so each
|
||||
/// takes a sketch by its own name (the face's, qualified by the faces around it) and follows it.
|
||||
#[test]
|
||||
fn a_raised_piece_of_a_split_top_takes_a_sketch_by_its_own_name() {
|
||||
let mut s = slotted_block();
|
||||
run(&mut s, "parameters.add", json!({"name": "raise", "expression": "2 mm"}));
|
||||
run(&mut s, "solid.offset_face", json!({"faces": [[5, 15, 10]], "distance": "raise", "name": "Raise"}));
|
||||
no_errors(&s);
|
||||
sketch_on(&mut s, [5., 15., 12.], "Raised");
|
||||
sketch_on(&mut s, [35., 15., 10.], "Kept");
|
||||
let (raised, certificate) = healthy(&s, "Raised");
|
||||
assert_eq!(certificate, Some(FaceCertificate::Lineage));
|
||||
assert!((raised.origin.z - 12.).abs() < 1e-9 && (raised.normal().z - 1.).abs() < 1e-9, "{raised:?}");
|
||||
assert!((healthy(&s, "Kept").0.origin.z - 10.).abs() < 1e-9);
|
||||
run(&mut s, "parameters.change", json!({"name": "raise", "expression": "3 mm"}));
|
||||
no_errors(&s);
|
||||
assert!((healthy(&s, "Raised").0.origin.z - 13.).abs() < 1e-9);
|
||||
assert!((healthy(&s, "Kept").0.origin.z - 10.).abs() < 1e-9);
|
||||
let t = reopened(&s);
|
||||
assert!((healthy(&t, "Raised").0.origin.z - 13.).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// A plastic Boss keeps the history of the body it stands on (#494): the plate's faces still
|
||||
/// take attached sketches, and so does the boss's own top, which follows its height.
|
||||
#[test]
|
||||
fn faces_after_a_boss_take_a_sketch_that_follows_it() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "boss_h", "expression": "12 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 2, "name": "Plate"}));
|
||||
run(
|
||||
&mut s,
|
||||
"plastic.boss",
|
||||
json!({"position": [20, 20, 2], "diameter": 7, "height": "boss_h", "hole_diameter": 2.5, "fillet": 0.5, "name": "Boss"}),
|
||||
);
|
||||
no_errors(&s);
|
||||
sketch_on(&mut s, [5., 25., 2.], "OnPlate");
|
||||
sketch_on(&mut s, [80., 25., 1.], "OnSide");
|
||||
sketch_on(&mut s, [22.5, 20., 14.], "OnBoss");
|
||||
for name in ["OnPlate", "OnSide"] {
|
||||
healthy(&s, name);
|
||||
}
|
||||
let (top, certificate) = healthy(&s, "OnBoss");
|
||||
assert_eq!(certificate, Some(FaceCertificate::Lineage));
|
||||
assert!((top.origin.z - 14.).abs() < 1e-9, "{top:?}");
|
||||
run(&mut s, "parameters.change", json!({"name": "boss_h", "expression": "15 mm"}));
|
||||
no_errors(&s);
|
||||
assert!((healthy(&s, "OnBoss").0.origin.z - 17.).abs() < 1e-9);
|
||||
healthy(&reopened(&s), "OnBoss");
|
||||
}
|
||||
|
||||
/// A Hole names its faces by the part they cut (a counterbore's floor is `F<hole>:cbore:base`),
|
||||
/// and a pattern's copies of it as copies: each floor takes its own sketch, which follows the
|
||||
/// counterbore's depth. The plate's faces stay attachable too.
|
||||
#[test]
|
||||
fn counterbore_floors_and_their_pattern_copies_take_a_sketch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "cb", "expression": "3 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 80, "width": 30, "height": 10, "name": "Plate"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.hole",
|
||||
json!({"position": [15, 15, 10], "diameter": 5, "type": "counterbore", "cb_diameter": 10, "cb_depth": "cb", "name": "Hole"}),
|
||||
);
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Hole"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 25, "name": "Row"}));
|
||||
no_errors(&s);
|
||||
for (name, x) in [("Seed", 18.5), ("Copy", 43.5), ("Top", 30.)] {
|
||||
sketch_on(&mut s, [x, 15., if name == "Top" { 10. } else { 7. }], name);
|
||||
}
|
||||
for name in ["Seed", "Copy"] {
|
||||
let (floor, certificate) = healthy(&s, name);
|
||||
assert_eq!(certificate, Some(FaceCertificate::Lineage), "{name}");
|
||||
assert!((floor.origin.z - 7.).abs() < 1e-9, "{name}: {floor:?}");
|
||||
}
|
||||
healthy(&s, "Top");
|
||||
run(&mut s, "parameters.change", json!({"name": "cb", "expression": "4 mm"}));
|
||||
no_errors(&s);
|
||||
for name in ["Seed", "Copy"] {
|
||||
assert!((healthy(&s, name).0.origin.z - 6.).abs() < 1e-9, "{name}");
|
||||
}
|
||||
healthy(&reopened(&s), "Copy");
|
||||
}
|
||||
|
||||
/// The middle of the largest triangle of each flat face of the first body.
|
||||
fn flat_faces(s: &Session) -> Vec<(usize, solidcraft_geom::Vec3)> {
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
let mesh = body.tessellate(0.01).unwrap();
|
||||
(0..body.face_count())
|
||||
.filter(|index| body.planar_face(*index).is_some())
|
||||
.filter_map(|index| {
|
||||
let point = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, f)| **f as usize == index)
|
||||
.filter_map(|(t, _)| mesh.tri(t))
|
||||
.max_by(|[a, b, c], [d, e, f]| (*b - *a).cross(*c - *a).len().total_cmp(&(*e - *d).cross(*f - *d).len()))
|
||||
.map(|[a, b, c]| (a + b + c) * (1.0 / 3.0))?;
|
||||
Some((index, point))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every flat face of the first body takes an attached sketch (#681: a flat face is a flat face,
|
||||
/// whichever features it was merged from).
|
||||
fn every_flat_face_takes_a_sketch(s: &mut Session) {
|
||||
no_errors(s);
|
||||
let faces = flat_faces(s);
|
||||
assert!(faces.len() >= 8, "{}", faces.len());
|
||||
let mut failed = Vec::new();
|
||||
for (index, point) in faces {
|
||||
match s.execute("sketch.create", &json!({"plane": {"face": point, "body": "Body1", "index": index}})) {
|
||||
Ok(made) => {
|
||||
run(s, "sketch.finish", json!({}));
|
||||
let id = made["sketch"].as_u64().unwrap();
|
||||
if !matches!(s.placed_document().sketch_face_attachment(id, &s.model), FaceAttachmentStatus::Healthy { .. }) {
|
||||
failed.push(format!("face {index} at {point:?}: not healthy"));
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push(format!("face {index} at {point:?}: {e}")),
|
||||
}
|
||||
}
|
||||
assert!(failed.is_empty(), "{}", failed.join("\n"));
|
||||
}
|
||||
|
||||
/// #681: a block with a lower step joined flush beside it. The step's side walls continue the
|
||||
/// block's (a Join merged them), and they take a sketch like every other flat face.
|
||||
#[test]
|
||||
fn every_flat_face_of_a_block_and_a_flush_step_takes_a_sketch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "A"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "A", "distance": 10, "name": "Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "B"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 10], "p1": [20, 20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "B", "distance": 5, "operation": "join", "name": "Step"}));
|
||||
every_flat_face_takes_a_sketch(&mut s);
|
||||
}
|
||||
|
||||
/// #681 as reported: one sketch of a 2 x 2 grid, three cells extruded up (an elbow) and three
|
||||
/// joined down (the inverse elbow).
|
||||
#[test]
|
||||
#[ignore = "the join of the elbow down fails until #679 is fixed"]
|
||||
fn every_flat_face_of_an_elbow_up_and_an_elbow_down_takes_a_sketch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Grid"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0], [20, 20], [0, 20]], "closed": true}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 0], [10, 20]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 10], [20, 10]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let cells = |pts: [[f64; 2]; 3]| pts.iter().map(|p| json!({"point": p})).collect::<Vec<_>>();
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Grid", "distance": 10, "profiles": cells([[5., 5.], [15., 5.], [5., 15.]]), "name": "Up"}));
|
||||
let down = cells([[15., 5.], [5., 15.], [15., 15.]]);
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Grid", "distance": 10, "direction": "negative", "operation": "join", "profiles": down, "name": "Down"}),
|
||||
);
|
||||
every_flat_face_takes_a_sketch(&mut s);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,17 @@ macro_rules! created_doc {
|
||||
};
|
||||
}
|
||||
|
||||
/// A command that is designed and registered (id, toolbar place, icon, params doc) but not built
|
||||
/// yet. It is disabled with the reason, so the toolbar greys it and says why, and its `run`
|
||||
/// refuses too, however it is reached. `$issue` is the GitHub issue that tracks building it.
|
||||
macro_rules! planned {
|
||||
($id:literal, $label:literal, $issue:literal) => {
|
||||
CommandSpec::new($id, $label, |_, _| Err(crate::EngineError::Other(concat!("not supported yet: ", $label, " (issue #", $issue, ")").into())))
|
||||
.enabled(|_| Err(concat!("not supported yet (issue #", $issue, ")").into()))
|
||||
.planned($issue)
|
||||
};
|
||||
}
|
||||
|
||||
mod browser;
|
||||
mod clipboard;
|
||||
mod component;
|
||||
@@ -38,6 +49,7 @@ pub(crate) use measure_sel::measure_items;
|
||||
mod kernel_heal_tests;
|
||||
mod parameters;
|
||||
mod plastic;
|
||||
mod render;
|
||||
mod sheet;
|
||||
mod sketch;
|
||||
pub(crate) use sketch::author_path_for_rebind;
|
||||
@@ -53,6 +65,7 @@ mod sketch_modify;
|
||||
mod sketch_project;
|
||||
mod surface;
|
||||
mod surface_analysis;
|
||||
mod view_navigation;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
@@ -87,6 +100,8 @@ pub struct CommandSpec {
|
||||
/// Reads the evaluated model (the default). A command that reads only the document and the
|
||||
/// session's history runs on a model left stale by a live gesture without evaluating it first.
|
||||
pub reads_model: bool,
|
||||
/// Registered but not built yet (see `planned!`): the GitHub issue number that tracks it.
|
||||
pub planned: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
@@ -104,8 +119,14 @@ impl CommandSpec {
|
||||
undoable: true,
|
||||
live: None,
|
||||
reads_model: true,
|
||||
planned: None,
|
||||
}
|
||||
}
|
||||
/// Not built yet; `issue` tracks building it.
|
||||
pub const fn planned(mut self, issue: &'static str) -> Self {
|
||||
self.planned = Some(issue);
|
||||
self
|
||||
}
|
||||
/// Takes `live: true`, run by `run`.
|
||||
pub const fn live(mut self, run: LiveRun) -> Self {
|
||||
self.live = Some(run);
|
||||
@@ -159,6 +180,7 @@ impl CommandSpec {
|
||||
params: self.params,
|
||||
enabled: e.is_ok(),
|
||||
disabled_reason: e.err(),
|
||||
planned: self.planned,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,6 +196,8 @@ pub struct CommandInfo {
|
||||
pub params: &'static str,
|
||||
pub enabled: bool,
|
||||
pub disabled_reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub planned: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
@@ -184,6 +208,12 @@ pub fn in_sketch(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active_sketch.is_some() { Ok(()) } else { Err("no sketch is being edited (Create Sketch or Edit Sketch first)".into()) }
|
||||
}
|
||||
|
||||
/// The mirror of [`in_sketch`], for a command that acts on bodies and has nothing to offer the
|
||||
/// geometry under the cursor while a sketch is open.
|
||||
pub fn not_in_sketch(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active_sketch.is_none() { Ok(()) } else { Err("a sketch is being edited (this works on bodies: Finish Sketch first)".into()) }
|
||||
}
|
||||
|
||||
/// Every command, in toolbar order.
|
||||
pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
let mut v: Vec<&'static CommandSpec> = Vec::new();
|
||||
@@ -219,7 +249,9 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(inspect::COMMANDS.iter());
|
||||
v.extend(materials::COMMANDS.iter());
|
||||
v.extend(section::COMMANDS.iter());
|
||||
v.extend(view_navigation::COMMANDS.iter());
|
||||
v.extend(diagnostics::COMMANDS.iter());
|
||||
v.extend(render::COMMANDS.iter());
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! `solid.move` moves bodies, so it has nothing to offer the geometry under the cursor while a
|
||||
//! sketch is being edited. It is disabled there, with a reason, and comes back once the sketch
|
||||
//! is finished.
|
||||
use super::*;
|
||||
|
||||
fn cube(s: &mut Session) {
|
||||
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20})).unwrap();
|
||||
}
|
||||
|
||||
fn spec() -> &'static crate::cmd::CommandSpec {
|
||||
crate::find_command("solid.move").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_is_disabled_while_a_sketch_is_being_edited_and_says_why() {
|
||||
let mut s = Session::default();
|
||||
cube(&mut s);
|
||||
assert!(spec().info(&s).enabled, "Move is enabled with no sketch open");
|
||||
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
let info = spec().info(&s);
|
||||
assert!(!info.enabled, "Move must be disabled while a sketch is being edited");
|
||||
let why = info.disabled_reason.unwrap_or_default();
|
||||
assert!(why.contains("sketch is being edited"), "the reason must name the sketch: {why}");
|
||||
assert!(why.contains("bodies"), "the reason must say what Move acts on: {why}");
|
||||
assert!(why.contains("Finish Sketch"), "the reason must say the way out: {why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_move_in_a_sketch_is_refused_and_changes_nothing() {
|
||||
let mut s = Session::default();
|
||||
cube(&mut s);
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
let features = s.doc.features.len();
|
||||
|
||||
let e = s.execute("solid.move", &json!({"bodies": ["Body1"], "translate": [30, 0, 0]})).unwrap_err();
|
||||
assert!(matches!(e, EngineError::Disabled(ref id, _) if id == "solid.move"), "{e:?}");
|
||||
|
||||
assert_eq!(s.doc.features.len(), features, "a refused command adds no feature");
|
||||
assert!(s.active_sketch.is_some(), "the sketch is still being edited");
|
||||
let bounds = s.model.state().body("Body1").unwrap().mesh().bounds();
|
||||
assert!(bounds.min.dist(Vec3::ZERO) < 1e-6, "the body did not move: {bounds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_works_again_once_the_sketch_is_finished() {
|
||||
let mut s = Session::default();
|
||||
cube(&mut s);
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
assert!(spec().info(&s).enabled, "Move comes back once the sketch is finished");
|
||||
|
||||
s.execute("solid.move", &json!({"bodies": ["Body1"], "translate": [30, 0, 0]})).unwrap();
|
||||
let bounds = s.model.state().body("Body1").unwrap().mesh().bounds();
|
||||
assert!(bounds.min.dist(Vec3::new(30.0, 0.0, 0.0)) < 1e-6, "{bounds:?}");
|
||||
}
|
||||
@@ -260,16 +260,53 @@ fn hole_pattern(count1: usize, count2: usize) -> (f64, std::time::Duration) {
|
||||
(v, took)
|
||||
}
|
||||
|
||||
/// Whether a job four times the size took at most about twice linear: `pair` times the small job,
|
||||
/// then the big one. Linear is 4×, the old quadratic costs 16× or more. The load of the other test
|
||||
/// binaries can rise between the two runs (on a 16-core Mac under `cargo xtask test`, 32 circles
|
||||
/// took 4.7 s and 128 took 42 s, though both together take 18 s alone), so a pair that misses is
|
||||
/// measured again, up to three times. Load only ever slows a run, so each size keeps its fastest
|
||||
/// time and the ratio is taken of those: a retry cannot pass a regression on a small run that a
|
||||
/// spike happened to slow, once an earlier small run was fast. (A spike on the very first small
|
||||
/// run can still hide one, as it could before the retries.)
|
||||
fn scales_linearly(mut pair: impl FnMut() -> (std::time::Duration, std::time::Duration)) -> Result<(), String> {
|
||||
let (mut small, mut big) = (f64::INFINITY, f64::INFINITY);
|
||||
let mut runs = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let (s, b) = pair();
|
||||
small = small.min(s.as_secs_f64());
|
||||
big = big.min(b.as_secs_f64());
|
||||
runs.push(format!("{s:?} then {b:?}"));
|
||||
if big < 8.0 * small + 2.0 {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(format!("{}; fastest {small:.2} s and {big:.2} s, limit {:.2} s", runs.join(", "), 8.0 * small + 2.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scales_linearly_keeps_each_sizes_fastest_run() {
|
||||
use std::time::Duration;
|
||||
let pairs = |p: Vec<(u64, u64)>| {
|
||||
let mut p = p.into_iter().map(|(s, b)| (Duration::from_secs(s), Duration::from_secs(b)));
|
||||
move || p.next().unwrap_or((Duration::from_secs(1000), Duration::from_secs(1000)))
|
||||
};
|
||||
assert!(scales_linearly(pairs(vec![(1, 4)])).is_ok());
|
||||
// A big run slowed by load, then a quiet pair.
|
||||
assert!(scales_linearly(pairs(vec![(1, 30), (1, 5)])).is_ok());
|
||||
// A quadratic cost (16×) whose later small runs a spike slowed: the fast first one still counts.
|
||||
let r = scales_linearly(pairs(vec![(1, 16), (3, 16), (3, 16)]));
|
||||
assert!(r.as_ref().is_err_and(|e| e.contains("fastest 1.00 s and 16.00 s, limit 10.00 s")), "{r:?}");
|
||||
}
|
||||
|
||||
/// 512 identical through-holes from a feature pattern of one cut (#261). 128 took 30 s and 512 did
|
||||
/// not finish in 150 s: each copy was cut into an ever more complex plate. The time must grow about
|
||||
/// linearly with the count. A ratio, not a time: CI runs every test binary at once and is many
|
||||
/// times slower than a machine running this alone (about 6 s for 512 holes there).
|
||||
#[test]
|
||||
fn pattern_of_512_hole_cuts_finishes_in_reasonable_time() {
|
||||
let (_, t128) = hole_pattern(16, 8);
|
||||
let (_, t512) = hole_pattern(32, 16);
|
||||
// Linear is 4×, the old quadratic cost 16× or more.
|
||||
assert!(t512.as_secs_f64() < 8.0 * t128.as_secs_f64() + 2.0, "128 holes took {t128:?}, 512 took {t512:?}");
|
||||
if let Err(missed) = scales_linearly(|| (hole_pattern(16, 8).1, hole_pattern(32, 16).1)) {
|
||||
panic!("128 holes, then 512, took {missed}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A plate with `n` Ø2 circles drawn in one sketch (16 a row, 6 mm apart) and cut by one extrude:
|
||||
@@ -305,7 +342,7 @@ fn sketch_hole_cut(n: usize) -> std::time::Duration {
|
||||
/// linear since #261. Linear is 4×. A ratio, not a time (see the 512-hole pattern test).
|
||||
#[test]
|
||||
fn one_extrude_of_many_sketch_circles_scales_linearly() {
|
||||
let t32 = sketch_hole_cut(32);
|
||||
let t128 = sketch_hole_cut(128);
|
||||
assert!(t128.as_secs_f64() < 8.0 * t32.as_secs_f64() + 2.0, "32 circles took {t32:?}, 128 took {t128:?}");
|
||||
if let Err(missed) = scales_linearly(|| (sketch_hole_cut(32), sketch_hole_cut(128))) {
|
||||
panic!("32 circles, then 128, took {missed}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Render capability discovery. No rendering, host probing or document mutation occurs here.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("render.backends", "Render Backends", backends)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("No parameters. Returns {schema_version: 1, default_backend: builtin, backends: [{id, label, availability, executors: [{id, availability, profiles, limits, scratch}]}]}. Metadata only; does not start rendering or probe a GPU. See docs/render-backends.md")];
|
||||
|
||||
fn backends(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
if !params.as_object().is_some_and(|p| p.is_empty()) {
|
||||
return Err(bad("render.backends", "expected an empty parameter object"));
|
||||
}
|
||||
Ok(json!({"schema_version": 1, "default_backend": "builtin", "backends": [crate::render::builtin_backend()]}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn assert_unchanged(s: &mut Session, params: Value) -> Result<Value> {
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.is_dirty(), s.revision, s.evaluations, s.model.is_stale());
|
||||
let camera = s.camera;
|
||||
let undo = s.undo.clone();
|
||||
let redo = s.redo.clone();
|
||||
let result = s.execute("render.backends", ¶ms);
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(before, (s.is_dirty(), s.revision, s.evaluations, s.model.is_stale()));
|
||||
assert_eq!(camera, s.camera);
|
||||
for (before, after) in [(&undo, &s.undo), (&redo, &s.redo)] {
|
||||
assert_eq!(before.len(), after.len());
|
||||
for (a, b) in before.iter().zip(after.iter()) {
|
||||
assert!(Arc::ptr_eq(&a.doc, &b.doc));
|
||||
assert_eq!((&a.label, a.active_sketch, &a.pending_moves), (&b.label, b.active_sketch, &b.pending_moves));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_preserves_clean_dirty_and_undo_redo_state() {
|
||||
let mut s = Session::default();
|
||||
let r = assert_unchanged(&mut s, Value::Null).unwrap();
|
||||
assert_eq!(r["schema_version"], 1);
|
||||
assert_eq!(r["default_backend"], "builtin");
|
||||
assert_eq!(r["backends"].as_array().unwrap().len(), 1);
|
||||
let descriptor: crate::render::BackendDescriptor = serde_json::from_value(r["backends"][0].clone()).unwrap();
|
||||
assert_eq!(descriptor, crate::render::builtin_backend());
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 20, "height": 5})).unwrap();
|
||||
s.execute("view.apply", &json!({"view": "top"})).unwrap();
|
||||
assert!(s.is_dirty());
|
||||
assert_unchanged(&mut s, json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.redo.is_empty());
|
||||
assert_unchanged(&mut s, json!({})).unwrap();
|
||||
for p in [json!([]), json!(3), json!({"backend": "missing"}), json!({"width": "bad"})] {
|
||||
assert!(assert_unchanged(&mut s, p).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_does_not_settle_a_live_drag_or_rebuild_geometry() {
|
||||
let mut s = Session::default();
|
||||
for (id, p) in [
|
||||
("sketch.create", json!({"plane": "XY"})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]})),
|
||||
("sketch.dimension", json!({"entities": ["l1"], "value": 20})),
|
||||
("sketch.dimension", json!({"entities": ["l2"], "value": 15})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.extrude", json!({"distance": 5})),
|
||||
("sketch.edit", json!({"sketch": "Sketch1"})),
|
||||
("sketch.drag", json!({"entity": "l1.start", "from": [5, 5], "to": [4, 5], "live": true})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap();
|
||||
}
|
||||
assert!(s.model.is_stale());
|
||||
s.evaluations = 0;
|
||||
let model = s.model.state();
|
||||
assert_unchanged(&mut s, json!({})).unwrap();
|
||||
assert_unchanged(&mut s, json!({"unexpected": true})).unwrap_err();
|
||||
assert!(Arc::ptr_eq(&model, &s.model.state()));
|
||||
assert_eq!(s.evaluations, 0);
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,17 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sheet.convert", "Convert to Sheet Metal", convert)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("convert_sheet")
|
||||
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
|
||||
.params(
|
||||
"body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?; \
|
||||
bends?: [[x,y,z] on sharp edges] (a solid or a shelled body: `face` is the fixed face and the flat faces reached from it \
|
||||
across these edges become the sheet; a shell keeps its thickness, a solid takes the rule's inward; \
|
||||
edges between two of those faces that are no bends are cut open with the rule's gap)",
|
||||
),
|
||||
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
|
||||
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's; \
|
||||
gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); \
|
||||
corner_relief_shape?: notch|round|rectangular (what is cut where two bends meet at a corner of the base, `corner_relief` across; default notch); \
|
||||
corner_relief_shape?: notch|round|rectangular|tear (what is cut where two bends meet at a corner of the base: round and rectangular a relief \
|
||||
`corner_relief` across; tear none, each bend's end is cut straight from the corner to its flange's end; default notch); \
|
||||
active?: bool; delete?: bool",
|
||||
),
|
||||
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
|
||||
@@ -272,8 +278,17 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("no body `{body}`")));
|
||||
}
|
||||
let face = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on the plate's face"))?;
|
||||
// With bend edges, a solid or shelled body; without, a plate.
|
||||
let bends = match p.get("bends") {
|
||||
None | Some(Value::Null) => Vec::new(),
|
||||
Some(_) => points(p, "bends", cmd)?,
|
||||
};
|
||||
// Such a sheet's values are its rule's (a plate's thickness is the plate's own).
|
||||
if let Some(key) = RULE_ONLY.iter().find(|k| !bends.is_empty() && p.get(**k).is_some()) {
|
||||
return Err(bad(cmd, format!("`{key}` is not a sheet.convert parameter: the sheet metal rule sets it. Pass a rule's name as `rule`")));
|
||||
}
|
||||
let rule = rule_param(s, p, cmd)?;
|
||||
add_feature(s, p, FeatureKind::SheetConvert { body, face, rule })
|
||||
add_feature(s, p, FeatureKind::SheetConvert { body, face, rule, bends })
|
||||
}
|
||||
|
||||
fn rules(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -309,7 +324,7 @@ fn rules(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if let Some(shape) = p.get("corner_relief_shape").filter(|v| !v.is_null()) {
|
||||
let shape = shape.as_str().and_then(solidcraft_doc::sheet::ReliefShape::parse);
|
||||
rule.corner_relief_shape = shape.ok_or_else(|| bad(cmd, "`corner_relief_shape` must be notch, round or rectangular"))?.name().into();
|
||||
rule.corner_relief_shape = shape.ok_or_else(|| bad(cmd, "`corner_relief_shape` must be notch, round, rectangular or tear"))?.name().into();
|
||||
}
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let values = s.doc.rule_values(&vals, &rule).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
@@ -599,3 +614,7 @@ fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
#[path = "sheet_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sheet_convert_tests.rs"]
|
||||
mod convert_tests;
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Convert to Sheet Metal with bend edges: solid and shelled bodies.
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, PI, SQRT_2};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn bbox(s: &mut Session) -> ([f64; 3], [f64; 3]) {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
let b = &m["bodies"][0]["bbox"];
|
||||
let v = |k: &str| [0, 1, 2].map(|i| b[k][i].as_f64().unwrap_or(f64::NAN));
|
||||
(v("min"), v("max"))
|
||||
}
|
||||
|
||||
fn flat(s: &mut Session) -> Value {
|
||||
run(s, "sheet.flat_pattern", json!({}))
|
||||
}
|
||||
|
||||
fn flat_size(s: &mut Session) -> (f64, f64) {
|
||||
let f = flat(s);
|
||||
let v = |i: usize| f["flat_size_mm"][i].as_f64().unwrap_or(f64::NAN);
|
||||
(v(0), v(1))
|
||||
}
|
||||
|
||||
/// Either way round (the flat frame's x runs along the fixed face's longest edge).
|
||||
fn same_size(got: (f64, f64), want: (f64, f64), tol: f64) -> bool {
|
||||
((got.0 - want.0).abs() < tol && (got.1 - want.1).abs() < tol) || ((got.0 - want.1).abs() < tol && (got.1 - want.0).abs() < tol)
|
||||
}
|
||||
|
||||
/// The bend table's rows as (angle in whole degrees, direction, length).
|
||||
fn bend_rows(s: &mut Session) -> Vec<(i64, String, f64)> {
|
||||
let rows = flat(s)["bends"].as_array().cloned().unwrap_or_default();
|
||||
let row = |b: &Value| {
|
||||
(
|
||||
b["angle_deg"].as_f64().unwrap_or(0.0).round() as i64,
|
||||
b["direction"].as_str().unwrap_or("").to_string(),
|
||||
b["length"].as_f64().unwrap_or(0.0),
|
||||
)
|
||||
};
|
||||
rows.iter().map(row).collect()
|
||||
}
|
||||
|
||||
fn rule(s: &mut Session, name: &str, t: f64, k: f64, r: f64, gap: f64) {
|
||||
let mm = |v: f64| format!("{v} mm");
|
||||
run(
|
||||
s,
|
||||
"sheet.manage_rules",
|
||||
json!({"name": name, "thickness": mm(t), "k_factor": format!("{k}"), "bend_radius": mm(r), "gap": mm(gap), "active": true}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Bend deduction: twice the outside setback less the allowance.
|
||||
fn deduction(theta: f64, r: f64, t: f64, k: f64) -> f64 {
|
||||
2.0 * (r + t) * (theta / 2.0).tan() - theta * (r + k * t)
|
||||
}
|
||||
|
||||
const BOTTOM_EDGES: [[f64; 3]; 4] = [[50.0, 0.0, 0.0], [100.0, 30.0, 0.0], [50.0, 60.0, 0.0], [0.0, 30.0, 0.0]];
|
||||
|
||||
/// A 100 x 60 x 30 box shelled to `t` with its top open.
|
||||
fn tray(s: &mut Session, t: f64) {
|
||||
run(s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "body_name": "Tray"}));
|
||||
run(s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": t}));
|
||||
}
|
||||
|
||||
/// The folded tray's volume: base, four 90° bends and four walls cut back at the corners.
|
||||
fn tray_volume(l: f64, w: f64, h: f64, t: f64, r: f64, gap: f64) -> f64 {
|
||||
let s = r + t;
|
||||
let cut = t + gap / SQRT_2;
|
||||
let base = (l - 2.0 * s) * (w - 2.0 * s);
|
||||
let bends = FRAC_PI_2 * (r + t / 2.0) * 2.0 * ((l - 2.0 * s) + (w - 2.0 * s));
|
||||
let walls = (h - s) * 2.0 * ((l - 2.0 * cut) + (w - 2.0 * cut));
|
||||
t * (base + bends + walls)
|
||||
}
|
||||
|
||||
/// A strip `t` thick along the polyline `outer` (its material to the left of the way the line
|
||||
/// runs, its ends cut square), extruded `len` along z as the body `Section`. Returns a point on
|
||||
/// the edge at each corner of the polyline.
|
||||
fn section(s: &mut Session, outer: &[(f64, f64)], t: f64, len: f64) -> Vec<[f64; 3]> {
|
||||
let n = outer.len();
|
||||
let dir = |i: usize| {
|
||||
let (a, b) = (outer[i], outer[i + 1]);
|
||||
let l = (b.0 - a.0).hypot(b.1 - a.1);
|
||||
((b.0 - a.0) / l, (b.1 - a.1) / l)
|
||||
};
|
||||
let shift = |p: (f64, f64), d: (f64, f64)| (p.0 - d.1 * t, p.1 + d.0 * t);
|
||||
let mut inner = vec![shift(outer[0], dir(0))];
|
||||
for (i, &corner) in outer.iter().enumerate().take(n - 1).skip(1) {
|
||||
let (d0, d1) = (dir(i - 1), dir(i));
|
||||
let (p0, p1) = (shift(corner, d0), shift(corner, d1));
|
||||
let along = ((p1.0 - p0.0) * d1.1 - (p1.1 - p0.1) * d1.0) / (d0.0 * d1.1 - d0.1 * d1.0);
|
||||
inner.push((p0.0 + d0.0 * along, p0.1 + d0.1 * along));
|
||||
}
|
||||
inner.push(shift(outer[n - 1], dir(n - 2)));
|
||||
let pts: Vec<[f64; 2]> = outer.iter().chain(inner.iter().rev()).map(|p| [p.0, p.1]).collect();
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Section"}));
|
||||
run(s, "sketch.line", json!({"points": pts, "closed": true}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch": "Section", "distance": len, "body_name": "Section"}));
|
||||
outer[1..n - 1].iter().map(|p| [p.0, p.1, len / 2.0]).collect()
|
||||
}
|
||||
|
||||
/// A base 200 wide with walls 40 long at 72° and returns 15 long at 108° more, 300 long.
|
||||
fn trapezoid_section(s: &mut Session, t: f64) -> Vec<[f64; 3]> {
|
||||
let (c, sn) = (72f64.to_radians().cos(), 72f64.to_radians().sin());
|
||||
let (x, y) = (100.0 + 40.0 * c, 40.0 * sn);
|
||||
section(s, &[(-x + 15.0, y), (-x, y), (-100.0, 0.0), (100.0, 0.0), (x, y), (x - 15.0, y)], t, 300.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shelled_tray_keeps_its_walls_and_unfolds_by_the_k_factor() {
|
||||
let (t, k, r, gap) = (2.0, 0.44, 2.0, 0.5);
|
||||
let mut s = Session::default();
|
||||
// The shell's own thickness counts, not the rule's.
|
||||
rule(&mut s, "Test", 3.0, k, r, gap);
|
||||
tray(&mut s, t);
|
||||
let out = run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
assert!(out["warning"].is_null(), "{out}");
|
||||
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(0.0) - t).abs() < 1e-9);
|
||||
// Four 90° bends up, each as long as the base is between its setbacks (R + T at either end).
|
||||
let mut rows = bend_rows(&mut s);
|
||||
rows.sort_by(|a, b| a.2.total_cmp(&b.2));
|
||||
let want = [52.0, 52.0, 92.0, 92.0];
|
||||
assert!(rows.len() == 4 && rows.iter().zip(want).all(|(row, l)| row.0 == 90 && row.1 == "up" && (row.2 - l).abs() < 1e-6), "{rows:?}");
|
||||
let allowance = flat(&mut s)["bends"][0]["allowance"].as_f64().unwrap_or(0.0);
|
||||
assert!((allowance - FRAC_PI_2 * (r + k * t)).abs() < 1e-9, "{allowance}");
|
||||
// The envelope stays: the sharp edges became bends inside it.
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(
|
||||
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 100.0).abs() < 1e-6 && (hi[1] - 60.0).abs() < 1e-6 && (hi[2] - 30.0).abs() < 1e-6,
|
||||
"{lo:?} {hi:?}"
|
||||
);
|
||||
// Flat: the outside lengths less two bend deductions each way.
|
||||
let bd = deduction(FRAC_PI_2, r, t, k);
|
||||
let want = (100.0 + 60.0 - 2.0 * bd, 60.0 + 60.0 - 2.0 * bd);
|
||||
assert!(same_size(flat_size(&mut s), want, 1e-6), "{:?} vs {want:?}", flat_size(&mut s));
|
||||
let v = volume(&mut s);
|
||||
let expect = tray_volume(100.0, 60.0, 30.0, t, r, gap);
|
||||
assert!((v - expect).abs() < 2e-3 * expect, "{v} vs {expect}");
|
||||
// Unfold: flat, one sheet thick, the same outline; refold gives the tray back.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!((hi[2] - lo[2] - t).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
assert!(same_size((hi[0] - lo[0], hi[1] - lo[1]), want, 1e-6), "{lo:?} {hi:?}");
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
|
||||
// It exports like a sheet made from flanges: an outline and four bend lines up.
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}));
|
||||
assert_eq!(dxf["bends"], 4);
|
||||
let lines: Vec<&str> = dxf["dxf"].as_str().unwrap_or_default().lines().collect();
|
||||
assert_eq!(lines.windows(2).filter(|w| w[0].trim() == "8" && w[1].trim() == "BEND_UP").count(), 4, "four bend lines");
|
||||
assert_eq!(run(&mut s, "sheet.export_bend_table", json!({"format": "json"}))["bends"], 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bends_picked_inside_and_a_fixed_face_inside_give_the_same_sheet() {
|
||||
let (t, k, r, gap) = (2.0, 0.44, 2.0, 0.5);
|
||||
let inner = [[50.0, 2.0, 2.0], [98.0, 30.0, 2.0], [50.0, 58.0, 2.0], [2.0, 30.0, 2.0]];
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, gap);
|
||||
tray(&mut s, t);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
let (v, f) = (volume(&mut s), flat_size(&mut s));
|
||||
// The inner edges (2 mm in and up) are the same bends.
|
||||
let mut a = Session::default();
|
||||
rule(&mut a, "Test", t, k, r, gap);
|
||||
tray(&mut a, t);
|
||||
run(&mut a, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": inner}));
|
||||
assert!((volume(&mut a) - v).abs() < 1e-6 * v && same_size(flat_size(&mut a), f, 1e-9));
|
||||
// From the floor inside, the sheet's other skin is the fixed one: the bends turn down from it
|
||||
// and the flat is the inside lengths less the inside setbacks, L' + 2H' - 4R + 2BA, which is
|
||||
// the same blank.
|
||||
let mut b = Session::default();
|
||||
rule(&mut b, "Test", t, k, r, gap);
|
||||
tray(&mut b, t);
|
||||
run(&mut b, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 2], "bends": BOTTOM_EDGES}));
|
||||
assert!(bend_rows(&mut b).iter().all(|row| row.0 == 90 && row.1 == "down"), "{:?}", bend_rows(&mut b));
|
||||
let ba = FRAC_PI_2 * (r + k * t);
|
||||
let inside = (96.0 + 2.0 * 28.0 - 4.0 * r + 2.0 * ba, 56.0 + 2.0 * 28.0 - 4.0 * r + 2.0 * ba);
|
||||
assert!(same_size(flat_size(&mut b), inside, 1e-6) && same_size(inside, f, 1e-6), "{:?} {inside:?} {f:?}", flat_size(&mut b));
|
||||
assert!((volume(&mut b) - v).abs() < 1e-6 * v, "{} vs {v}", volume(&mut b));
|
||||
let (lo, hi) = bbox(&mut b);
|
||||
assert!(lo.iter().all(|x| x.abs() < 1e-6) && (hi[0] - 100.0).abs() < 1e-6 && (hi[2] - 30.0).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_solid_block_takes_its_rules_thickness_inward() {
|
||||
let (k, r, gap) = (0.44, 2.0, 0.5);
|
||||
for (name, t) in [(None, 2.0), (Some("Thick"), 3.0)] {
|
||||
// The active rule, or the one the conversion names.
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Thick", 3.0, k, r, gap);
|
||||
rule(&mut s, "Test", 2.0, k, r, gap);
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "body_name": "Block"}));
|
||||
let mut p = json!({"body": "Block", "face": [50, 30, 0], "bends": BOTTOM_EDGES});
|
||||
if let Some(n) = name {
|
||||
p["rule"] = json!(n);
|
||||
}
|
||||
run(&mut s, "sheet.convert", p);
|
||||
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(0.0) - t).abs() < 1e-9);
|
||||
// The block's faces are the sheet's outside; its top is left out.
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(
|
||||
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 100.0).abs() < 1e-6 && (hi[1] - 60.0).abs() < 1e-6 && (hi[2] - 30.0).abs() < 1e-6,
|
||||
"{lo:?} {hi:?}"
|
||||
);
|
||||
let bd = deduction(FRAC_PI_2, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (160.0 - 2.0 * bd, 120.0 - 2.0 * bd), 1e-6), "{:?}", flat_size(&mut s));
|
||||
let expect = tray_volume(100.0, 60.0, 30.0, t, r, gap);
|
||||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trapezoid_section_unfolds_through_72_and_108_degree_bends() {
|
||||
let (t, k, r) = (2.0, 0.5, 1.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
let bends = trapezoid_section(&mut s, t);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Section", "face": [0, 0, 150], "bends": bends}));
|
||||
let rows = bend_rows(&mut s);
|
||||
assert!(rows.len() == 4 && rows.iter().all(|row| row.1 == "up" && (row.2 - 300.0).abs() < 1e-9), "{rows:?}");
|
||||
assert_eq!((rows.iter().filter(|row| row.0 == 72).count(), rows.iter().filter(|row| row.0 == 108).count()), (2, 2), "{rows:?}");
|
||||
let (a72, a108) = (72f64.to_radians(), 108f64.to_radians());
|
||||
let across = 200.0 + 2.0 * 40.0 + 2.0 * 15.0 - 2.0 * deduction(a72, r, t, k) - 2.0 * deduction(a108, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (across, 300.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||||
// The section keeps its outside faces: the returns' tops stay 40 sin 72° up and the length
|
||||
// stays; the widest point is on the round of the 108° bend (outer radius R + T), whose
|
||||
// centre lies (R + T) / sin 36° from the sharp corner along the corner's bisector.
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
let (c72, s72) = (a72.cos(), a72.sin());
|
||||
let rho = r + t;
|
||||
let bisector_x = (-c72 - 1.0) / (-c72 - 1.0).hypot(-s72);
|
||||
let wide = 2.0 * (100.0 + 40.0 * c72 + bisector_x * rho / 36f64.to_radians().sin() + rho);
|
||||
// (The box is measured on the tessellation: the round comes out a chord's sag inside.)
|
||||
assert!((hi[0] - lo[0] - wide).abs() < 2e-3 && (hi[1] - 40.0 * s72).abs() < 1e-6 && (hi[2] - lo[2] - 300.0).abs() < 1e-6, "{lo:?} {hi:?} {wide}");
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
let flat_box = [(hi[0] - lo[0], hi[1] - lo[1]), (hi[0] - lo[0], hi[2] - lo[2])];
|
||||
assert!(flat_box.iter().any(|b| same_size(*b, (across, 300.0), 1e-6)), "{lo:?} {hi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_z_section_bends_up_at_one_edge_and_down_at_the_other() {
|
||||
let (t, k, r) = (2.0, 0.5, 1.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
// Flange 60, web 40, flange 30 on one skin: its first corner is the outside of its bend,
|
||||
// its second the inside.
|
||||
let bends = section(&mut s, &[(0.0, 0.0), (60.0, 0.0), (60.0, 40.0), (90.0, 40.0)], t, 100.0);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Section", "face": [30, 0, 50], "bends": bends}));
|
||||
let mut directions: Vec<String> = bend_rows(&mut s).into_iter().map(|row| row.1).collect();
|
||||
directions.sort();
|
||||
assert_eq!(directions, ["down", "up"]);
|
||||
// Outside setbacks R + T at the first bend, inside setbacks R at the second, and a 90°
|
||||
// allowance for each: 60 + 40 + 30 - 2 (R + T) - 2 R + π (R + K T) = 128.2832.
|
||||
let across = 130.0 - 2.0 * (r + t) - 2.0 * r + PI * (r + k * t);
|
||||
assert!((across - 128.283185).abs() < 1e-6);
|
||||
assert!(same_size(flat_size(&mut s), (across, 100.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(
|
||||
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 90.0).abs() < 1e-6 && (hi[1] - 42.0).abs() < 1e-6 && (hi[2] - 100.0).abs() < 1e-6,
|
||||
"{lo:?} {hi:?}"
|
||||
);
|
||||
// Three flat strips and two quarter rounds on the neutral radius R + T/2.
|
||||
let expect = t * 100.0 * ((60.0 - (r + t)) + (40.0 - (r + t) - r) + (30.0 - r) + PI * (r + t / 2.0));
|
||||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tapered_tray_converts_with_72_degree_walls_and_slanted_corners() {
|
||||
let (t, k, r, gap) = (2.0, 0.5, 1.0, 0.5);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, gap);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Floor"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [200, 120]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Walls leaning out 18°: 72° bends from the floor.
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Floor", "distance": 40, "taper": "18 deg", "body_name": "Tray"}));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(hi[0] - lo[0] > 200.0 + 1.0, "the taper widens the top: {lo:?} {hi:?}");
|
||||
run(&mut s, "solid.shell", json!({"faces": [[100, 60, 40]], "thickness": t}));
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [100, 60, 0], "bends": [[100, 0, 0], [200, 60, 0], [100, 120, 0], [0, 60, 0]]}));
|
||||
let rows = bend_rows(&mut s);
|
||||
assert!(rows.len() == 4 && rows.iter().all(|row| row.0 == 72 && row.1 == "up"), "{rows:?}");
|
||||
let slant = 40.0 / 72f64.to_radians().sin();
|
||||
let bd = deduction(72f64.to_radians(), r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (200.0 + 2.0 * slant - 2.0 * bd, 120.0 + 2.0 * slant - 2.0 * bd), 1e-6), "{:?}", flat_size(&mut s));
|
||||
// The outside stays; the rim is cut square to the sheet, as a blank is, so the inside edge
|
||||
// of a wall leaning out 18° stands T sin 18° above the solid's level rim.
|
||||
let (lo2, hi2) = bbox(&mut s);
|
||||
assert!((0..2).all(|i| (lo2[i] - lo[i]).abs() < 1e-6 && (hi2[i] - hi[i]).abs() < 1e-6) && lo2[2].abs() < 1e-6, "{lo2:?} {hi2:?}");
|
||||
assert!((hi2[2] - 40.0 - t * 18f64.to_radians().sin()).abs() < 1e-6, "{hi2:?}");
|
||||
let v = volume(&mut s);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sheet_follows_the_solid_through_parameter_changes_undo_and_reopening() {
|
||||
let (t, k, r) = (2.0, 0.44, 2.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
run(&mut s, "parameters.add", json!({"name": "L", "expression": "100 mm"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "T", "expression": "2 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": "L", "width": 60, "height": 30, "body_name": "Tray"}));
|
||||
run(&mut s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": "T"}));
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
let bd = |t: f64| deduction(FRAC_PI_2, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (160.0 - 2.0 * bd(t), 120.0 - 2.0 * bd(t)), 1e-6));
|
||||
// Longer: the right-hand bend edge moved from x = 100 to x = 140 and is found by its name.
|
||||
run(&mut s, "parameters.change", json!({"name": "L", "expression": "140 mm"}));
|
||||
assert!(s.model.results.iter().all(|x| x.error.is_none()), "{:?}", s.model.results.iter().map(|x| (&x.name, &x.error)).collect::<Vec<_>>());
|
||||
assert!(same_size(flat_size(&mut s), (200.0 - 2.0 * bd(t), 120.0 - 2.0 * bd(t)), 1e-6), "{:?}", flat_size(&mut s));
|
||||
let (_, hi) = bbox(&mut s);
|
||||
assert!((hi[0] - 140.0).abs() < 1e-6, "{hi:?}");
|
||||
// Thicker walls: the sheet takes the shell's new thickness.
|
||||
run(&mut s, "parameters.change", json!({"name": "T", "expression": "3 mm"}));
|
||||
assert!(s.model.results.iter().all(|x| x.error.is_none()));
|
||||
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(0.0) - 3.0).abs() < 1e-9);
|
||||
let thick = (200.0 - 2.0 * bd(3.0), 120.0 - 2.0 * bd(3.0));
|
||||
assert!(same_size(flat_size(&mut s), thick, 1e-6), "{:?}", flat_size(&mut s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(same_size(flat_size(&mut s), (200.0 - 2.0 * bd(t), 120.0 - 2.0 * bd(t)), 1e-6));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(same_size(flat_size(&mut s), thick, 1e-6));
|
||||
// The design file keeps the bend edges, and reopens to the same sheet.
|
||||
let saved = s.doc.to_json();
|
||||
assert!(saved.contains("\"bends\""), "{saved}");
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
|
||||
assert!(same_size(flat_size(&mut reopened), thick, 1e-6));
|
||||
// Bend edges came with format 17: a build from before them asks for an update.
|
||||
let version = serde_json::from_str::<Value>(&saved).unwrap()["format"].as_str().and_then(solidcraft_doc::format::version_of);
|
||||
assert!(version >= Some(17), "{version:?}");
|
||||
}
|
||||
|
||||
/// Designs from before bend edges open unchanged: a plate conversion saves without `bends`, as
|
||||
/// it always did, and a format 16 file with one is the same document and the same sheet.
|
||||
#[test]
|
||||
fn designs_from_before_bend_edges_open_unchanged() {
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", 2.0, 0.44, 2.0, 0.5);
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2, "body_name": "Plate"}));
|
||||
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [50, 30, 2]}));
|
||||
let mut older: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
assert!(!older.to_string().contains("\"bends\""), "{older}");
|
||||
older["format"] = json!("solidcraft/16");
|
||||
let doc = solidcraft_doc::Document::from_json(&older.to_string()).unwrap();
|
||||
assert_eq!(doc, *s.doc);
|
||||
let mut reopened = Session::new(doc);
|
||||
assert!(reopened.model.results.iter().all(|x| x.error.is_none()));
|
||||
assert!(same_size(flat_size(&mut reopened), (100.0, 60.0), 1e-9), "{:?}", flat_size(&mut reopened));
|
||||
assert!((volume(&mut reopened) - 100.0 * 60.0 * 2.0).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut reopened), flat(&mut s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bend_edges_changed_in_an_edit_are_the_ones_bent() {
|
||||
let (t, k, r) = (2.0, 0.44, 2.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
tray(&mut s, t);
|
||||
let made = run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
// Only the two long walls: a channel as long as the tray, 60 + 2 x 30 less two deductions
|
||||
// across. (By the names of the first two edges it would be the front and the right wall.)
|
||||
run(&mut s, "timeline.edit", json!({"feature": made["feature"], "set": {"bends": [BOTTOM_EDGES[0], BOTTOM_EDGES[2]]}}));
|
||||
let rows = bend_rows(&mut s);
|
||||
assert!(rows.len() == 2 && rows.iter().all(|row| row.0 == 90 && row.1 == "up" && (row.2 - 100.0).abs() < 1e-6), "{rows:?}");
|
||||
let across = 120.0 - 2.0 * deduction(FRAC_PI_2, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (100.0, across), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||||
// Its end walls are no faces of the sheet any more: the channel is open at both ends.
|
||||
let expect = t * 100.0 * ((60.0 - 2.0 * (r + t)) + 2.0 * (30.0 - (r + t)) + PI * (r + t / 2.0));
|
||||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_flanges_and_cuts_work_on_a_converted_sheet() {
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", 2.0, 0.44, 2.0, 0.5);
|
||||
tray(&mut s, 2.0);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
let v0 = volume(&mut s);
|
||||
// A return flange on the front wall's top edge.
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 30]], "height": 10, "angle": 90}));
|
||||
let v1 = volume(&mut s);
|
||||
assert!(v1 > v0 + 500.0, "{v0} {v1}");
|
||||
assert_eq!(bend_rows(&mut s).len(), 5);
|
||||
// A hole cut in the flat stays in the base when the tray is folded again.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50, 30], "radius": 5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 10, "operation": "cut", "direction": "symmetric"}));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1));
|
||||
let hole = PI * 25.0 * 2.0;
|
||||
assert!((volume(&mut s) - (v1 - hole)).abs() < 2e-3 * v1, "{} vs {}", volume(&mut s), v1 - hole);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversions_that_cannot_work_say_why() {
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Wide", 2.0, 0.44, 40.0, 0.5);
|
||||
rule(&mut s, "Test", 2.0, 0.44, 2.0, 0.5);
|
||||
tray(&mut s, 2.0);
|
||||
let err = |s: &mut Session, p: Value| s.execute("sheet.convert", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
// A bend that doesn't reach the fixed face through other bends.
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": [[0, 0, 15]]}));
|
||||
assert!(e.contains("does not connect"), "{e}");
|
||||
// No edge there, and no face there.
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": [[50, 30, 0]]}));
|
||||
assert!(e.contains("no edge"), "{e}");
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, -20], "bends": BOTTOM_EDGES}));
|
||||
assert!(e.contains("no face"), "{e}");
|
||||
// A loop of bends.
|
||||
let mut bends: Vec<[f64; 3]> = BOTTOM_EDGES.to_vec();
|
||||
bends.push([0.0, 0.0, 15.0]);
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": bends}));
|
||||
assert!(e.contains("loop"), "{e}");
|
||||
// Walls shorter than their bends' setbacks (a 40 mm bend radius on 30 mm walls).
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES, "rule": "Wide"}));
|
||||
assert!(e.contains("setback"), "{e}");
|
||||
// The sheet's values are the rule's; an empty list of bends is not a plate.
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES, "thickness": 3}));
|
||||
assert!(e.contains("the sheet metal rule sets it"), "{e}");
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": []}));
|
||||
assert!(e.contains("1…1000"), "{e}");
|
||||
// Curved faces.
|
||||
let mut c = Session::default();
|
||||
run(&mut c, "solid.cylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
|
||||
let e = err(&mut c, json!({"body": "Rod", "face": [0, 0, 30], "bends": [[10, 0, 30]]}));
|
||||
assert!(e.contains("curved"), "{e}");
|
||||
// Nothing changed.
|
||||
assert!(s.model.state().sheets.is_empty() && c.model.state().sheets.is_empty());
|
||||
}
|
||||
@@ -1029,6 +1029,152 @@ fn the_bend_table_and_the_dxf_follow_what_flat_cuts_leave_of_a_bend() {
|
||||
assert!(close(&rows(&mut s), &[(1, 40.0, 100.0, 140.0), (1, 40.0, 160.0, 200.0)]), "{:?}", rows(&mut s));
|
||||
}
|
||||
|
||||
/// The flat pattern's area: its outline loops less its cut-outs.
|
||||
fn flat_area(f: &Value) -> f64 {
|
||||
let loops = |k: &str| -> f64 {
|
||||
let all = f[k].as_array().cloned().unwrap_or_default();
|
||||
all.iter()
|
||||
.map(|l| {
|
||||
let pts: Vec<(f64, f64)> = l
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|q| (q[0].as_f64().unwrap_or(f64::NAN), q[1].as_f64().unwrap_or(f64::NAN)))
|
||||
.collect();
|
||||
polygon_area(&pts)
|
||||
})
|
||||
.sum()
|
||||
};
|
||||
loops("outline") - loops("cutouts")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edge_flange_made_after_a_cut_runs_along_the_edge_the_cut_left() {
|
||||
// A 200 x 100 plate, its bottom left 100 x 60 cut away, then a flange picked on what is left of
|
||||
// the bottom edge (x 100 … 200): it used to span the whole original edge, x 0 … 200, its left
|
||||
// half standing beside nothing.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "2 mm", "active": true}));
|
||||
base(&mut s, 200.0, 100.0);
|
||||
rectangle_tool(&mut s, "Corner", [-10.0, -10.0], [100.0, 60.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Corner", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
assert!((volume(&mut s) - 14_000.0 * 2.0).abs() < 1e-6);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[150, 0, 2]], "height": 40, "angle": 90, "radius": 1, "position": "outside"}));
|
||||
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
|
||||
let f = flat(&mut s);
|
||||
let bends = f["bends"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(bends.len(), 1, "{f}");
|
||||
let x = |k: &str| bends[0][k][0].as_f64().unwrap_or(f64::NAN);
|
||||
assert!(
|
||||
(x("start") - 100.0).abs() < 1e-9 && (x("end") - 200.0).abs() < 1e-9 && (bends[0]["length"].as_f64().unwrap_or(0.0) - 100.0).abs() < 1e-9,
|
||||
"{f}"
|
||||
);
|
||||
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}");
|
||||
// The flat pattern: the plate less the cut, and the flange (π of bend, 37 of panel) on 100 mm.
|
||||
assert!((flat_area(&f) - (14_000.0 + 100.0 * (PI + 37.0))).abs() < 1e-6, "{}", flat_area(&f));
|
||||
// Folded: the bend keeps its volume (K 0.5); the flange stands on x 100 … 200 only.
|
||||
let want = (14_000.0 + 100.0 * (PI + 37.0)) * 2.0;
|
||||
assert!((volume(&mut s) - want).abs() < 1e-3 * want, "{} vs {want}", volume(&mut s));
|
||||
assert!(contains(&s, [150.0, -2.0, 20.0]) && !contains(&s, [50.0, -2.0, 20.0]));
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(body.validity().is_empty() && body.shell_count() == 1);
|
||||
for command in ["sheet.unfold", "sheet.refold"] {
|
||||
run(&mut s, command, json!({}));
|
||||
assert!((volume(&mut s) - want).abs() < 1e-3 * want, "{command}: {}", volume(&mut s));
|
||||
}
|
||||
}
|
||||
|
||||
/// A 200 x 100 plate (2 mm, K 0.5) with `cut`, a rectangle between two corners, cut through it.
|
||||
fn plate_with_a_cut(s: &mut Session, cut: [[f64; 2]; 2]) {
|
||||
run(s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
|
||||
base(s, 200.0, 100.0);
|
||||
rectangle_tool(s, "Cut", cut[0], cut[1]);
|
||||
run(s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_flanges_on_either_side_of_a_notch_span_the_side_they_were_picked_on() {
|
||||
// A notch 40 wide and 30 deep in the middle of the bottom edge leaves x 0 … 80 and 120 … 200 of
|
||||
// it. A 90° flange 40 high set inside (setback 3, BA π, panel 37) spans the side it was picked
|
||||
// on and takes its setback off that side alone; picked on both sides, each has its own.
|
||||
for (edges, sides) in
|
||||
[(json!([[160, 0, 2]]), [false, true]), (json!([[40, 0, 2]]), [true, false]), (json!([[40, 0, 2], [160, 0, 2]]), [true, true])]
|
||||
{
|
||||
let mut s = Session::default();
|
||||
plate_with_a_cut(&mut s, [[80.0, -10.0], [120.0, 30.0]]);
|
||||
run(&mut s, "sheet.flange", json!({"edges": edges, "height": 40, "angle": 90}));
|
||||
assert!(no_errors_or_warnings(&s), "{edges}: {:?}", s.model.results);
|
||||
let f = flat(&mut s);
|
||||
let want: Vec<(f64, f64)> = [(0.0, 80.0), (120.0, 200.0)].into_iter().zip(sides).filter_map(|(span, on)| on.then_some(span)).collect();
|
||||
let bends: Vec<(f64, f64)> = f["bends"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|b| (b["start"][0].as_f64().unwrap_or(f64::NAN), b["end"][0].as_f64().unwrap_or(f64::NAN)))
|
||||
.collect();
|
||||
assert!(
|
||||
bends.len() == want.len() && bends.iter().zip(&want).all(|(b, w)| (b.0 - w.0).abs() < 1e-9 && (b.1 - w.1).abs() < 1e-9),
|
||||
"{edges}: {f}"
|
||||
);
|
||||
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{edges}: {f}");
|
||||
// The plate less the notch, and for each flange 80 × (π + 37) less the 80 × 3 it set back.
|
||||
let area = 20_000.0 - 40.0 * 30.0 + want.len() as f64 * 80.0 * (PI + 37.0 - 3.0);
|
||||
assert!((flat_area(&f) - area).abs() < 1e-6, "{edges}: {}", flat_area(&f));
|
||||
assert!(rel(volume(&mut s), area * 2.0) < 1e-4, "{edges}: {} vs {}", volume(&mut s), area * 2.0);
|
||||
// A flange stands with its outer face on the edge (y 0 … 2); a side without one keeps the
|
||||
// base up to the edge, which the bend's round outer corner leaves free.
|
||||
for (x, on) in [40.0, 160.0].into_iter().zip(sides) {
|
||||
assert_eq!(contains(&s, [x, 1.0, 20.0]), on, "{edges}: a flange at x {x}");
|
||||
assert_eq!(contains(&s, [x, 0.2, 0.2]), !on, "{edges}: the base's own edge at x {x}");
|
||||
}
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(body.validity().is_empty() && body.shell_count() == 1, "{edges}");
|
||||
for command in ["sheet.unfold", "sheet.refold"] {
|
||||
run(&mut s, command, json!({}));
|
||||
assert!(rel(volume(&mut s), area * 2.0) < 1e-4, "{edges}, {command}: {}", volume(&mut s));
|
||||
}
|
||||
}
|
||||
// An edge that a cut has taken whole is refused.
|
||||
let mut s = Session::default();
|
||||
plate_with_a_cut(&mut s, [[-10.0, -10.0], [210.0, 30.0]]);
|
||||
let e = s.execute("sheet.flange", &json!({"edges": [[100, 0, 2]], "height": 40})).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(e.contains("a cut-out has taken that edge away"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edge_flange_on_a_flange_runs_along_what_a_flat_cut_left_of_its_far_edge() {
|
||||
// A 40 high wall on the plate's bottom edge (outside: BA π, panel 37), its far edge at
|
||||
// y = -(π + 37) in the flat. Unfolded, a cut takes x 0 … 60 of that edge, up to y = -30. A
|
||||
// 15 high flange picked on the rest of the edge (the wall's inner face is y = -1, its top
|
||||
// z = 40) spans x 60 … 200.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
|
||||
base(&mut s, 200.0, 100.0);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[100, 0, 2]], "height": 40, "angle": 90, "position": "outside"}));
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
rectangle_tool(&mut s, "Corner", [-10.0, -50.0], [60.0, -30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Corner", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[130, -1, 40]], "height": 15, "angle": 90, "position": "outside"}));
|
||||
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
|
||||
let f = flat(&mut s);
|
||||
let bends = f["bends"].as_array().cloned().unwrap_or_default();
|
||||
let spans: Vec<(f64, f64)> =
|
||||
bends.iter().map(|b| (b["start"][0].as_f64().unwrap_or(f64::NAN), b["end"][0].as_f64().unwrap_or(f64::NAN))).collect();
|
||||
let close = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9;
|
||||
assert!(spans.len() == 2 && close(spans[0], (0.0, 200.0)) && close(spans[1], (60.0, 200.0)), "{f}");
|
||||
// The plate, the wall less the cut's 60 × (π + 7), and 140 × (π + 12) of the flange on it.
|
||||
let area = 20_000.0 + 200.0 * (PI + 37.0) - 60.0 * (PI + 7.0) + 140.0 * (PI + 12.0);
|
||||
assert!((flat_area(&f) - area).abs() < 1e-6, "{}", flat_area(&f));
|
||||
assert!(rel(volume(&mut s), area * 2.0) < 1e-4, "{} vs {}", volume(&mut s), area * 2.0);
|
||||
// It lies over the plate from the wall (z 41 … 43, y 0 … 12), on x 60 … 200 only.
|
||||
assert!(contains(&s, [130.0, 6.0, 42.0]) && !contains(&s, [30.0, 6.0, 42.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() {
|
||||
let mut s = Session::default();
|
||||
@@ -1187,6 +1333,75 @@ fn inherited_round_hole_survives_flange_and_unfold_in_flipped_frames() {
|
||||
}
|
||||
}
|
||||
|
||||
fn faces(s: &mut Session) -> (u64, u64) {
|
||||
let body = run(s, "inspect.measure", json!({}))["bodies"][0].clone();
|
||||
(body["faces"].as_u64().unwrap(), body["face_types"]["cylinder"].as_u64().unwrap_or(0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elliptic_cut_out_stays_exact_in_the_flat_pattern_and_follows_arcs_when_folded() {
|
||||
use solidcraft_geom::Seg2;
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
|
||||
let (uncut, blank) = (volume(&mut s), s.model.state().sheets[0].flat_regions().unwrap()[0].area());
|
||||
let (plain_faces, plain_cylinders) = faces(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
|
||||
run(&mut s, "sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
// The ellipse takes π·15·8 of the 2.5 mm sheet (a body's volume is measured on its mesh).
|
||||
let cut = PI * 15.0 * 8.0;
|
||||
let folded = volume(&mut s);
|
||||
assert!((uncut - folded - cut * 2.5).abs() < 0.5, "{}", uncut - folded);
|
||||
// The flat pattern holds the ellipse itself, so its area is the blank less π·15·8.
|
||||
let regions = s.model.state().sheets[0].flat_regions().unwrap();
|
||||
let hole = ®ions[0].holes[0];
|
||||
assert!(hole.segs.len() == 4 && hole.segs.iter().all(|x| matches!(x, Seg2::Conic { .. })), "{} segments", hole.segs.len());
|
||||
assert!((regions[0].area() - (blank - cut)).abs() < 1e-9, "{}", regions[0].area());
|
||||
// The flat pattern JSON lists it as those four curves, one `polyline` each, with every
|
||||
// point on the ellipse ((x − 40)/15)² + ((y − 30)/8)² = 1.
|
||||
let shapes = flat(&mut s)["cutout_shapes"].as_array().cloned().unwrap_or_default();
|
||||
let curves: Vec<Vec<Value>> = shapes[0]["segments"].as_array().into_iter().flatten().filter_map(|x| x["polyline"].as_array().cloned()).collect();
|
||||
assert!(shapes.len() == 1 && curves.len() == 4 && shapes[0]["segments"].as_array().is_some_and(|a| a.len() == 4), "{} curves", curves.len());
|
||||
let off = |p: &Value| ((p[0].as_f64().unwrap_or(0.0) - 40.0) / 15.0).powi(2) + ((p[1].as_f64().unwrap_or(0.0) - 30.0) / 8.0).powi(2) - 1.0;
|
||||
assert!(curves.iter().all(|c| c.len() > 2 && c.iter().all(|p| off(p).abs() < 1e-9)));
|
||||
// Folded, the cut-out's wall is cylinders over tangent arcs: fewer than half the 264
|
||||
// chords of 0.001 mm (four quarters of √(2·17/0.008) = 66 each).
|
||||
let (cut_faces, cut_cylinders) = faces(&mut s);
|
||||
let wall = cut_faces - plain_faces;
|
||||
assert!(wall < 132 && cut_cylinders - plain_cylinders + 4 >= wall, "{wall} wall faces, {} cylinders", cut_cylinders - plain_cylinders);
|
||||
// Unfolded, it is the four conic faces of the ellipse.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - (blank - cut) * 2.5).abs() < 0.5);
|
||||
assert_eq!(faces(&mut s).0, regions[0].outer.segs.len() as u64 + 4 + 2);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - folded).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flipped_base_flange_keeps_its_elliptic_outline() {
|
||||
for flip in [false, true] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Oval"}));
|
||||
run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [25, 0], "minor_radius": 12}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Oval", "flip": flip}));
|
||||
let regions = s.model.state().sheets[0].flat_regions().unwrap();
|
||||
let outline = ®ions[0].outer.segs;
|
||||
assert!(
|
||||
outline.len() == 4 && outline.iter().all(|x| matches!(x, solidcraft_geom::Seg2::Conic { .. })),
|
||||
"flip {flip}: {} segments",
|
||||
outline.len()
|
||||
);
|
||||
// π·25·12 of the 2.5 mm sheet: exactly in the flat pattern; within 0.5 mm³ in the body,
|
||||
// which follows the outline by arcs and is measured on its mesh (a 64-sided outline
|
||||
// would be 3.8 mm³ short).
|
||||
assert!((regions[0].area() - PI * 300.0).abs() < 1e-9, "flip {flip}: {}", regions[0].area());
|
||||
assert!((volume(&mut s) - PI * 300.0 * 2.5).abs() < 0.5, "flip {flip}: {}", volume(&mut s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_only_disconnecting_cut_preserves_all_material_states() {
|
||||
let mut s = Session::default();
|
||||
@@ -2263,6 +2478,76 @@ fn corner_reliefs_of_the_rule_reach_the_part_the_flat_pattern_and_the_dxf() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tear_of_the_rule_reaches_the_part_the_flat_pattern_and_the_dxf() {
|
||||
use std::f64::consts::SQRT_2;
|
||||
// The tray at 90° and 72° (see the doc tests): its bends meet at (s0, s0), s0 = 3·tan(θ/2), and
|
||||
// are BA = 2θ wide in the flat. The flange on y = 0 is closed to x_end(w) = g/√2 + T·sinθ − w·cosθ.
|
||||
// Torn, its bend's end is cut from (s0, s0) straight to (x_end(s0), s0 − BA), where the flange's
|
||||
// end starts: grow = s0 − x_end(s0) = sinθ − 0.5/√2 past the corner, instead of square across the
|
||||
// bend and back along the flange's edge (the notch's step).
|
||||
for deg in [90.0f64, 72.0] {
|
||||
let th = deg.to_radians();
|
||||
let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0);
|
||||
let x_end = |w: f64| 0.5 / SQRT_2 + 2.0 * th.sin() - w * th.cos();
|
||||
let grow = s0 - x_end(s0);
|
||||
assert!((grow - (th.sin() - 0.5 / SQRT_2)).abs() < 1e-12);
|
||||
let points = |f: &Value| -> Vec<(f64, f64)> {
|
||||
let line = f["outline"][0].as_array();
|
||||
line.map(|l| l.iter().map(|p| (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN))).collect()).unwrap_or_default()
|
||||
};
|
||||
let mut notch = Session::default();
|
||||
alu_tray(&mut notch, deg, json!({}));
|
||||
let (v0, stepped) = (volume(&mut notch), points(&flat(&mut notch)));
|
||||
run(&mut notch, "sheet.unfold", json!({}));
|
||||
let flat0 = volume(&mut notch);
|
||||
let mut s = Session::default();
|
||||
alu_tray(&mut s, deg, json!({"corner_relief_shape": "tear"}));
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{deg}: {:?}", s.model.results);
|
||||
let listed = run(&mut s, "sheet.rules", json!({}));
|
||||
assert_eq!(listed["rules"][0]["rule"]["corner_relief_shape"], "tear");
|
||||
assert_eq!(listed["rules"][0]["values"]["corner_relief_shape"], "tear");
|
||||
// The flat pattern, at the corner at the origin: the flange's end, the cut, the corner of the
|
||||
// bends, and the same on the flange on x = 0. The step is gone: a corner less at each of the
|
||||
// eight bend ends.
|
||||
let f = flat(&mut s);
|
||||
let outline = points(&f);
|
||||
let at = |pts: &[(f64, f64)], x: f64, y: f64| pts.iter().any(|p| (p.0 - x).abs() < 1e-6 && (p.1 - y).abs() < 1e-6);
|
||||
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{deg}: {f}");
|
||||
for (x, y) in [(x_end(38.6), s0 - ba - (38.6 - s0)), (x_end(s0), s0 - ba), (s0, s0)] {
|
||||
assert!(at(&outline, x, y) && at(&outline, y, x), "{deg}: ({x}, {y}) not in {outline:?}");
|
||||
}
|
||||
assert!(at(&stepped, s0, s0 - ba) && !at(&outline, s0, s0 - ba) && !at(&outline, s0 - ba, s0), "{deg}");
|
||||
assert_eq!(outline.len() + 8, stepped.len(), "{deg}");
|
||||
// The bend table: every centre line runs between the middles of its bend's two ends.
|
||||
let lengths: Vec<f64> =
|
||||
f["bends"].as_array().map(|b| b.iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect()).unwrap_or_default();
|
||||
for (got, edge) in lengths.iter().zip([120.0, 90.0, 120.0, 90.0]) {
|
||||
assert!(lengths.len() == 4 && (got - (edge - 2.0 * s0 + grow)).abs() < 1e-6, "{deg}: {lengths:?}");
|
||||
}
|
||||
// The folded part gains what the flat pattern gains: the triangle BA·grow/2 at each of the
|
||||
// eight bend ends (K = 0.5 keeps the volume of a bend).
|
||||
let (v, want) = (volume(&mut s), 8.0 * ba * grow / 2.0 * 2.0);
|
||||
assert!((v - v0 - want).abs() < 0.05, "{deg}: {v} - {v0} vs {want}");
|
||||
// The DXF: the outline in straight lines, each cut one of them, and four bend lines.
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
|
||||
assert_eq!(dxf.matches("0\nLINE\n").count(), outline.len() + 4, "{deg}");
|
||||
assert!(dxf.contains(&format!("{:.6}\n20\n{:.6}\n", x_end(s0), s0 - ba)), "{deg}");
|
||||
// Unfold and refold keep it, and a cut made folded still goes through: a Ø20 hole in the base.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - flat0 - want).abs() < 1e-6, "{deg}: unfolded {} - {flat0} vs {want}", volume(&mut s));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{deg}");
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [60, 45], "diameter": 20}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 10, "operation": "cut", "through_all": true}));
|
||||
assert!((v - volume(&mut s) - PI * 100.0 * 2.0).abs() < 0.5, "{deg}: {v} - {}", volume(&mut s));
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((volume(&mut reopened) - volume(&mut s)).abs() < 1e-6, "{deg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_corner_relief_shape_is_a_rule_setting_checked_and_undone() {
|
||||
let mut s = Session::default();
|
||||
@@ -2270,10 +2555,10 @@ fn the_corner_relief_shape_is_a_rule_setting_checked_and_undone() {
|
||||
let shape = |s: &mut Session| run(s, "sheet.rules", json!({}))["rules"][0]["rule"]["corner_relief_shape"].clone();
|
||||
let v0 = volume(&mut s);
|
||||
assert_eq!(shape(&mut s), "notch");
|
||||
// Only the three names are shapes; a refused one changes nothing.
|
||||
// Only the four names are shapes; a refused one changes nothing.
|
||||
for wrong in [json!("star"), json!("square"), json!(""), json!(2)] {
|
||||
let e = s.execute("sheet.manage_rules", &json!({"name": "AL2", "corner_relief_shape": wrong})).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(e.contains("`corner_relief_shape` must be notch, round or rectangular"), "{e}");
|
||||
assert!(e.contains("`corner_relief_shape` must be notch, round, rectangular or tear"), "{e}");
|
||||
}
|
||||
assert!(shape(&mut s) == "notch" && volume(&mut s) == v0);
|
||||
// Changing the rule changes the parts made with it; other settings leave the shape alone.
|
||||
@@ -3023,3 +3308,79 @@ fn fold_fixed_point_must_touch_the_sheet_but_any_face_will_do() {
|
||||
assert!(fold["warning"].as_str().is_some_and(|w| w.contains("fixed point is not on the sheet")), "{fold}");
|
||||
assert!((bbox(&mut f)["max"][0].as_f64().unwrap_or(f64::NAN) - x).abs() < 1e-6, "the right side stays");
|
||||
}
|
||||
|
||||
/// The middle of the largest triangle of each flat face of the first body: well inside it.
|
||||
fn flat_face_points(s: &Session) -> Vec<(usize, solidcraft_geom::Vec3, solidcraft_geom::Vec3)> {
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
let mesh = body.tessellate(0.01).unwrap();
|
||||
(0..body.face_count())
|
||||
.filter_map(|index| {
|
||||
let normal = body.planar_face(index)?.normal();
|
||||
let point = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, f)| **f as usize == index)
|
||||
.filter_map(|(t, _)| mesh.tri(t))
|
||||
.max_by(|[a, b, c], [d, e, f]| (*b - *a).cross(*c - *a).len().total_cmp(&(*e - *d).cross(*f - *d).len()))
|
||||
.map(|[a, b, c]| (a + b + c) * (1.0 / 3.0))?;
|
||||
Some((index, point, normal))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every flat face of a sheet takes an attached sketch (#495): its panels, the rims along the
|
||||
/// panels' straight edges (named by the base sketch line or the flange's far edge and sides) and
|
||||
/// the flat ends of a bend. Each lies on its face, facing out of the sheet, and a rim's sketch
|
||||
/// follows the rim when the base grows. A press-pull on a rim pushes it out, and a plain hole into
|
||||
/// a panel is kept as a cut-out of the flat pattern (#560).
|
||||
#[test]
|
||||
fn every_flat_face_of_a_sheet_takes_an_attached_sketch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "W", "expression": "100 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["p1"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "W"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
|
||||
let faces = flat_face_points(&s);
|
||||
// The base: its two faces and three rims (the fourth edge bends); the flange: its two faces,
|
||||
// its far rim and two sides; the two ends of the bend.
|
||||
assert_eq!(faces.len(), 12);
|
||||
let mut on_side = None;
|
||||
for (index, point, normal) in faces {
|
||||
let made = run(&mut s, "sketch.create", json!({"plane": {"face": point, "body": "Body1", "index": index}}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(made["attachment"], "sheet_face", "face {index} at {point:?}");
|
||||
let id = made["sketch"].as_u64().unwrap();
|
||||
let solidcraft_doc::FaceAttachmentStatus::Sheet { plane: Some(plane) } = s.placed_document().sketch_face_attachment(id, &s.model) else {
|
||||
panic!("face {index} at {point:?}")
|
||||
};
|
||||
assert!(plane.normal().dot(normal) > 1.0 - 1e-9 && (point - plane.origin).dot(normal).abs() < 1e-6, "face {index}: {plane:?}");
|
||||
if (point.x - 100.0).abs() < 1e-6 && point.y > 10.0 {
|
||||
on_side = Some(id);
|
||||
}
|
||||
}
|
||||
// The base's rim along x = W (the sketch's l2) moves with W.
|
||||
let side = on_side.expect("the base rim on x = 100");
|
||||
run(&mut s, "parameters.change", json!({"name": "W", "expression": "120 mm"}));
|
||||
let solidcraft_doc::FaceAttachmentStatus::Sheet { plane: Some(plane) } = s.placed_document().sketch_face_attachment(side, &s.model) else {
|
||||
panic!("the rim sketch after the edit")
|
||||
};
|
||||
assert!((plane.origin.x - 120.0).abs() < 1e-9 && (plane.normal().x - 1.0).abs() < 1e-9, "{plane:?}");
|
||||
// Press-pull the rim 5 mm out: the base grows by 5 x 60 x 2.5 less what the flange covers.
|
||||
let v0 = volume(&mut s);
|
||||
run(&mut s, "solid.press_pull", json!({"face": [120, 30, 1.25], "distance": 5}));
|
||||
assert!(volume(&mut s) > v0 + 5.0 * 50.0 * 2.5, "{} {v0}", volume(&mut s));
|
||||
// A plain hole into the base is a cut-out of the flat pattern.
|
||||
let mut t = Session::default();
|
||||
plate_with_flange(&mut t);
|
||||
let v0 = volume(&mut t);
|
||||
run(&mut t, "solid.hole", json!({"position": [50, 30, 2.5], "direction": [0, 0, -1], "diameter": 6}));
|
||||
assert!((v0 - volume(&mut t) - PI * 9.0 * 2.5).abs() < 0.1, "{}", v0 - volume(&mut t));
|
||||
let f = flat(&mut t);
|
||||
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1), "{f}");
|
||||
}
|
||||
|
||||
+200
-15
@@ -106,7 +106,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
|
||||
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
|
||||
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. Returns measured in unit (mm or deg)",
|
||||
),
|
||||
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
@@ -183,7 +183,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params("dimension: constraint id or parameter name; at: [x,y] text centre (sketch coordinates) | reset: true (default place)"),
|
||||
CommandSpec::new("sketch.options", "Sketch Palette Options", sketch_options).icon("sketch").params(
|
||||
"sketch?: id|name (default active); any of show_profile, show_points, show_dimensions, show_constraints, show_projected, grid, snap, slice, sketch_3d: bool. Returns them all",
|
||||
"sketch?: id|name (default active); any of show_profile, show_points, show_dimensions, show_constraints, show_projected, show_construction, grid, snap, slice, sketch_3d: bool. Returns them all",
|
||||
),
|
||||
CommandSpec::new("sketch.chain", "Chain", chain_cmd)
|
||||
.enabled(in_sketch)
|
||||
@@ -401,6 +401,7 @@ pub(super) fn draw(
|
||||
let first = sk.points.len();
|
||||
let (curves, mut cons) = f(sk, doc)?;
|
||||
cons.extend(connect_points(sk, first, p)?);
|
||||
drop_implied_axes(sk, &mut cons);
|
||||
Ok((curves, cons))
|
||||
})
|
||||
}
|
||||
@@ -821,6 +822,74 @@ fn rect_constraints(sk: &mut Sketch, l: &[usize]) -> Result<Vec<String>> {
|
||||
Ok(cons)
|
||||
}
|
||||
|
||||
/// Whether line `l` being horizontal (`horizontal`) or vertical already follows from another
|
||||
/// constraint than `own`: its ends coincide with the ends of a line constrained the same way (a
|
||||
/// side two rectangles share), or with two points constrained level (or plumb).
|
||||
fn axis_implied(sk: &Sketch, l: usize, horizontal: bool, own: &str) -> bool {
|
||||
let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|c| &c.kind) else { return false };
|
||||
let (a, b) = (*a, *b);
|
||||
// Coincident points as one (union-find over the coincident constraints).
|
||||
let mut parent: Vec<usize> = (0..sk.points.len()).collect();
|
||||
let root = |parent: &[usize], mut i: usize| {
|
||||
for _ in 0..parent.len() {
|
||||
match parent.get(i) {
|
||||
Some(&up) if up != i => i = up,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
i
|
||||
};
|
||||
for c in &sk.constraints {
|
||||
if let ConstraintKind::Coincident { p, q } = c.kind {
|
||||
let (rp, rq) = (root(&parent, p), root(&parent, q));
|
||||
if rp != rq
|
||||
&& let Some(slot) = parent.get_mut(rp)
|
||||
{
|
||||
*slot = rq;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (ra, rb) = (root(&parent, a), root(&parent, b));
|
||||
sk.constraints.iter().filter(|c| c.id != own).any(|c| {
|
||||
let ends = match (&c.kind, horizontal) {
|
||||
(ConstraintKind::Horizontal { l: m }, true) | (ConstraintKind::Vertical { l: m }, false) if *m != l => {
|
||||
match sk.curves.get(*m).map(|c| &c.kind) {
|
||||
Some(CurveKind::Line { a, b }) => Some((*a, *b)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
(ConstraintKind::HorizontalPoints { p, q }, true) | (ConstraintKind::VerticalPoints { p, q }, false) => Some((*p, *q)),
|
||||
_ => None,
|
||||
};
|
||||
ends.is_some_and(|(p, q)| {
|
||||
let (rp, rq) = (root(&parent, p), root(&parent, q));
|
||||
rp != rq && ((rp, rq) == (ra, rb) || (rp, rq) == (rb, ra))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Drawing adds horizontal/vertical only where it is not already implied: a rectangle drawn
|
||||
/// onto another's side shares that side's direction through the two coincident corners, so a
|
||||
/// second "vertical" there would only be redundant. Drops such constraints among `cons` (the
|
||||
/// drawing's own) from the sketch and the list.
|
||||
fn drop_implied_axes(sk: &mut Sketch, cons: &mut Vec<String>) {
|
||||
let mut dropped = Vec::new();
|
||||
for id in cons.iter() {
|
||||
let axis = sk.constraints.iter().find(|c| &c.id == id).and_then(|c| match c.kind {
|
||||
ConstraintKind::Horizontal { l } => Some((l, true)),
|
||||
ConstraintKind::Vertical { l } => Some((l, false)),
|
||||
_ => None,
|
||||
});
|
||||
if let Some((l, horizontal)) = axis
|
||||
&& axis_implied(sk, l, horizontal, id)
|
||||
{
|
||||
sk.constraints.retain(|c| &c.id != id);
|
||||
dropped.push(id.clone());
|
||||
}
|
||||
}
|
||||
cons.retain(|id| !dropped.contains(id));
|
||||
}
|
||||
|
||||
/// Which side and corner of an axis-aligned rectangle is which, from its lines and corner
|
||||
/// points as `polygon_lines` made them (counter-clockwise from the bottom-left corner), so a
|
||||
/// caller can dimension it without inspecting the sketch first.
|
||||
@@ -855,6 +924,7 @@ fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut cons = rect_constraints(sk, &l)?;
|
||||
mark_construction(sk, &l, p);
|
||||
cons.extend(connect_points(sk, first, p)?);
|
||||
drop_implied_axes(sk, &mut cons);
|
||||
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
|
||||
})?;
|
||||
Ok(rect_result(out, names, info))
|
||||
@@ -887,6 +957,7 @@ fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
mark_construction(sk, &l, p);
|
||||
cons.extend(connect_points(sk, first, p)?);
|
||||
drop_implied_axes(sk, &mut cons);
|
||||
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
|
||||
})?;
|
||||
Ok(rect_result(out, names, info))
|
||||
@@ -1174,19 +1245,24 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch) -> Result<ConstraintKind>) -> Result<Value> {
|
||||
let mut before = None;
|
||||
let mut residual = None;
|
||||
let (id, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let k = f(sk)?;
|
||||
let rep = solve(&mut sk.clone());
|
||||
before = Some(rep.dof);
|
||||
let order = entity_order(sk, p);
|
||||
let id = add_c(sk, k)?;
|
||||
if let Some((first, second)) = order {
|
||||
residual = residual_of(sk, &id);
|
||||
// A constraint that already holds needs nothing moved (even when both entities are
|
||||
// fully determined: it is then redundant, see `redundancy`).
|
||||
let holds = residual.is_some_and(|r| r <= solidcraft_sketch::CONSISTENT);
|
||||
if let Some((first, second)) = order.filter(|_| !holds) {
|
||||
apply_in_order(sk, &rep, &[first], second, cmd)?;
|
||||
}
|
||||
Ok(id)
|
||||
})?;
|
||||
reject_redundant(before, &info, cmd)?;
|
||||
Ok(json!({"constraint": id, "sketch": info}))
|
||||
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
|
||||
constraint_result(p, cmd, id, info, by, Value::Null)
|
||||
}
|
||||
|
||||
/// A sketch entity a constraint is applied to.
|
||||
@@ -1330,18 +1406,79 @@ pub(super) fn dof_now(sk: &Sketch) -> usize {
|
||||
solve(&mut sk.clone()).dof
|
||||
}
|
||||
|
||||
/// A constraint that removes no degree of freedom over-constrains the sketch (Fusion refuses
|
||||
/// it); the caller's edit is rolled back by returning the error.
|
||||
pub(super) fn reject_redundant(before: Option<usize>, info: &Value, cmd: &str) -> Result<()> {
|
||||
/// A constraint just added that removes no degree of freedom (`before` → the edit's `info`) is
|
||||
/// redundant or a conflict. Redundant when it held where the sketch was before it was added
|
||||
/// (`residual`, see [`solidcraft_sketch::constraint_residual`]): what it asks for is already
|
||||
/// implied by other constraints, and the solver satisfies it with them. Returns the
|
||||
/// constraints that determine it (as "id (Name)"), or `None` when it removes a degree of
|
||||
/// freedom. A conflict is refused; the caller's edit is rolled back by returning the error.
|
||||
pub(super) fn redundancy(
|
||||
s: &Session,
|
||||
p: &Value,
|
||||
cmd: &str,
|
||||
before: Option<usize>,
|
||||
info: &Value,
|
||||
residual: Option<f64>,
|
||||
cid: &str,
|
||||
) -> Result<Option<Vec<String>>> {
|
||||
let after = info.get("dof").and_then(Value::as_u64).map(|d| d as usize);
|
||||
if let (Some(b), Some(a)) = (before, after)
|
||||
&& a >= b
|
||||
{
|
||||
return Err(bad(cmd, "that would over-constrain the sketch (it is already determined by other constraints)"));
|
||||
let (Some(b), Some(a)) = (before, after) else { return Ok(None) };
|
||||
if a < b {
|
||||
return Ok(None);
|
||||
}
|
||||
if !residual.is_some_and(|r| r <= solidcraft_sketch::CONSISTENT) {
|
||||
return Err(bad(cmd, "that would over-constrain the sketch (it conflicts with what the other constraints already determine)"));
|
||||
}
|
||||
Ok(Some(determined_by(s, p, cmd, cid)))
|
||||
}
|
||||
|
||||
/// The constraints that already determine constraint `cid` of the target sketch, as
|
||||
/// "id (Name)".
|
||||
fn determined_by(s: &Session, p: &Value, cmd: &str, cid: &str) -> Vec<String> {
|
||||
let Some(sk) = target_sketch(s, p, cmd).ok().and_then(|id| s.doc.sketch(id).ok()) else { return Vec::new() };
|
||||
let Some(ci) = sk.constraints.iter().position(|c| c.id == cid) else { return Vec::new() };
|
||||
solidcraft_sketch::determined_by(sk, ci)
|
||||
.into_iter()
|
||||
.filter_map(|k| sk.constraints.get(k))
|
||||
.map(|c| format!("{} ({})", c.id, c.kind.name()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// "k3 (Vertical), k9 (Vertical)", or "other constraints" when they are not known.
|
||||
pub(super) fn listed(by: &[String]) -> String {
|
||||
if by.is_empty() { "other constraints".to_string() } else { by.join(", ") }
|
||||
}
|
||||
|
||||
/// A redundant constraint is kept (and shown as redundant) unless the caller asked for
|
||||
/// `if_redundant: "refuse"` (drawing-time inferences, which only add what is not already there).
|
||||
pub(super) fn keep_redundant(p: &Value, cmd: &str, by: &[String]) -> Result<()> {
|
||||
if str_(p, "if_redundant").is_some_and(|m| m.eq_ignore_ascii_case("refuse")) {
|
||||
return Err(bad(cmd, format!("that would over-constrain the sketch (it is already determined by {})", listed(by))));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The result of a constraint command: the constraint, and whether it is redundant (and what
|
||||
/// determines it).
|
||||
pub(super) fn constraint_result(p: &Value, cmd: &str, id: String, info: Value, by: Option<Vec<String>>, extra: Value) -> Result<Value> {
|
||||
let mut out = json!({"constraint": id, "sketch": info});
|
||||
if let Some(by) = by {
|
||||
keep_redundant(p, cmd, &by)?;
|
||||
out["redundant"] = json!(true);
|
||||
out["determined_by"] = json!(by);
|
||||
}
|
||||
if let (Some(o), Value::Object(e)) = (out.as_object_mut(), extra) {
|
||||
o.extend(e);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The residual of the constraint `id` where the sketch is now (see
|
||||
/// [`solidcraft_sketch::constraint_residual`]).
|
||||
pub(super) fn residual_of(sk: &Sketch, id: &str) -> Option<f64> {
|
||||
sk.constraints.iter().position(|c| c.id == id).and_then(|ci| solidcraft_sketch::constraint_residual(sk, ci))
|
||||
}
|
||||
|
||||
fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.constraint.horizontal_vertical";
|
||||
let mode = str_(p, "mode").map(str::to_ascii_lowercase);
|
||||
@@ -1610,6 +1747,8 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text_at = p.get("text_at").and_then(vec2);
|
||||
let driven = bool_(p, "driven").unwrap_or(false);
|
||||
let mut before = None;
|
||||
let (mut residual, mut new_id) = (None, String::new());
|
||||
let start = s.doc.clone();
|
||||
let ((param, kind_name, current), info) = edit(s, p, cmd, true, |sk, doc| {
|
||||
before = Some(dof_now(sk));
|
||||
let ent = |r: &str| -> Result<(Option<usize>, Option<usize>)> {
|
||||
@@ -1766,8 +1905,13 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
(k, cur) = (ka, ca);
|
||||
}
|
||||
// How far the value is from what the geometry measures now: zero for a dimension that
|
||||
// only restates what the sketch already has.
|
||||
let want = vals.get(&pname).and_then(|v| v.to_kind(if is_angle { Kind::Angle } else { Kind::Length }).ok());
|
||||
residual = want.map(|w| (w - cur).abs()).filter(|r| r.is_finite());
|
||||
let name = k.name();
|
||||
let id = sk.add_constraint(k, Some(pname.clone()))?;
|
||||
new_id = id.clone();
|
||||
place_text(sk, &id, text_at);
|
||||
Ok((pname, name, measured(is_angle, cur)))
|
||||
})?;
|
||||
@@ -1776,8 +1920,28 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if driven {
|
||||
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}));
|
||||
}
|
||||
if reject_redundant(before, &info, cmd).is_err() {
|
||||
return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true"));
|
||||
// A dimension the sketch already determines cannot drive it: it is refused, or with
|
||||
// `if_redundant: "driven"` placed as a driven (reference) dimension instead.
|
||||
let by = redundancy(s, p, cmd, before, &info, residual, &new_id)
|
||||
.map_err(|_| bad(cmd, "that dimension conflicts with the sketch: other constraints already determine it with a different value"))?;
|
||||
if let Some(by) = by {
|
||||
if str_(p, "if_redundant").is_some_and(|m| m.eq_ignore_ascii_case("driven")) {
|
||||
s.doc = start;
|
||||
let mut q = p.clone();
|
||||
q["driven"] = json!(true);
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("if_redundant");
|
||||
o.remove("value");
|
||||
}
|
||||
let mut out = dimension(s, &q)?;
|
||||
out["redundant"] = json!(true);
|
||||
out["determined_by"] = json!(by);
|
||||
return Ok(out);
|
||||
}
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("that dimension is already determined by {}; add it as a driven (reference) dimension with driven: true", listed(&by)),
|
||||
));
|
||||
}
|
||||
let v = s.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default();
|
||||
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info}))
|
||||
@@ -2027,6 +2191,11 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
|
||||
let (d, rep) = edit_solved(s, id, false, |sk, doc| {
|
||||
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
|
||||
let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?;
|
||||
if let Some(link) = super::sketch_create::text_link(sk, r).map(str::to_owned) {
|
||||
let moving = sk.link_points(&link).into_iter().filter_map(|i| sk.point(i).map(|p| (i, p))).collect();
|
||||
super::sketch_create::move_text(sk, &link, to - from, 0.0, Vec2::ZERO, false)?;
|
||||
return Ok((drag::Dragged { reached: 1.0, moving }, Left::Unsolved));
|
||||
}
|
||||
// The held solves work on the dimensions' current values (a solved step applies them).
|
||||
doc.apply_dimension_values(&doc.param_values().0, sk)?;
|
||||
let (d, left) = drag_entity(sk, e, from, to, live);
|
||||
@@ -2083,6 +2252,7 @@ fn options_json(v: &solidcraft_sketch::SketchView) -> Value {
|
||||
"show_dimensions": !v.hide_dimensions,
|
||||
"show_constraints": !v.hide_constraints,
|
||||
"show_projected": !v.hide_projected,
|
||||
"show_construction": !v.hide_construction,
|
||||
"grid": !v.hide_grid,
|
||||
"snap": !v.no_snap,
|
||||
"slice": v.slice,
|
||||
@@ -2092,7 +2262,18 @@ fn options_json(v: &solidcraft_sketch::SketchView) -> Value {
|
||||
|
||||
fn sketch_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.options";
|
||||
let keys = ["show_profile", "show_points", "show_dimensions", "show_constraints", "show_projected", "grid", "snap", "slice", "sketch_3d"];
|
||||
let keys = [
|
||||
"show_profile",
|
||||
"show_points",
|
||||
"show_dimensions",
|
||||
"show_constraints",
|
||||
"show_projected",
|
||||
"show_construction",
|
||||
"grid",
|
||||
"snap",
|
||||
"slice",
|
||||
"sketch_3d",
|
||||
];
|
||||
if let Some(o) = p.as_object()
|
||||
&& let Some(k) = o.keys().find(|k| *k != "sketch" && !keys.contains(&k.as_str()))
|
||||
{
|
||||
@@ -2115,6 +2296,7 @@ fn sketch_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
put("show_dimensions", &mut v.hide_dimensions, true);
|
||||
put("show_constraints", &mut v.hide_constraints, true);
|
||||
put("show_projected", &mut v.hide_projected, true);
|
||||
put("show_construction", &mut v.hide_construction, true);
|
||||
put("grid", &mut v.hide_grid, true);
|
||||
put("snap", &mut v.no_snap, true);
|
||||
put("slice", &mut v.slice, false);
|
||||
@@ -2156,3 +2338,6 @@ mod drag_live_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_ids_tests.rs"]
|
||||
mod ids_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_redundant_tests.rs"]
|
||||
mod redundant_tests;
|
||||
|
||||
@@ -150,7 +150,10 @@ fn wire_frame_include_other_component_refresh_break_lost_and_reopen() {
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
// Lost references retain the last coordinates and explicit marker, with no guessing.
|
||||
let sk = s.doc_mut().sketch_mut(sketch).unwrap();
|
||||
sk.links.iter_mut().find(|l| l.id == link).unwrap().source = solidcraft_sketch::LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO };
|
||||
let entry = sk.links.iter_mut().find(|l| l.id == link).unwrap();
|
||||
entry.source = solidcraft_sketch::LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO };
|
||||
// The link is the edge it names; without the name it is the edge nearest its point.
|
||||
entry.edge_name = None;
|
||||
run(&mut s, "sketch.line", json!({"points":[[2,0],[3,0]]}));
|
||||
let sk = s.doc.sketch(sketch).unwrap();
|
||||
assert!(sk.link(&link).unwrap().lost);
|
||||
|
||||
@@ -232,7 +232,8 @@ fn bad_line_references_are_errors_and_change_nothing() {
|
||||
// A constraint the sketch refuses rolls the axis line back with it.
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "b", "mode": "horizontal"}));
|
||||
let before_redundant = s.doc.to_json();
|
||||
assert!(s.execute("sketch.constraint.parallel", &json!({"a": "b", "b": "x"})).is_err(), "already horizontal");
|
||||
// (A redundant one is kept unless refused: refused here.)
|
||||
assert!(s.execute("sketch.constraint.parallel", &json!({"a": "b", "b": "x", "if_redundant": "refuse"})).is_err(), "already horizontal");
|
||||
assert_eq!(s.doc.to_json(), before_redundant);
|
||||
assert!(axis_lines(&sketch(&s)).is_empty());
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec2;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
|
||||
|
||||
use super::sketch::{add_c, dof_now, edit, reject_redundant};
|
||||
use super::sketch::{add_c, constraint_result, dof_now, edit, listed, redundancy, residual_of};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, num, req_vec2, str_, string_list, vec2};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -56,15 +56,17 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
|
||||
fn c_smooth(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.constraint.curvature";
|
||||
let mut before = None;
|
||||
let (mut before, mut residual) = (None, None);
|
||||
let (id, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let a = curve(sk, p.get("a"), cmd)?;
|
||||
let b = curve(sk, p.get("b"), cmd)?;
|
||||
before = Some(dof_now(sk));
|
||||
add_c(sk, ConstraintKind::Smooth { a, b })
|
||||
let id = add_c(sk, ConstraintKind::Smooth { a, b })?;
|
||||
residual = residual_of(sk, &id);
|
||||
Ok(id)
|
||||
})?;
|
||||
reject_redundant(before, &info, cmd)?;
|
||||
Ok(json!({"constraint": id, "sketch": info}))
|
||||
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
|
||||
constraint_result(p, cmd, id, info, by, Value::Null)
|
||||
}
|
||||
|
||||
fn curve(sk: &Sketch, v: Option<&Value>, cmd: &str) -> Result<usize> {
|
||||
@@ -349,16 +351,18 @@ fn guess(sk: &Sketch, ents: &[String], cmd: &str) -> Result<ConstraintKind> {
|
||||
fn smart_constrain(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.constrainer";
|
||||
let ents = string_list(p, "entities");
|
||||
let mut before = None;
|
||||
let (mut before, mut residual) = (None, None);
|
||||
let mut name = "";
|
||||
let (id, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let k = guess(sk, &ents, cmd)?;
|
||||
name = k.name();
|
||||
before = Some(dof_now(sk));
|
||||
add_c(sk, k)
|
||||
let id = add_c(sk, k)?;
|
||||
residual = residual_of(sk, &id);
|
||||
Ok(id)
|
||||
})?;
|
||||
reject_redundant(before, &info, cmd)?;
|
||||
Ok(json!({"constraint": id, "type": name, "sketch": info}))
|
||||
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
|
||||
constraint_result(p, cmd, id, info, by, json!({"type": name}))
|
||||
}
|
||||
|
||||
fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -397,7 +401,10 @@ fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
c.driven = true;
|
||||
}
|
||||
before = Some(dof_now(&sk));
|
||||
reject_redundant(before, &info, cmd)?;
|
||||
// It holds (its parameter takes the value it measures), so it can only be redundant.
|
||||
if let Some(by) = redundancy(s, p, cmd, before, &info, Some(0.0), &cid)? {
|
||||
return Err(bad(cmd, format!("that dimension is already determined by {}; it stays driven", listed(&by))));
|
||||
}
|
||||
}
|
||||
s.doc_mut().prune_model_params();
|
||||
Ok(json!({"constraint": cid, "driven": driven, "sketch": info}))
|
||||
@@ -468,6 +475,8 @@ fn glyphs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"param": c.param,
|
||||
"at": anchor(&ss.sketch, &c.kind),
|
||||
"failing": failing.contains(&c.id),
|
||||
// Implied by the others (it removes no freedom) but holding: shown dimmed.
|
||||
"redundant": ss.report.redundant.contains(&c.id),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -25,17 +25,17 @@ fn sketch(s: &Session) -> solidcraft_sketch::Sketch {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redundant_constraints_and_dimensions_are_refused_driven_ones_measure() {
|
||||
fn redundant_dimensions_are_refused_driven_ones_measure() {
|
||||
let mut s = new_sketch();
|
||||
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"])[0].clone();
|
||||
// Already horizontal (inferred).
|
||||
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": l})).unwrap_err().to_string();
|
||||
assert!(e.contains("over-constrain"), "{e}");
|
||||
// Already horizontal (inferred): kept as redundant, unless asked to refuse it.
|
||||
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": l, "if_redundant": "refuse"})).unwrap_err().to_string();
|
||||
assert!(e.contains("over-constrain") && e.contains("already determined by"), "{e}");
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{l}.start"), "b": "origin"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": 10}));
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(si["dof"], 0, "{si}");
|
||||
// A second length on it over-constrains: refused, unless driven.
|
||||
// A second length on it is already determined: refused, unless driven.
|
||||
assert!(s.execute("sketch.dimension", &json!({"entities": [format!("{l}.start"), format!("{l}.end")]})).is_err());
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": [format!("{l}.start"), format!("{l}.end")], "driven": true}));
|
||||
assert_eq!(d["driven"], true);
|
||||
|
||||
@@ -10,6 +10,10 @@ use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, num, req_vec2, str_, string_list};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_text_move_tests.rs"]
|
||||
mod text_move_tests;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
|
||||
.at("SKETCH", "CREATE")
|
||||
@@ -133,6 +137,39 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"link": l, "curves": curves}))
|
||||
}
|
||||
|
||||
/// The text source owning an outline or one of its linked points.
|
||||
pub(super) fn text_link<'a>(sk: &'a Sketch, entity: &str) -> Option<&'a str> {
|
||||
let link = sk
|
||||
.curve_index(entity)
|
||||
.and_then(|i| sk.curves.get(i))
|
||||
.and_then(|c| c.link.as_deref())
|
||||
.or_else(|| sk.resolve_point(entity).and_then(|i| sk.points.get(i)).and_then(|p| p.link.as_deref()))?;
|
||||
sk.link(link).filter(|source| source.kind == LinkKind::Text).map(|source| source.id.as_str())
|
||||
}
|
||||
|
||||
/// Transform a text source as a unit; generated outlines remain linked and editable.
|
||||
pub(super) fn move_text(sk: &mut Sketch, link: &str, translate: Vec2, rotation: f64, center: Vec2, copy: bool) -> Result<Vec<usize>> {
|
||||
let Some(LinkSource::Text { text, at, height, angle, font }) = sk.link(link).map(|l| l.source.clone()) else {
|
||||
return Err(bad("sketch.move", "the selected source is not text"));
|
||||
};
|
||||
let q = at - center;
|
||||
let (sin, cos) = rotation.sin_cos();
|
||||
let at = center + Vec2::new(q.x * cos - q.y * sin, q.x * sin + q.y * cos) + translate;
|
||||
let angle = angle + rotation;
|
||||
let geom = solidcraft_sketch::text_geometry_in(font.as_deref(), &text, at, height, angle)?;
|
||||
let source = LinkSource::Text { text, at, height, angle, font };
|
||||
let link = if copy {
|
||||
sk.add_link(LinkKind::Text, source, &geom)?
|
||||
} else {
|
||||
sk.set_link_source(link, source);
|
||||
if sk.update_link(link, &geom)? {
|
||||
return Err(bad("sketch.move", "moving text must preserve its outline topology; edit the text source first"));
|
||||
}
|
||||
link.to_owned()
|
||||
};
|
||||
Ok(sk.link_curves(&link))
|
||||
}
|
||||
|
||||
fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.edit_text";
|
||||
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
|
||||
|
||||
@@ -95,7 +95,9 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => return Err(bad(cmd, "unsupported dimension role")),
|
||||
};
|
||||
let mut dimension = json!({"entities": entities, "type": ty, "value": value});
|
||||
// A typed size the sketch already has (a rectangle drawn onto another's side, the same
|
||||
// height) is placed as a driven (reference) dimension rather than refused.
|
||||
let mut dimension = json!({"entities": entities, "type": ty, "value": value, "if_redundant": "driven"});
|
||||
if let Some(sketch) = params.get("sketch") {
|
||||
dimension["sketch"] = sketch.clone();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use solidcraft_geom::Vec2;
|
||||
use solidcraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch};
|
||||
use solidcraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch, conic_rho_from_point};
|
||||
|
||||
use super::{Ent, Left, counted_solve};
|
||||
|
||||
@@ -47,13 +47,18 @@ enum Path {
|
||||
Radius { c: usize, from: f64, to: f64 },
|
||||
/// An arc's ends move along their rays from the centre `o` to the cursor's radius.
|
||||
ArcRadius { o: Vec2, ends: Vec<(usize, Vec2)>, from: f64, to: f64 },
|
||||
/// A conic's `rho` follows the cursor, read off its position between the chord's midpoint
|
||||
/// and the apex (see [`solidcraft_sketch::conic_rho_from_point`]). `a`, `b` and `apex` stay
|
||||
/// put; only the curve's fullness changes, the way an ellipse's outline changes its minor
|
||||
/// radius rather than sliding the ellipse.
|
||||
ConicRho { c: usize, from: f64, to: f64 },
|
||||
}
|
||||
|
||||
impl Path {
|
||||
fn length(&self) -> f64 {
|
||||
match self {
|
||||
Path::Slide { d, .. } => d.len(),
|
||||
Path::Radius { from, to, .. } | Path::ArcRadius { from, to, .. } => (to - from).abs(),
|
||||
Path::Radius { from, to, .. } | Path::ArcRadius { from, to, .. } | Path::ConicRho { from, to, .. } => (to - from).abs(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +66,7 @@ impl Path {
|
||||
fn points(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
let pts = match self {
|
||||
Path::Slide { pts, .. } | Path::ArcRadius { ends: pts, .. } => pts.as_slice(),
|
||||
Path::Radius { .. } => &[],
|
||||
Path::Radius { .. } | Path::ConicRho { .. } => &[],
|
||||
};
|
||||
pts.iter().map(|(q, _)| *q)
|
||||
}
|
||||
@@ -90,13 +95,19 @@ impl Path {
|
||||
*r = from + (to - from) * t;
|
||||
}
|
||||
}
|
||||
Path::ConicRho { c, from, to } => {
|
||||
if let Some(CurveKind::Conic { rho, .. }) = sk.curves.get_mut(*c).map(|x| &mut x.kind) {
|
||||
*rho = from + (to - from) * t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line,
|
||||
/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes
|
||||
/// radius about its centre) and the rest of the sketch moves as little as it can, staying on the
|
||||
/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line or spline
|
||||
/// slides by the cursor's move; a circle or arc grabbed on its edge changes radius about its
|
||||
/// centre; a conic grabbed on its curve reads its fullness, `rho`, off the cursor) and the rest
|
||||
/// of the sketch moves as little as it can, staying on the
|
||||
/// solution it started on. Fixed geometry stays. A step that cannot be solved with the entity
|
||||
/// held (the path crosses where the geometry cannot reach) is approached by halving the way to
|
||||
/// it, and the next steps are tried from the last solved state, the last of them being the whole
|
||||
@@ -430,8 +441,18 @@ fn plan(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold)> {
|
||||
hold.radii.push(c);
|
||||
Path::Radius { c, from: r, to: if minor > 1e-9 { minor } else { r } }
|
||||
}
|
||||
CurveKind::Conic { a, b, apex, rho } => {
|
||||
// Grabbed on the curve itself: its fullness (`rho`) follows the drag, the
|
||||
// way an ellipse's outline changes its minor radius rather than sliding the
|
||||
// ellipse. `a`, `b` and the apex (the tangent intersection, dragged
|
||||
// separately as an ordinary point) stay where they are.
|
||||
let (pa, px, pb) = (sk.point(a)?, sk.point(apex)?, sk.point(b)?);
|
||||
hold.points.extend([a, b, apex]);
|
||||
let to_rho = conic_rho_from_point(pa, px, pb, to);
|
||||
Path::ConicRho { c, from: rho, to: to_rho }
|
||||
}
|
||||
k => {
|
||||
// Lines, splines and conics slide with the cursor.
|
||||
// Lines and splines slide with the cursor.
|
||||
let mut ids = k.point_ids();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
|
||||
@@ -105,10 +105,10 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
|
||||
let inverse = mat_inverse(&frame).ok_or_else(|| bad(cmd, "invalid selected face frame"))?;
|
||||
let local_point = apply_point(&inverse, point);
|
||||
let tolerance = (body.body.size() * 1e-4).max(1e-5);
|
||||
// A flat face of a sheet metal body attaches to its panel: the exact plane comes from
|
||||
// the panel's flat region and fold, and the sketch keeps the face's name to follow it.
|
||||
// A flat face of a sheet metal body attaches to its panel (or a panel's rim): the
|
||||
// exact plane comes from the panel's flat region and fold, and the sketch keeps the
|
||||
// face's name to follow it.
|
||||
if !fixed
|
||||
&& !internal
|
||||
&& let Some(sheet) = state.sheets.iter().find(|sheet| sheet.body == body.name)
|
||||
&& let Some((face, plane)) = sheet.panel_face_at(local_point, tolerance * 10.0)
|
||||
{
|
||||
|
||||
@@ -68,8 +68,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"),
|
||||
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("move")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool", created_doc!())),
|
||||
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool; picking a text outline or linked point transforms its whole text source once, and copies remain editable text", created_doc!())),
|
||||
CommandSpec::new("sketch.mirror", "Mirror", mirror)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("sketch_mirror")
|
||||
@@ -1458,19 +1460,35 @@ fn transform_in_place(sk: &mut Sketch, cs: &[usize], ps: &[usize], f: &dyn Fn(Ve
|
||||
|
||||
fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.move";
|
||||
let t = p.get("translate").and_then(vec2).unwrap_or(Vec2::ZERO);
|
||||
let ang = num(p, "angle").unwrap_or(0.0).to_radians();
|
||||
let t = if p.get("translate").is_some() { req_vec2(cmd, p, "translate")? } else { Vec2::ZERO };
|
||||
let ang = if p.get("angle").is_some() { num(p, "angle").ok_or_else(|| bad(cmd, "`angle` must be finite"))? } else { 0.0 }.to_radians();
|
||||
let copy = bool_(p, "copy").unwrap_or(false);
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (cs, ps) = entities(sk, p, cmd)?;
|
||||
let (mut cs, mut ps) = entities(sk, p, cmd)?;
|
||||
let c = if p.get("center").is_some() { point_or_ref(sk, p, "center", cmd)? } else { Vec2::ZERO };
|
||||
let rot = rotate(c, ang);
|
||||
let f = move |q: Vec2| rot(q) + t;
|
||||
let mut links = Vec::new();
|
||||
for entity in string_list(p, "entities") {
|
||||
if let Some(link) = super::sketch_create::text_link(sk, &entity)
|
||||
&& !links.iter().any(|old| old == link)
|
||||
{
|
||||
links.push(link.to_owned());
|
||||
}
|
||||
}
|
||||
cs.retain(|&i| !sk.curves.get(i).and_then(|c| c.link.as_ref()).is_some_and(|link| links.contains(link)));
|
||||
ps.retain(|&i| !sk.points.get(i).and_then(|p| p.link.as_ref()).is_some_and(|link| links.contains(link)));
|
||||
let mut text_curves = Vec::new();
|
||||
for link in links {
|
||||
text_curves.extend(super::sketch_create::move_text(sk, &link, t, ang, c, copy)?);
|
||||
}
|
||||
if copy {
|
||||
let (m, _) = copy_through(sk, &cs, &ps, &f, false, &|_| false, 1.0)?;
|
||||
let (mut m, _) = copy_through(sk, &cs, &ps, &f, false, &|_| false, 1.0)?;
|
||||
m.extend(text_curves);
|
||||
return Ok(ids_of(sk, &m));
|
||||
}
|
||||
transform_in_place(sk, &cs, &ps, &f, 1.0, cmd)?;
|
||||
cs.extend(text_curves);
|
||||
Ok(ids_of(sk, &cs))
|
||||
})?;
|
||||
Ok(json!({"curves": out, "sketch": info}))
|
||||
|
||||
@@ -226,9 +226,12 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The link is the edge that was picked, not whichever is nearest the picked point later.
|
||||
let edge_name = solidcraft_doc::project::resolved_edge_name(&st, &r);
|
||||
let link = sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?;
|
||||
if let Some(l) = sk.links.iter_mut().find(|l| l.id == link) {
|
||||
l.face_name = name;
|
||||
l.edge_name = edge_name;
|
||||
}
|
||||
Ok(link)
|
||||
}
|
||||
@@ -568,6 +571,10 @@ mod tests;
|
||||
#[path = "sketch_project_side_tests.rs"]
|
||||
mod side_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_project_edge_tests.rs"]
|
||||
mod edge_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_model_plane_tests.rs"]
|
||||
mod model_plane_tests;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! A projected edge stays the edge that was picked: its link keeps the edge's persistent name,
|
||||
//! so the edge is found by that name wherever a change of the body takes it, and no other edge
|
||||
//! is taken for it when it is split or gone.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::{Vec2, Vec3};
|
||||
use solidcraft_sketch::{Link, LinkSource, Shape};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A plate `width` (100) x 50 x 20 with a corner at the origin: body `Body1`.
|
||||
fn plate(s: &mut Session) {
|
||||
run(s, "parameters.add", json!({"name": "width", "expression": "100 mm"}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 50]}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "width"}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch": "Base", "distance": "20 mm", "name": "Plate"}));
|
||||
}
|
||||
|
||||
/// Sketch `Top` on the plate's top (z = 20) with the top edge of the plate's right-hand side,
|
||||
/// from (100, 0, 20) to (100, 50, 20), projected into it: picked at `y` along it.
|
||||
fn project_right_edge(s: &mut Session, y: f64) {
|
||||
run(s, "sketch.create", json!({"plane": "XY", "offset": "20 mm", "name": "Top"}));
|
||||
run(s, "sketch.project", json!({"refs": [{"edge": [100, y, 20], "body": "Body1"}]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
fn set_width(s: &mut Session, width: f64) {
|
||||
run(s, "parameters.change", json!({"name": "width", "expression": format!("{width} mm")}));
|
||||
}
|
||||
|
||||
/// What sketch `Top` has to report.
|
||||
fn trouble(s: &Session) -> String {
|
||||
let r = s.model.result(s.doc.find_feature("Top").unwrap().id).unwrap();
|
||||
format!("{}{}", r.error.clone().unwrap_or_default(), r.warning.clone().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The link of sketch `Top` as the model has it.
|
||||
fn link(s: &Session) -> Link {
|
||||
s.model.state().sketch(s.doc.find_feature("Top").unwrap().id).unwrap().sketch.links[0].clone()
|
||||
}
|
||||
|
||||
/// The link of sketch `Top` as the design stores it.
|
||||
fn stored(s: &Session) -> Link {
|
||||
s.doc.sketch(s.doc.find_feature("Top").unwrap().id).unwrap().links[0].clone()
|
||||
}
|
||||
|
||||
/// The ends of the line the link of sketch `Top` gives.
|
||||
fn projected(s: &Session) -> (Vec2, Vec2) {
|
||||
let st = s.model.state();
|
||||
let sk = &st.sketch(s.doc.find_feature("Top").unwrap().id).unwrap().sketch;
|
||||
match sk.shape(sk.link_curves(&sk.links[0].id)[0]) {
|
||||
Some(Shape::Line { a, b }) => (a, b),
|
||||
other => panic!("not a line: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The projected line runs along y at `x`, between `y0` and `y1`.
|
||||
#[track_caller]
|
||||
fn assert_line(s: &Session, x: f64, (y0, y1): (f64, f64), when: &str) {
|
||||
let (a, b) = projected(s);
|
||||
let ends = (a.y.min(b.y), a.y.max(b.y));
|
||||
assert!(
|
||||
(a.x - x).abs() < 1e-6 && (b.x - x).abs() < 1e-6 && (ends.0 - y0).abs() < 1e-6 && (ends.1 - y1).abs() < 1e-6,
|
||||
"{when}: expected the line x = {x}, y = {y0}..{y1}: {a:?} {b:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The name of the edge of `Body1` whose middle is `mid`.
|
||||
fn edge_named_at(s: &mut Session, mid: [f64; 3]) -> String {
|
||||
let edges = run(s, "model.edges", json!({"body": "Body1"}));
|
||||
let at = |e: &Value| Vec3::new(e["mid"][0].as_f64().unwrap(), e["mid"][1].as_f64().unwrap(), e["mid"][2].as_f64().unwrap());
|
||||
let edge = edges["edges"].as_array().unwrap().iter().find(|e| at(e).dist(Vec3::new(mid[0], mid[1], mid[2])) < 1e-6).unwrap();
|
||||
edge["name"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// The picked point (100, 25, 20) is 25 from the front and back edges of the top: wider than
|
||||
/// 125 they are nearer to it than the picked edge is. At 60 the picked edge is 40 from it,
|
||||
/// further than a reference found by its point is looked for (a quarter of the body's diagonal,
|
||||
/// 20.2). The link follows the edge by its name through all of them.
|
||||
#[test]
|
||||
fn a_projected_edge_stays_that_edge_when_the_body_changes_size() {
|
||||
let mut s = Session::default();
|
||||
plate(&mut s);
|
||||
project_right_edge(&mut s, 25.0);
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "drawn");
|
||||
let name = edge_named_at(&mut s, [100.0, 25.0, 20.0]);
|
||||
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
|
||||
for width in [150.0, 60.0, 400.0] {
|
||||
set_width(&mut s, width);
|
||||
assert_eq!(trouble(&s), "", "width {width}");
|
||||
assert_line(&s, width, (0.0, 50.0), &format!("width {width}"));
|
||||
assert!(!link(&s).lost, "width {width}");
|
||||
}
|
||||
// Saved and opened again.
|
||||
let opened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(stored(&opened).edge_name.as_deref(), Some(name.as_str()));
|
||||
assert_line(&opened, 400.0, (0.0, 50.0), "opened");
|
||||
// Undo and redo of the change.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_line(&s, 60.0, (0.0, 50.0), "undone");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_line(&s, 400.0, (0.0, 50.0), "redone");
|
||||
// Editing the sketch keeps the edge, and stores the link's point where the edge is now.
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
|
||||
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(trouble(&s), "");
|
||||
assert_line(&s, 400.0, (0.0, 50.0), "edited");
|
||||
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
|
||||
assert!(matches!(&stored(&s).source, LinkSource::Edge { body, at } if body == "Body1" && at.dist(Vec3::new(400.0, 25.0, 20.0)) < 1e-6));
|
||||
}
|
||||
|
||||
/// A slot cut through the plate from x = 80 to 120, y = 20 to 30 leaves the picked edge in two
|
||||
/// pieces, y = 0..20 and y = 30..50. The link, picked at y = 10, gives the piece it was picked
|
||||
/// on and says the edge was split; without the slot it is the whole edge again.
|
||||
#[test]
|
||||
fn a_projected_edge_that_was_split_gives_its_nearest_piece_and_says_so() {
|
||||
let mut s = Session::default();
|
||||
plate(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "SlotSketch"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [80, 20], "p1": [120, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "SlotSketch", "distance": "20 mm", "operation": "cut", "name": "Slot"}));
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": true}));
|
||||
project_right_edge(&mut s, 10.0);
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "drawn");
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": false}));
|
||||
assert_line(&s, 100.0, (0.0, 20.0), "split");
|
||||
assert!(trouble(&s).contains("was split"), "{}", trouble(&s));
|
||||
assert!(!link(&s).lost);
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": true}));
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "whole again");
|
||||
assert_eq!(trouble(&s), "");
|
||||
}
|
||||
|
||||
/// A fillet of radius 4 takes the picked edge away. Its two tangent edges are 4 from the picked
|
||||
/// point, the nearest edges there are now: the link is lost and keeps its line, it takes
|
||||
/// neither of them. Without the fillet the link has its edge again.
|
||||
#[test]
|
||||
fn a_projected_edge_that_is_gone_is_lost_not_replaced() {
|
||||
let mut s = Session::default();
|
||||
plate(&mut s);
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[100, 25, 20]], "radius": "4 mm", "name": "Round"}));
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": true}));
|
||||
project_right_edge(&mut s, 25.0);
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "drawn");
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": false}));
|
||||
let said = trouble(&s);
|
||||
assert!(said.contains("projected reference lost: edge") && said.contains("no longer exists"), "{said}");
|
||||
assert!(link(&s).lost);
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "the last geometry is kept");
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": true}));
|
||||
assert_eq!(trouble(&s), "");
|
||||
assert!(!link(&s).lost);
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "found again");
|
||||
}
|
||||
|
||||
/// A boss 20 x 20 x 10 made as its own body on the plate and joined into it afterwards: no body
|
||||
/// has the link's body name any more. The join left the boss's top edge from (30, 10, 30) to
|
||||
/// (30, 30, 30) as it was, and the link finds it by its name on the body that has it now.
|
||||
#[test]
|
||||
fn a_projected_edge_whose_body_was_joined_into_another_is_found_there() {
|
||||
let mut s = Session::default();
|
||||
plate(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "20 mm", "name": "BossSketch"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [30, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "BossSketch", "distance": "10 mm", "operation": "new", "name": "Boss", "body_name": "Boss"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Body1", "tools": ["Boss"], "operation": "join"}));
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Combine1", "suppressed": true}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "30 mm", "name": "Top"}));
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"edge": [30, 20, 30], "body": "Boss"}]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_line(&s, 30.0, (10.0, 30.0), "drawn");
|
||||
assert!(stored(&s).edge_name.is_some());
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Combine1", "suppressed": false}));
|
||||
assert!(s.model.state().body("Boss").is_none(), "the boss is part of the plate now");
|
||||
assert_eq!(trouble(&s), "");
|
||||
assert!(!link(&s).lost);
|
||||
assert_line(&s, 30.0, (10.0, 30.0), "joined");
|
||||
assert!(matches!(&link(&s).source, LinkSource::Edge { body, .. } if body == "Body1"));
|
||||
}
|
||||
|
||||
/// A design saved before links kept the edge's name (format 16) has a link with a point only.
|
||||
/// It opens and is found by that point, as it was, and takes the edge's name when its sketch is
|
||||
/// next edited: undoing the edit takes the name away again, redoing it brings it back.
|
||||
#[test]
|
||||
fn an_edge_link_saved_without_a_name_is_found_as_before_and_named_by_the_next_edit() {
|
||||
let mut s = Session::default();
|
||||
plate(&mut s);
|
||||
project_right_edge(&mut s, 25.0);
|
||||
let name = edge_named_at(&mut s, [100.0, 25.0, 20.0]);
|
||||
let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
let links = saved["features"].as_array_mut().unwrap().iter_mut().filter_map(|f| f.pointer_mut("/sketch/links")?.as_array_mut());
|
||||
let removed = links.flatten().filter_map(|l| l.as_object_mut()?.remove("edge_name")).count();
|
||||
assert_eq!(removed, 1, "the one projected edge");
|
||||
saved["format"] = json!("solidcraft/16");
|
||||
let mut s = Session::new(solidcraft_doc::Document::from_json(&saved.to_string()).unwrap());
|
||||
assert_eq!(s.doc.format, solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT), "upgraded on opening");
|
||||
assert_eq!(stored(&s).edge_name, None);
|
||||
assert_eq!(trouble(&s), "");
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "opened");
|
||||
// Found by its point: 0.2 further out the picked edge is still the nearest one.
|
||||
set_width(&mut s, 100.2);
|
||||
assert_eq!(stored(&s).edge_name, None);
|
||||
assert_eq!(trouble(&s), "");
|
||||
assert_line(&s, 100.2, (0.0, 50.0), "width 100.2");
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
|
||||
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
|
||||
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(stored(&s).edge_name, None);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
set_width(&mut s, 150.0);
|
||||
assert_eq!(trouble(&s), "");
|
||||
assert_line(&s, 150.0, (0.0, 50.0), "width 150");
|
||||
}
|
||||
@@ -526,3 +526,62 @@ fn faces_split_by_hidden_seams_project_whole() {
|
||||
assert_eq!(radii.len(), 4, "{round}");
|
||||
assert!(radii[..2].iter().all(|r| (r - 17.).abs() < 1e-6) && radii[2..].iter().all(|r| (r - 20.).abs() < 1e-6), "{round}");
|
||||
}
|
||||
|
||||
/// #635: a rectangle whose corner is made coincident with a projected edge of the solid under
|
||||
/// the sketch, so that its bottom side lies on that edge, is still one closed profile (the
|
||||
/// projected line stays, and names the shared edge as it was there first), and it extrudes to
|
||||
/// its own volume, also when the design is opened again.
|
||||
#[test]
|
||||
fn a_rectangle_put_onto_a_projected_edge_stays_a_closed_profile() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 20}));
|
||||
let block = s.model.state().bodies[0].name.clone();
|
||||
let id = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
|
||||
// The solid's bottom edge along X, from (0, 0) to (40, 0).
|
||||
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 0, 0]}]}));
|
||||
let projected = r["links"][0]["curves"][0].as_str().unwrap().to_string();
|
||||
// A 20 x 15 rectangle clear of it is a closed profile of its own.
|
||||
let sides = ids(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 10], "p1": [25, 25]}))["curves"]);
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [sides[0]], "value": 20}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [sides[1]], "value": 15}));
|
||||
let mut own = sides.clone();
|
||||
own.sort();
|
||||
let profile_curves = |si: &Value| -> Vec<Vec<String>> {
|
||||
si["profiles"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut c = ids(&p["curves"]);
|
||||
c.sort();
|
||||
c
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(profile_curves(&inspect(&mut s)), vec![own.clone()]);
|
||||
// Its bottom-left corner onto the projected line: the bottom side lies on it.
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", sides[0]), "b": projected}));
|
||||
let si = inspect(&mut s);
|
||||
assert_eq!(si["status"], "solved", "{si}");
|
||||
let curve = |id: &str| si["curves"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone();
|
||||
let bottom = curve(&sides[0]);
|
||||
for end in ["start_at", "end_at"] {
|
||||
assert!(bottom[end][1].as_f64().unwrap().abs() < 1e-9, "{bottom}");
|
||||
}
|
||||
assert!(curve(&projected).get("link").is_some(), "the projected line stays");
|
||||
let mut on_line = vec![projected.clone(), sides[1].clone(), sides[2].clone(), sides[3].clone()];
|
||||
on_line.sort();
|
||||
assert_eq!(profile_curves(&si), vec![on_line], "{si}");
|
||||
assert!((si["profiles"][0]["area_mm2"].as_f64().unwrap() - 300.0).abs() < 1e-6, "{si}");
|
||||
// Picked by a point inside, it extrudes to a 20 x 15 x 10 block of its own.
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": id, "profiles": [{"point": [15, 7.5]}], "distance": 10, "operation": "new", "body_name": "Pad"}));
|
||||
let volume = |s: &Session, name: &str| s.model.state().body(name).unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
assert!((volume(&s, "Pad") - 3000.0).abs() < 1e-6, "{}", volume(&s, "Pad"));
|
||||
assert!((volume(&s, &block) - 24000.0).abs() < 1e-6, "{}", volume(&s, &block));
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((volume(&reopened, "Pad") - 3000.0).abs() < 1e-6, "{}", volume(&reopened, "Pad"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Redundant but consistent constraints (#687): rectangles of one size drawn around a shared
|
||||
//! corner (a "clover"), or onto each other's sides, imply some of their constraints twice. Those
|
||||
//! are kept (and reported as redundant) or not added at all; only constraints that conflict
|
||||
//! with the sketch are refused.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_sketch() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
s
|
||||
}
|
||||
|
||||
fn sketch(s: &Session) -> solidcraft_sketch::Sketch {
|
||||
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.clone()
|
||||
}
|
||||
|
||||
fn report(s: &Session) -> solidcraft_sketch::SolveReport {
|
||||
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().report.clone()
|
||||
}
|
||||
|
||||
fn glyphs(s: &mut Session) -> Value {
|
||||
run(s, "sketch.glyphs", json!({}))
|
||||
}
|
||||
|
||||
/// The four quadrants around `c`, each `w` square: bottom-left, bottom-right, top-right,
|
||||
/// top-left (each next one shares a side with the one before; the last shares two).
|
||||
fn quadrants(c: [f64; 2], w: f64) -> [([f64; 2], [f64; 2]); 4] {
|
||||
let [x, y] = c;
|
||||
[([x - w, y - w], [x, y]), ([x, y - w], [x + w, y]), ([x, y], [x + w, y + w]), ([x - w, y], [x, y + w])]
|
||||
}
|
||||
|
||||
fn horizontal_vertical(sk: &solidcraft_sketch::Sketch) -> usize {
|
||||
sk.constraints
|
||||
.iter()
|
||||
.filter(|c| matches!(c.kind, solidcraft_sketch::ConstraintKind::Horizontal { .. } | solidcraft_sketch::ConstraintKind::Vertical { .. }))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// The owner's steps: rectangles of one size (typed 10 × 10) drawn snapped onto a shared
|
||||
/// corner, 2, 3 and 4 of them. Every one is accepted; a size the sketch already has becomes a
|
||||
/// driven (reference) dimension, and the sketch ends fully constrained.
|
||||
#[test]
|
||||
fn a_clover_of_same_size_rectangles_drawn_by_snapping_is_accepted() {
|
||||
for n in 2..=4 {
|
||||
let mut s = new_sketch();
|
||||
for (i, (a, b)) in quadrants([0.0, 0.0], 10.0).iter().take(n).enumerate() {
|
||||
let r = s.execute(
|
||||
"sketch.draw.dimensioned",
|
||||
&json!({"command": "sketch.rectangle.two_point", "params": {"p0": a, "p1": b}, "dimensions": {"width": 10, "height": 10}}),
|
||||
);
|
||||
let r = r.unwrap_or_else(|e| panic!("rectangle {i} of {n}: {e}"));
|
||||
assert_eq!(r["sketch"]["solved"], true, "{r}");
|
||||
}
|
||||
let g = glyphs(&mut s);
|
||||
assert_eq!(g["fully_constrained"], true, "{n} rectangles: {g}");
|
||||
assert_eq!(g["conflicts"], json!([]), "{g}");
|
||||
let sk = sketch(&s);
|
||||
assert_eq!(sk.curves.len(), 4 * n);
|
||||
// Shared sides carry one horizontal/vertical, not two: 4 for the first rectangle, 3 for
|
||||
// each with one shared side, 2 for the last of four (two shared sides).
|
||||
let expected = [0, 4, 7, 10, 12][n];
|
||||
assert_eq!(horizontal_vertical(&sk), expected, "{n} rectangles");
|
||||
// Each later rectangle's size along a shared side is a reference dimension.
|
||||
let driven = sk.constraints.iter().filter(|c| c.driven).count();
|
||||
assert_eq!(driven, [0, 0, 1, 2, 4][n], "{n} rectangles");
|
||||
for (a, b) in quadrants([0.0, 0.0], 10.0).iter().take(n) {
|
||||
for corner in [Vec2::new(a[0], a[1]), Vec2::new(b[0], b[1])] {
|
||||
assert!(sk.points.iter().any(|p| p.pos.dist(corner) < 1e-9), "{n} rectangles: corner {corner:?} moved");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Undimensioned, the same clover is drawn without a single redundant constraint: shared
|
||||
/// sides are not constrained twice, and the freedom left is the two column widths and the two
|
||||
/// row heights.
|
||||
#[test]
|
||||
fn rectangles_sharing_sides_do_not_constrain_a_side_twice() {
|
||||
let mut s = new_sketch();
|
||||
for (a, b) in quadrants([50.0, 50.0], 10.0) {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": a, "p1": b}));
|
||||
}
|
||||
let rep = report(&s);
|
||||
assert!(rep.ok(), "{rep:?}");
|
||||
assert_eq!(rep.redundant, Vec::<String>::new());
|
||||
// Free: the shared corner (2), two column widths, two row heights.
|
||||
assert_eq!(rep.dof, 6);
|
||||
assert_eq!(horizontal_vertical(&sketch(&s)), 12);
|
||||
}
|
||||
|
||||
/// Two rectangles on one side: the second only adds its width.
|
||||
#[test]
|
||||
fn shared_edge_rectangles() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [25, 10]}));
|
||||
// Three of its sides' directions and its two corners on the first one.
|
||||
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(5), "{r}");
|
||||
let rep = report(&s);
|
||||
assert_eq!(rep.dof, 3, "the first one's width and height, the second one's width");
|
||||
assert!(rep.redundant.is_empty());
|
||||
// Sized and placed, all of it is fixed.
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 10}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l5"], "value": 15}));
|
||||
assert_eq!(glyphs(&mut s)["fully_constrained"], true);
|
||||
}
|
||||
|
||||
/// Two separately drawn 10 × 10 squares snapped corner to corner: the second snap is implied by
|
||||
/// the first and the equal sizes. It is kept, reported as redundant along with what implies
|
||||
/// it, and the degrees of freedom and the fully constrained state are unchanged.
|
||||
#[test]
|
||||
fn snapping_a_second_corner_of_same_size_rectangles_is_redundant_not_refused() {
|
||||
let mut s = new_sketch();
|
||||
for (a, b) in [([0.0, 0.0], [10.0, 10.0]), ([20.0, 0.0], [30.0, 10.0])] {
|
||||
run(
|
||||
&mut s,
|
||||
"sketch.draw.dimensioned",
|
||||
json!({"command": "sketch.rectangle.two_point", "params": {"p0": a, "p1": b}, "dimensions": {"width": 10, "height": 10}}),
|
||||
);
|
||||
}
|
||||
// The second square's bottom-left corner onto the first one's bottom-right.
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": "l5.start", "b": "l1.end"}));
|
||||
let dof = report(&s).dof;
|
||||
assert_eq!(dof, 0, "the first square is on the origin, the second one placed by its corner");
|
||||
// Its top-left corner is already on the first one's top-right.
|
||||
let r = run(&mut s, "sketch.constraint.coincident", json!({"a": "l7.end", "b": "l2.end"}));
|
||||
assert_eq!(r["redundant"], true, "{r}");
|
||||
assert!(!r["determined_by"].as_array().is_none_or(Vec::is_empty), "{r}");
|
||||
let rep = report(&s);
|
||||
assert_eq!((rep.ok(), rep.dof), (true, 0));
|
||||
assert_eq!(rep.redundant, vec![r["constraint"].as_str().unwrap().to_string()]);
|
||||
let g = glyphs(&mut s);
|
||||
assert_eq!(g["fully_constrained"], true);
|
||||
let flagged: Vec<&Value> = g["glyphs"].as_array().unwrap().iter().filter(|x| x["redundant"] == true).collect();
|
||||
assert_eq!(flagged.len(), 1, "{g}");
|
||||
// Drawing-time inferences ask for redundant ones to be left out.
|
||||
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": "l8", "if_redundant": "refuse"})).unwrap_err().to_string();
|
||||
assert!(e.contains("already determined by"), "{e}");
|
||||
}
|
||||
|
||||
/// A size the sketch already has, dimensioned again: refused as already determined (naming
|
||||
/// what determines it), or placed as a driven dimension; an equal constraint the sketch already
|
||||
/// implies is kept as redundant.
|
||||
#[test]
|
||||
fn redundant_equal_dimensions() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [20, 10]}));
|
||||
let height = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
|
||||
let k = sketch(&s).constraints.iter().find(|c| c.param.as_deref() == height["param"].as_str()).map(|c| c.id.clone()).unwrap();
|
||||
// The second one's right side is as tall as the shared side.
|
||||
let e = s.execute("sketch.dimension", &json!({"entities": ["l6"], "value": 10})).unwrap_err().to_string();
|
||||
assert!(e.contains("already determined by") && e.contains(&k), "{e}");
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l6"], "value": 10, "if_redundant": "driven"}));
|
||||
assert_eq!((d["driven"].clone(), d["redundant"].clone()), (json!(true), json!(true)), "{d}");
|
||||
// Equal widths, twice: the second is implied by the first.
|
||||
run(&mut s, "sketch.constraint.equal", json!({"a": "l1", "b": "l5"}));
|
||||
let before = report(&s).dof;
|
||||
let r = run(&mut s, "sketch.constraint.equal", json!({"a": "l3", "b": "l7"}));
|
||||
assert_eq!(r["redundant"], true, "{r}");
|
||||
assert_eq!(report(&s).dof, before);
|
||||
// Its driving value changes still solve with the redundant constraints present.
|
||||
let pname = height["param"].as_str().unwrap().to_string();
|
||||
run(&mut s, "parameters.change", json!({"name": pname, "expression": "14 mm"}));
|
||||
let sk = sketch(&s);
|
||||
assert!(report(&s).ok());
|
||||
let len = |id: &str| {
|
||||
let c = sk.curve_index(id).unwrap();
|
||||
let solidcraft_sketch::CurveKind::Line { a, b } = sk.curves[c].kind else { panic!() };
|
||||
sk.points[a].pos.dist(sk.points[b].pos)
|
||||
};
|
||||
assert!((len("l6") - 14.0).abs() < 1e-7 && (len("l4") - 14.0).abs() < 1e-7);
|
||||
}
|
||||
|
||||
/// A constraint that does not hold where the others put the geometry still conflicts.
|
||||
#[test]
|
||||
fn a_conflicting_constraint_is_still_refused() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [20, 10]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
|
||||
let before = sketch(&s).constraints.len();
|
||||
// The second one's right side cannot be 12 while the shared side is 10.
|
||||
let e = s.execute("sketch.dimension", &json!({"entities": ["l6"], "value": 12})).unwrap_err().to_string();
|
||||
assert!(e.contains("over-constrain") || e.contains("conflicts"), "{e}");
|
||||
let e = s.execute("sketch.dimension", &json!({"entities": ["l6"], "value": 12, "if_redundant": "driven"})).unwrap_err().to_string();
|
||||
assert!(e.contains("over-constrain") || e.contains("conflicts"), "{e}");
|
||||
// A vertical side cannot be horizontal.
|
||||
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": "l2", "mode": "horizontal"})).unwrap_err().to_string();
|
||||
assert!(e.contains("over-constrain"), "{e}");
|
||||
assert_eq!(sketch(&s).constraints.len(), before);
|
||||
assert!(report(&s).redundant.is_empty());
|
||||
}
|
||||
|
||||
/// Dragging the shared corner of a clover moves all four rectangles with it, redundant
|
||||
/// constraints and all.
|
||||
#[test]
|
||||
fn dragging_the_shared_corner_moves_every_rectangle() {
|
||||
let mut s = new_sketch();
|
||||
for (a, b) in quadrants([50.0, 50.0], 10.0) {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": a, "p1": b}));
|
||||
}
|
||||
// A redundant equal and a redundant vertical, so the drag has some to carry.
|
||||
run(&mut s, "sketch.constraint.equal", json!({"a": "l1", "b": "l13"}));
|
||||
let r = run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l4"}));
|
||||
assert_eq!(r["redundant"], true, "{r}");
|
||||
assert!(!report(&s).redundant.is_empty());
|
||||
let centre = Vec2::new(50.0, 50.0);
|
||||
let id = sketch(&s).points.iter().find(|p| p.pos.dist(centre) < 1e-9).map(|p| p.id.clone()).unwrap();
|
||||
run(&mut s, "sketch.drag", json!({"entity": id, "from": [50, 50], "to": [56, 47]}));
|
||||
let sk = sketch(&s);
|
||||
let rep = report(&s);
|
||||
assert!(rep.ok(), "{rep:?}");
|
||||
let to = Vec2::new(56.0, 47.0);
|
||||
// Each rectangle still has a corner on the moved one and is still a rectangle.
|
||||
let near: usize = sk.points.iter().filter(|p| p.pos.dist(to) < 1e-6).count();
|
||||
assert!(near >= 4, "{near} corners at the dragged spot");
|
||||
for l in &sk.curves {
|
||||
let solidcraft_sketch::CurveKind::Line { a, b } = l.kind else { continue };
|
||||
let d = sk.points[b].pos - sk.points[a].pos;
|
||||
assert!(d.x.abs() < 1e-7 || d.y.abs() < 1e-7, "{} is no longer axis-aligned: {d:?}", l.id);
|
||||
}
|
||||
assert!(sk.points.iter().all(|p| p.pos.dist(centre) > 1e-6), "nothing stayed behind at the old corner");
|
||||
}
|
||||
|
||||
/// A horizontal on a line that already is (it was drawn so, inferred) is redundant, not an
|
||||
/// error; the degrees of freedom do not change, and the sketch still turns fully constrained.
|
||||
#[test]
|
||||
fn an_explicit_redundant_constraint_keeps_the_dof_and_full_constraint() {
|
||||
let mut s = new_sketch();
|
||||
let l = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"][0].as_str().unwrap().to_string();
|
||||
let r = run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": l}));
|
||||
assert_eq!(r["redundant"], true, "{r}");
|
||||
// Its start is on the origin (snapped): only its length is free.
|
||||
assert_eq!(report(&s).dof, 1);
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": 10}));
|
||||
let g = glyphs(&mut s);
|
||||
assert_eq!((g["dof"].clone(), g["fully_constrained"].clone()), (json!(0), json!(true)), "{g}");
|
||||
assert!(report(&s).point_determined.iter().all(|d| *d));
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use super::*;
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
|
||||
s.execute(cmd, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
fn setup() -> (Session, String, String) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
let result = run(&mut s, "sketch.text", json!({"text":"II","at":[3,4],"height":10,"angle":15}));
|
||||
(s, result["link"].as_str().unwrap().into(), result["curves"][0].as_str().unwrap().into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_move_updates_the_source_once_preserves_ids_and_roundtrips() {
|
||||
let (mut s, link, curve) = setup();
|
||||
let before = s.doc.to_json();
|
||||
let ids: Vec<_> = s.doc.sketch(1).unwrap().curves.iter().map(|c| c.id.clone()).collect();
|
||||
let undo = s.undo.len();
|
||||
run(&mut s, "sketch.move", json!({"entities":[curve,ids[1]],"translate":[10,-2],"center":[3,4],"angle":30}));
|
||||
let sk = s.doc.sketch(1).unwrap();
|
||||
assert_eq!(sk.curves.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), ids);
|
||||
let LinkSource::Text { at, angle, text, height, font } = &sk.link(&link).unwrap().source else { panic!() };
|
||||
assert!(at.dist(Vec2::new(13.0, 2.0)) < 1e-9);
|
||||
assert!((*angle - 45_f64.to_radians()).abs() < 1e-12);
|
||||
assert_eq!((text.as_str(), *height, font), ("II", 10.0, &None));
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let moved = s.doc.to_json();
|
||||
let reopened = solidcraft_doc::Document::from_json(&moved).unwrap();
|
||||
assert_eq!(reopened.to_json(), moved);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), moved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_copy_is_a_new_editable_source_and_original_is_unchanged() {
|
||||
let (mut s, link, curve) = setup();
|
||||
let original = s.doc.sketch(1).unwrap().link(&link).unwrap().source.clone();
|
||||
let result = run(&mut s, "sketch.move", json!({"entities":[curve],"translate":[20,0],"copy":true}));
|
||||
let copied_curve = result["curves"][0].as_str().unwrap();
|
||||
let new_link = text_link(s.doc.sketch(1).unwrap(), copied_curve).unwrap().to_owned();
|
||||
assert_ne!(new_link, link);
|
||||
run(&mut s, "sketch.edit_text", json!({"link":new_link,"text":"O"}));
|
||||
assert_eq!(s.doc.sketch(1).unwrap().link(&link).unwrap().source, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_drag_moves_all_letters_and_rebuilds_downstream_on_release() {
|
||||
let (mut s, link, curve) = setup();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":3}));
|
||||
run(&mut s, "sketch.edit", json!({"sketch":1}));
|
||||
let sk = s.doc.sketch(1).unwrap();
|
||||
let before = sk.link_points(&link).into_iter().map(|i| (i, sk.point(i).unwrap())).collect::<Vec<_>>();
|
||||
for frame in 0..3 {
|
||||
let result = run(&mut s, "sketch.drag", json!({"entity":curve,"from":[frame,0],"to":[frame+1,1],"live":true}));
|
||||
assert_eq!(result["reached"], 1.0);
|
||||
assert!(s.model.is_stale());
|
||||
}
|
||||
run(&mut s, "sketch.drag_end", json!({}));
|
||||
assert!(!s.model.is_stale());
|
||||
assert!(s.model.result(2).is_some_and(|r| r.error.is_none()));
|
||||
let sk = s.doc.sketch(1).unwrap();
|
||||
for (i, old) in before {
|
||||
assert!(sk.point(i).unwrap().dist(old + Vec2::new(3.0, 3.0)) < 1e-8);
|
||||
}
|
||||
assert!(matches!(sk.link(&link).unwrap().source, LinkSource::Text { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_move_in_a_rotated_component_preserves_font_and_local_coordinates() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Label"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":component["occurrence"],"translate":[30,20,10],"axis":[0,0,1],"angle":90}));
|
||||
let created = run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
let sid = created["sketch"].as_u64().unwrap();
|
||||
let result = run(&mut s, "sketch.text", json!({"text":"I","at":[0,0],"height":10,"font":solidcraft_sketch::DEFAULT_FONT_FAMILY}));
|
||||
let link = result["link"].as_str().unwrap();
|
||||
let before = s.world_state().sketch(sid).unwrap().plane;
|
||||
run(&mut s, "sketch.move", json!({"entities":[result["curves"][0]],"translate":[5,2]}));
|
||||
let sk = s.doc.sketch(sid).unwrap();
|
||||
let LinkSource::Text { at, font, .. } = &sk.link(link).unwrap().source else { panic!() };
|
||||
assert!(at.dist(Vec2::new(5.0, 2.0)) < 1e-9);
|
||||
assert_eq!(font.as_deref(), Some(solidcraft_sketch::DEFAULT_FONT_FAMILY));
|
||||
let state = s.world_state();
|
||||
assert!(state.sketch(sid).unwrap().plane.to_world(*at).dist(before.to_world(Vec2::new(5.0, 2.0))) < 1e-9);
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(reopened.doc.sketch(sid).unwrap().link(link).unwrap().source, sk.link(link).unwrap().source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_move_rejects_malformed_values_without_changing_history() {
|
||||
let (mut s, _, curve) = setup();
|
||||
let before = s.doc.to_json();
|
||||
let undo = s.undo.len();
|
||||
for params in [
|
||||
json!({"entities":[curve],"translate":[1]}),
|
||||
json!({"entities":[curve],"angle":"invalid"}),
|
||||
json!({"entity":curve,"from":[0,0],"to":["invalid",2]}),
|
||||
] {
|
||||
let cmd = if params.get("entity").is_some() { "sketch.drag" } else { "sketch.move" };
|
||||
assert!(s.execute(cmd, ¶ms).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
@@ -482,6 +482,63 @@ fn palette_options_are_kept_with_each_sketch() {
|
||||
assert!(!sketch(&s).view.hide_points);
|
||||
}
|
||||
|
||||
/// Construction Geometries is a display option of one sketch, saved with it, and it changes
|
||||
/// nothing about the geometry: construction curves were already outside every profile, and
|
||||
/// they still are while hidden. Centerlines are a line type of their own and keep showing. A
|
||||
/// design written before the option existed carries no key for it and loads with construction
|
||||
/// geometry shown.
|
||||
#[test]
|
||||
fn hiding_construction_geometry_is_per_sketch_saved_and_leaves_profiles_and_centerlines_alone() {
|
||||
use crate::view::sketch_curve_visible;
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 45], [40, 45]], "connect": false, "infer": false}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 55], [40, 55]], "connect": false, "infer": false}));
|
||||
run(&mut s, "sketch.construction", json!({"curves": ["l5"], "value": true}));
|
||||
run(&mut s, "sketch.centerline", json!({"curves": ["l6"], "value": true}));
|
||||
let (rect, construction, centerline) = (0, 4, 5);
|
||||
let profiles = run(&mut s, "sketch.inspect", json!({}))["profiles"].clone();
|
||||
assert_eq!(profiles.as_array().map(Vec::len), Some(1), "the rectangle is one profile: {profiles}");
|
||||
|
||||
assert_eq!(run(&mut s, "sketch.options", json!({}))["options"]["show_construction"], true);
|
||||
let o = run(&mut s, "sketch.options", json!({"show_construction": false}));
|
||||
assert_eq!(o["options"]["show_construction"], false);
|
||||
let sk = sketch(&s);
|
||||
assert!(sk.view.hide_construction);
|
||||
assert!(!sketch_curve_visible(&sk, construction), "construction geometry is hidden");
|
||||
assert!(sketch_curve_visible(&sk, centerline), "a centerline is its own line type");
|
||||
assert!(sketch_curve_visible(&sk, rect), "ordinary geometry stays");
|
||||
// Hiding is display only: the curves, their line types and the profiles are untouched.
|
||||
let after = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(after["profiles"], profiles);
|
||||
assert_eq!(after["curves"][construction]["construction"], true);
|
||||
assert!(sketch(&s).curves[centerline].centerline, "the centerline keeps its line type");
|
||||
// Undo puts the option back.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(!sketch(&s).view.hide_construction);
|
||||
run(&mut s, "sketch.options", json!({"show_construction": false}));
|
||||
|
||||
// Another sketch shows construction geometry; the first keeps the option after finishing.
|
||||
let first = s.active_sketch.expect("sketch");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
|
||||
assert_eq!(run(&mut s, "sketch.options", json!({}))["options"]["show_construction"], true);
|
||||
assert_eq!(run(&mut s, "sketch.options", json!({"sketch": first}))["options"]["show_construction"], false);
|
||||
|
||||
// Saved with the design.
|
||||
let text = serde_json::to_string(s.doc.sketch(first).expect("first")).expect("json");
|
||||
assert!(text.contains("hide_construction"), "{text}");
|
||||
let back: Sketch = serde_json::from_str(&text).expect("parse");
|
||||
assert!(back.view.hide_construction);
|
||||
|
||||
// An older design file has no `hide_construction` key at all: `SketchView`'s serde default
|
||||
// fills it in, and construction geometry shows, the way it did before the option existed.
|
||||
let older = r#"{"points":[{"id":"origin","pos":[0.0,0.0],"fixed":true}],"curves":[],"constraints":[],
|
||||
"view":{"hide_projected":true,"slice":true}}"#;
|
||||
let old: Sketch = serde_json::from_str(older).expect("an older sketch still loads");
|
||||
assert!(old.view.hide_projected && old.view.slice && !old.view.hide_construction);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Dragging
|
||||
|
||||
@@ -534,6 +591,32 @@ fn dragging_circles_and_arcs() {
|
||||
assert!(pt(&s, "a1.start").dist(v(70.0, 0.0)) < 1e-9, "{:?}", pt(&s, "a1.start"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_conic_reads_rho_off_the_cursor_and_its_apex_slides_like_any_point() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.conic", json!({"start": [0, 0], "end": [40, 0], "apex": [20, 20]}));
|
||||
let rho = |s: &Session| match sketch(s).curves[0].kind {
|
||||
solidcraft_sketch::CurveKind::Conic { rho, .. } => rho,
|
||||
ref k => panic!("not a conic: {k:?}"),
|
||||
};
|
||||
assert!((rho(&s) - 0.5).abs() < 1e-9, "default rho: {}", rho(&s));
|
||||
|
||||
// Grabbed on the curve itself (its midpoint, at the default rho of 0.5): `rho` follows the
|
||||
// cursor, read off where it falls between the chord's midpoint (20, 0) and the apex (20,
|
||||
// 20). The three defining points stay exactly where they were.
|
||||
drag(&mut s, "k1", [20.0, 10.0], [20.0, 14.0]);
|
||||
assert!((rho(&s) - 0.7).abs() < 1e-6, "rho after drag: {}", rho(&s));
|
||||
assert!(pt(&s, "k1.start").dist(v(0.0, 0.0)) < 1e-9);
|
||||
assert!(pt(&s, "k1.end").dist(v(40.0, 0.0)) < 1e-9);
|
||||
assert!(pt(&s, "k1.apex").dist(v(20.0, 20.0)) < 1e-9);
|
||||
|
||||
// Grabbed on the apex itself: it slides like any other point (not a `rho` edit), and `rho`
|
||||
// is unaffected.
|
||||
drag(&mut s, "k1.apex", [20.0, 20.0], [10.0, 30.0]);
|
||||
assert!(pt(&s, "k1.apex").dist(v(10.0, 30.0)) < 1e-9);
|
||||
assert!((rho(&s) - 0.7).abs() < 1e-6, "rho after apex drag: {}", rho(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_geometry_resists_drags() {
|
||||
let mut s = new_sketch();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Surface commands: Patch, Stitch, Thicken and Trim (surface bodies and turning them into
|
||||
//! solids).
|
||||
//! solids), and the planned surface toolset (`planned!`: registered, not built yet).
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, expr::Kind};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, check_expr, feature_sketch, operation, plane_param, profiles};
|
||||
use super::features::{add_feature, check_expr, extent_param, feature_sketch, operation, plane_param, profiles};
|
||||
use crate::params::{bad, bool_, expr, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -30,6 +30,54 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("trim")
|
||||
.params("body: surface name; plane: XY|XZ|YZ|plane name|{origin, normal} or tool: {body, point} (a face, its surface extended); keep: [x,y,z] (a point on the side to keep)"),
|
||||
CommandSpec::new("surface.extrude", "Surface Extrude", surface_extrude)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_extrude")
|
||||
.params("sketch?: id|name; curves?: [curve ids] (open or closed chains of curves meeting end to end) and/or profiles? (their boundaries; the default when no curves are given); distance: expr; direction?: positive|negative|symmetric; distance2?: expr; start_offset?: expr; name?, body_name? (a surface body: walls only, no caps)"),
|
||||
planned!("surface.revolve", "Surface Revolve", "757")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_revolve")
|
||||
.params("planned: the parameters of solid.revolve, with open curve chains as well as profiles (no caps); caps?: bool"),
|
||||
planned!("surface.sweep", "Surface Sweep", "757")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_sweep")
|
||||
.params("planned: the parameters of solid.sweep, with an open profile chain as well as a closed one (no caps); caps?: bool"),
|
||||
planned!("surface.loft", "Surface Loft", "757")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_loft")
|
||||
.params("planned: the parameters of solid.loft, with open sections as well as closed ones (no caps); caps?: bool"),
|
||||
planned!("surface.offset", "Offset Surface", "758")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_offset")
|
||||
.params("planned: faces: [{body, point}]; distance: expr (signed, along the normals; 0 copies the faces); chain?: bool (the whole tangent chain of each pick); tolerance?: expr (B-spline fit, default 0.01 mm)"),
|
||||
planned!("surface.ruled", "Ruled Surface", "762")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("ruled")
|
||||
.params("planned: body; edges: [[x,y,z]] (points on an edge chain); distance: expr; mode?: normal|tangent|angle|direction; angle?: expr; direction?: [x,y,z]; flip?: bool; corners?: mitre|round"),
|
||||
planned!("surface.boundary", "Boundary Surface", "763")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("boundary")
|
||||
.params("planned: u: [curves], v?: [curves] (sketch curves, 3D sketch wires or {body, point} edges; 2–4 boundaries, more make a network); continuity?: [{curve, g: 0|1|2}] for edge boundaries; operation?: new|join"),
|
||||
planned!("surface.blend", "Blend Surface", "764")
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_blend")
|
||||
.params("planned: from, to: {body, edges: [[x,y,z]]} (two edge chains apart); continuity?: [g0|g1|g2, g0|g1|g2] (each side); bulge?: [number, number]; align?: auto|[[x,y,z], [x,y,z]]; stitch?: bool"),
|
||||
planned!("surface.untrim", "Untrim", "761")
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("untrim")
|
||||
.params("planned: face: {body, point}; holes?: all|[[x,y,z]] (points on the inner loops to remove); outer?: bool (restore the natural bounds); in_place?: bool (a surface body only)"),
|
||||
planned!("surface.unstitch", "Unstitch", "766")
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("unstitch")
|
||||
.params("planned: body; faces?: [[x,y,z]] (points on the faces to split off; default: every face its own surface body)"),
|
||||
planned!("surface.reverse_normal", "Reverse Normal", "766")
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("reverse_normal")
|
||||
.params("planned: body (a surface); faces?: [[x,y,z]] (default: all of them)"),
|
||||
planned!("surface.fillet", "Surface Fillet", "769")
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("surface_fillet")
|
||||
.params("planned: a, b: {body, point} (two faces of separate bodies that meet or cross; the points pick the side the ball rolls on); radius: expr; trim?: both|first|none"),
|
||||
];
|
||||
|
||||
fn patch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -47,6 +95,21 @@ fn patch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, FeatureKind::Patch { sketch: Some(sketch), profiles, body: None, edges: Vec::new() })
|
||||
}
|
||||
|
||||
fn surface_extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.extrude";
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let curves = string_list(p, "curves");
|
||||
if curves.len() > 1000 {
|
||||
return Err(bad(cmd, "at most 1000 curves"));
|
||||
}
|
||||
let profiles = if curves.is_empty() || p.get("profiles").is_some() { Some(profiles(p, cmd)?) } else { None };
|
||||
let extent = extent_param(s, p, cmd)?;
|
||||
if extent.through_all || extent.taper.is_some() || extent.to.is_some() || extent.from.is_some() {
|
||||
return Err(bad(cmd, "not supported yet: through all, to or from an object, or a taper"));
|
||||
}
|
||||
add_feature(s, p, FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent })
|
||||
}
|
||||
|
||||
fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() {
|
||||
@@ -129,6 +192,105 @@ mod tests {
|
||||
solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume
|
||||
}
|
||||
|
||||
fn area(s: &Session, name: &str) -> f64 {
|
||||
solidcraft_kernel::measure(&s.world_state().body(name).unwrap().body).unwrap().area
|
||||
}
|
||||
|
||||
/// Surface Extrude of open curves: a line and an arc joined end to end (listed in either
|
||||
/// order) make one sheet of two walls; both sides of the sketch; undo, redo and reopening
|
||||
/// give the same sheet.
|
||||
#[test]
|
||||
fn surface_extrude_open_curves() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "ids": ["l1"]}));
|
||||
let arc = run(&mut s, "sketch.arc.three_point", json!({"start": [10, 0], "end": [20, 0], "through": [15, 5]}))["created"]["curves"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "surface.extrude", json!({"sketch": "S", "curves": [arc, "l1"], "distance": 4, "body_name": "Wall"}));
|
||||
let st = s.world_state();
|
||||
let wall = &st.body("Wall").unwrap().body;
|
||||
assert!(wall.is_surface() && wall.face_count() == 2);
|
||||
// The line's wall and the half circle's (radius 5): length × height.
|
||||
let want = (10.0 + std::f64::consts::PI * 5.0) * 4.0;
|
||||
assert!((area(&s, "Wall") - want).abs() < want * 1e-3, "{} vs {want}", area(&s, "Wall"));
|
||||
run(&mut s, "surface.extrude", json!({"sketch": "S", "curves": ["l1"], "distance": 2, "direction": "symmetric", "body_name": "Both"}));
|
||||
let bb = solidcraft_kernel::measure(&s.world_state().body("Both").unwrap().body).unwrap().bbox;
|
||||
assert!((bb.min.z + 2.0).abs() < 1e-9 && (bb.max.z - 2.0).abs() < 1e-9, "{bb:?}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.world_state().body("Both").is_none());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.world_state().body("Both").is_some());
|
||||
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((area(&cold, "Wall") - area(&s, "Wall")).abs() < 1e-9);
|
||||
// Refused: no such curve, a zero distance, and an extent a sheet does not take yet.
|
||||
assert!(s.execute("surface.extrude", &json!({"sketch": "S", "curves": ["nope"], "distance": 4})).is_err());
|
||||
assert!(s.execute("surface.extrude", &json!({"sketch": "S", "curves": ["l1"], "distance": 0})).is_err());
|
||||
assert!(s.execute("surface.extrude", &json!({"sketch": "S", "curves": ["l1"], "distance": 4, "through_all": true})).is_err());
|
||||
}
|
||||
|
||||
/// Surface Extrude of a profile gives its walls alone; patching both ends and stitching
|
||||
/// makes the solid that solid.extrude makes of the same profile.
|
||||
#[test]
|
||||
fn surface_extrude_profile_walls_stitch_into_the_solid() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 6]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "surface.extrude", json!({"sketch": "R", "distance": 3, "body_name": "Walls"}));
|
||||
let st = s.world_state();
|
||||
let walls = &st.body("Walls").unwrap().body;
|
||||
assert!(walls.is_surface() && walls.face_count() == 4);
|
||||
assert!((area(&s, "Walls") - 96.0).abs() < 1e-9);
|
||||
let edges = |z: f64| json!([[5, 0, z], [10, 3, z], [5, 6, z], [0, 3, z]]);
|
||||
run(&mut s, "surface.patch", json!({"edges": edges(0.0), "body": "Walls", "body_name": "Bottom"}));
|
||||
run(&mut s, "surface.patch", json!({"edges": edges(3.0), "body": "Walls", "body_name": "Top"}));
|
||||
run(&mut s, "surface.stitch", json!({"bodies": ["Walls", "Bottom", "Top"]}));
|
||||
let st = s.world_state();
|
||||
assert!(!st.body("Walls").unwrap().body.is_surface());
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "R", "distance": 3, "body_name": "Solid"}));
|
||||
assert!((volume(&s, "Walls") - volume(&s, "Solid")).abs() < 1e-9);
|
||||
assert!((volume(&s, "Walls") - 180.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// The planned surface commands are registered but refuse to run: disabled with the
|
||||
/// issue that tracks them, an error (never a panic) from every caller, and the design left
|
||||
/// as it was.
|
||||
#[test]
|
||||
fn planned_commands_refuse_and_leave_the_design_alone() {
|
||||
let planned = [
|
||||
"surface.revolve",
|
||||
"surface.sweep",
|
||||
"surface.loft",
|
||||
"surface.offset",
|
||||
"surface.ruled",
|
||||
"surface.boundary",
|
||||
"surface.blend",
|
||||
"surface.untrim",
|
||||
"surface.unstitch",
|
||||
"surface.reverse_normal",
|
||||
"surface.fillet",
|
||||
"inspect.continuity",
|
||||
];
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
|
||||
let before = s.doc.features.len();
|
||||
for id in planned {
|
||||
let spec = crate::find_command(id).unwrap_or_else(|| panic!("{id} is not registered"));
|
||||
let info = spec.info(&s);
|
||||
assert!(!info.enabled, "{id} must stay disabled until it is built");
|
||||
let why = info.disabled_reason.unwrap_or_default();
|
||||
assert!(why.starts_with("not supported yet (issue #"), "{id}: {why}");
|
||||
assert!(!spec.icon.is_empty() && !spec.params.is_empty(), "{id}: icon and params doc");
|
||||
assert!(s.execute(id, &json!({"body": "B"})).is_err(), "{id} ran");
|
||||
let e = (spec.run)(&mut s, &json!({})).unwrap_err().to_string();
|
||||
assert!(e.starts_with("not supported yet: ") && e.contains("(issue #"), "{id}: {e}");
|
||||
}
|
||||
assert_eq!(s.doc.features.len(), before, "a planned command changed the design");
|
||||
}
|
||||
|
||||
/// A rectangle patched, trimmed by a plane and thickened; a box's open top patched; a rim
|
||||
/// that is not planar patched.
|
||||
#[test]
|
||||
|
||||
@@ -34,6 +34,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("curvature_map")
|
||||
.noundo()
|
||||
.params("radius?: mm shown red (default: a tenth of the model's size); clear?: true turns the analysis off"),
|
||||
planned!("inspect.continuity", "Continuity Analysis", "770")
|
||||
.at("SURFACE", "INSPECT")
|
||||
.icon("continuity")
|
||||
.noundo()
|
||||
.params("planned: body?; edges?: [[x,y,z]] (default: every edge of the body); returns each edge's worst gap (G0), normal angle (G1) and curvature jump (G2); clear?: true turns the analysis off"),
|
||||
];
|
||||
|
||||
fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result<Value> {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Stateless camera commands shared by the navigation cube and programmatic callers.
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_render::{Camera, StandardView};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("view.navigate", "Navigate View", navigate)
|
||||
.icon("orbit")
|
||||
.noundo()
|
||||
.params("camera: Camera; direction?: [x,y,z] in the camera's up-axis frame; or action: left|right|up|down|clockwise|counterclockwise|home|fit → camera, fit")];
|
||||
|
||||
fn navigate(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "view.navigate";
|
||||
let mut cam: Camera =
|
||||
serde_json::from_value(p.get("camera").cloned().ok_or_else(|| bad(cmd, "camera is required"))?).map_err(|_| bad(cmd, "invalid camera"))?;
|
||||
if !cam.is_valid()
|
||||
|| !cam.fov.is_finite()
|
||||
|| !(0.0..std::f64::consts::PI).contains(&cam.fov)
|
||||
|| cam.yaw.abs() > 1e6
|
||||
|| cam.roll.abs() > 1e6
|
||||
|| cam.pitch.abs() > std::f64::consts::FRAC_PI_2
|
||||
{
|
||||
return Err(bad(cmd, "invalid camera"));
|
||||
}
|
||||
let mut fit = false;
|
||||
if let Some(value) = p.get("direction") {
|
||||
let dir = vec3(value)
|
||||
.filter(|v| v.is_finite() && v.len() <= 2.0)
|
||||
.and_then(|v| v.normalized())
|
||||
.ok_or_else(|| bad(cmd, "direction must be a finite nonzero vector of length at most 2"))?;
|
||||
let heading = cam.yaw;
|
||||
cam = cam.looking_from(cam.from_frame(dir));
|
||||
if dir.x.abs() < 1e-9 && dir.y.abs() < 1e-9 {
|
||||
cam.yaw = square_heading(heading);
|
||||
}
|
||||
} else {
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
let (r, u, _) = cam.basis();
|
||||
match p.get("action").and_then(Value::as_str) {
|
||||
Some("left") => cam = cam.looking_from(-r),
|
||||
Some("right") => cam = cam.looking_from(r),
|
||||
Some("up") => cam = cam.looking_from(u),
|
||||
Some("down") => cam = cam.looking_from(-u),
|
||||
Some("clockwise") => cam.roll = (cam.roll + FRAC_PI_2).rem_euclid(std::f64::consts::TAU),
|
||||
Some("counterclockwise") => cam.roll = (cam.roll - FRAC_PI_2).rem_euclid(std::f64::consts::TAU),
|
||||
Some("home") => {
|
||||
cam.set_view(StandardView::Iso);
|
||||
fit = true;
|
||||
}
|
||||
Some("fit") => fit = true,
|
||||
_ => return Err(bad(cmd, "unknown navigation action")),
|
||||
}
|
||||
}
|
||||
Ok(json!({"camera": cam, "fit": fit}))
|
||||
}
|
||||
|
||||
/// At a halfway tie prefer the heading nearer the front plan view.
|
||||
pub(crate) fn square_heading(yaw: f64) -> f64 {
|
||||
if !yaw.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let q = std::f64::consts::FRAC_PI_2;
|
||||
let lo = (yaw / q).floor() * q;
|
||||
let hi = lo + q;
|
||||
if ((yaw - lo) - (hi - yaw)).abs() < 1e-6 {
|
||||
let off_front = |a: f64| ((a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI).abs();
|
||||
if off_front(lo) <= off_front(hi) { lo } else { hi }
|
||||
} else if yaw - lo < hi - yaw {
|
||||
lo
|
||||
} else {
|
||||
hi
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_render::UpAxis;
|
||||
|
||||
#[test]
|
||||
fn every_cube_direction_preserves_framing_without_editing_the_design() {
|
||||
for up in [UpAxis::Z, UpAxis::Y] {
|
||||
let mut s = Session::default();
|
||||
let before = s.doc.to_json();
|
||||
let cam = Camera { up, target: Vec3::new(10.0, 20.0, 30.0), distance: 123.0, ..Default::default() };
|
||||
for x in [-1.0, 0.0, 1.0] {
|
||||
for y in [-1.0, 0.0, 1.0] {
|
||||
for z in [-1.0, 0.0, 1.0] {
|
||||
let Some(dir) = Vec3::new(x, y, z).normalized() else { continue };
|
||||
let result = s.execute("view.navigate", &json!({"camera":cam,"direction":[x,y,z]})).unwrap();
|
||||
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
|
||||
assert!(to.back().dist(cam.from_frame(dir)) < 2e-6);
|
||||
assert_eq!((to.target, to.distance, to.fov, to.up), (cam.target, cam.distance, cam.fov, cam.up));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert!(s.undo.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_roll_and_home_are_programmatic_and_reject_hostile_input() {
|
||||
let mut s = Session::default();
|
||||
let cam = Camera::default();
|
||||
let (r, u, _) = cam.basis();
|
||||
for (action, dir) in [("left", -r), ("right", r), ("up", u), ("down", -u)] {
|
||||
let result = navigate(&mut s, &json!({"camera":cam,"action":action})).unwrap();
|
||||
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
|
||||
assert!(to.back().dist(dir) < 2e-6);
|
||||
}
|
||||
let result = navigate(&mut s, &json!({"camera":cam,"action":"clockwise"})).unwrap();
|
||||
let to: Camera = serde_json::from_value(result["camera"].clone()).unwrap();
|
||||
assert_eq!(to.back(), cam.back());
|
||||
assert!((to.basis().0 - u).len() < 1e-9);
|
||||
for action in ["home", "fit"] {
|
||||
assert_eq!(navigate(&mut s, &json!({"camera":cam,"action":action})).unwrap()["fit"], true);
|
||||
}
|
||||
for params in [
|
||||
json!({}),
|
||||
json!({"camera":cam,"direction":[0,0,0]}),
|
||||
json!({"camera":cam,"direction":[1e308,0,0]}),
|
||||
json!({"camera":cam,"action":"unknown"}),
|
||||
] {
|
||||
assert!(s.execute("view.navigate", ¶ms).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Sizes a command works out from what it was given keep the meaning those inputs had when the
|
||||
//! design's units change (#716): a sphere or a cylinder made with a bare `diameter`, a coil's
|
||||
//! pitch or revolutions from its height, a box placed by its centre, a Press Pull. A radius from
|
||||
//! a bare diameter used to be stored as `(20) / 2`: a unit-less expression that is not a bare
|
||||
//! number is read in the design's units of the moment, so switching a millimetre design to
|
||||
//! inches made such a sphere 25.4 times larger.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
|
||||
/// A body's volume (mm³) and bounding box (min x, y, z, max x, y, z in mm).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Shape {
|
||||
volume: f64,
|
||||
bbox: [f64; 6],
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
fn size(&self, axis: usize) -> f64 {
|
||||
self.bbox[axis + 3] - self.bbox[axis]
|
||||
}
|
||||
fn same(&self, other: &Shape) -> bool {
|
||||
rel(self.volume, other.volume) < 1e-9 && self.bbox.iter().zip(&other.bbox).all(|(a, b)| (a - b).abs() < 1e-7)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every body by name.
|
||||
fn shapes(s: &mut Session) -> Vec<(String, Shape)> {
|
||||
measured(s, json!({}))
|
||||
}
|
||||
|
||||
fn measured(s: &mut Session, p: Value) -> Vec<(String, Shape)> {
|
||||
let m = run(s, "inspect.measure", p);
|
||||
let bodies = m["bodies"].as_array().cloned().unwrap_or_default();
|
||||
bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let c = |k: &str, i: usize| b["bbox"][k][i].as_f64().unwrap_or(f64::NAN);
|
||||
let bbox = [c("min", 0), c("min", 1), c("min", 2), c("max", 0), c("max", 1), c("max", 2)];
|
||||
(b["name"].as_str().unwrap_or_default().to_string(), Shape { volume: b["volume_mm3"].as_f64().unwrap_or(f64::NAN), bbox })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One body, measured alone.
|
||||
fn shape(s: &mut Session, body: &str) -> Shape {
|
||||
measured(s, json!({ "bodies": [body] })).into_iter().next().map(|(_, x)| x).unwrap_or_else(|| panic!("no body {body}"))
|
||||
}
|
||||
|
||||
/// Switch the design to each unit in turn; every body keeps its volume and its bounding box.
|
||||
fn keeps_shape_through(s: &mut Session, units: &[&str]) {
|
||||
let before = shapes(s);
|
||||
assert!(!before.is_empty());
|
||||
for u in units {
|
||||
let r = run(s, "document.units", json!({ "units": u }));
|
||||
let now = shapes(s);
|
||||
assert_eq!(now.len(), before.len(), "{u}: {r}");
|
||||
for ((name, a), (_, b)) in before.iter().zip(&now) {
|
||||
assert!(a.same(b), "{name} after switching to {u} ({r}): {a:?} became {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The expressions of a feature's inputs.
|
||||
fn inputs(s: &Session, feature: usize) -> Vec<String> {
|
||||
s.doc.features.get(feature).map(|f| f.kind.inputs().into_iter().map(|(_, e, _)| e).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_diameters_keep_their_size_when_the_units_change() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.sphere", json!({"diameter": 20, "body_name": "Ball"}));
|
||||
run(&mut s, "solid.cylinder", json!({"diameter": 20, "height": 10, "base": [50, 0, 0], "body_name": "Can"}));
|
||||
let ball = shape(&mut s, "Ball");
|
||||
assert!(rel(ball.volume, 4.0 / 3.0 * PI * 1000.0) < 1e-3, "{ball:?}");
|
||||
// The bounding box comes from the tessellation: a little inside the surface.
|
||||
assert!((ball.size(0) - 20.0).abs() < 1e-3 && (ball.bbox[0] + 10.0).abs() < 1e-3, "{ball:?}");
|
||||
let can = shape(&mut s, "Can");
|
||||
assert!(rel(can.volume, PI * 100.0 * 10.0) < 1e-3, "{can:?}");
|
||||
assert!((can.bbox[0] - 40.0).abs() < 1e-3 && (can.bbox[3] - 60.0).abs() < 1e-3 && (can.size(2) - 10.0).abs() < 1e-6, "{can:?}");
|
||||
keeps_shape_through(&mut s, &["in", "cm", "ft", "mm", "in"]);
|
||||
// The radius says what the diameter was, with the unit it was typed in.
|
||||
assert_eq!(inputs(&s, 0), ["(20 mm) / 2"]);
|
||||
assert_eq!(inputs(&s, 1), ["(20 mm) / 2", "10"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_diameters_typed_in_an_inch_design_mean_inches() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.units", json!({"units": "in"}));
|
||||
run(&mut s, "solid.sphere", json!({"diameter": 2, "body_name": "Ball"}));
|
||||
// The base is a point: its bare numbers are millimetres.
|
||||
run(&mut s, "solid.cylinder", json!({"diameter": 1, "height": 2, "base": [100, 0, 0], "body_name": "Can"}));
|
||||
let ball = shape(&mut s, "Ball");
|
||||
assert!(rel(ball.volume, 4.0 / 3.0 * PI * 25.4f64.powi(3)) < 1e-3, "{ball:?}");
|
||||
assert!((ball.size(0) - 50.8).abs() < 1e-3, "{ball:?}");
|
||||
let can = shape(&mut s, "Can");
|
||||
assert!(rel(can.volume, PI * 12.7 * 12.7 * 50.8) < 1e-3, "{can:?}");
|
||||
assert!((can.bbox[0] - 87.3).abs() < 1e-3 && (can.size(2) - 50.8).abs() < 1e-6, "{can:?}");
|
||||
keeps_shape_through(&mut s, &["mm", "cm", "in"]);
|
||||
}
|
||||
|
||||
/// A body's extent: the diagonal of its vertices' bounding box. Measuring a coil takes seconds;
|
||||
/// this does not.
|
||||
fn extent(s: &Session, body: &str) -> f64 {
|
||||
s.model.state().body(body).map_or(f64::NAN, |b| b.body.size())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coil_pitch_and_revolutions_keep_their_size_when_the_units_change() {
|
||||
use std::f64::consts::TAU;
|
||||
let mut s = Session::default();
|
||||
// Height and revolutions give the pitch.
|
||||
run(&mut s, "solid.coil", json!({"diameter": 40, "revolutions": 2, "height": 16, "section_size": 4, "body_name": "A"}));
|
||||
// Height and pitch give the revolutions: a height with its unit next to a bare pitch, both
|
||||
// millimetres here.
|
||||
run(&mut s, "solid.coil", json!({"diameter": 40, "height": "16 mm", "pitch": 8, "section_size": 4, "base": [100, 0, 0], "body_name": "B"}));
|
||||
let made = shapes(&mut s);
|
||||
let want = PI * 4.0 * TAU * 20.0 * 2.0;
|
||||
for (body, x) in &made {
|
||||
assert!(rel(x.volume, want) < 1e-3, "{body}: {x:?}");
|
||||
assert!((x.size(2) - made[0].1.size(2)).abs() < 1e-6, "{body} is as tall as A: {made:?}");
|
||||
}
|
||||
assert_eq!(inputs(&s, 0)[1], "(16 mm) / (2)", "A's pitch");
|
||||
assert_eq!(inputs(&s, 1)[2], "(16 mm) / (8 mm)", "B's revolutions");
|
||||
let sizes = |s: &Session| [extent(s, "A"), extent(s, "B")];
|
||||
let before = sizes(&s);
|
||||
for u in ["in", "cm", "mm"] {
|
||||
run(&mut s, "document.units", json!({ "units": u }));
|
||||
let now = sizes(&s);
|
||||
assert!(now.iter().zip(&before).all(|(a, b)| rel(*a, *b) < 1e-9), "{u}: {before:?} became {now:?}");
|
||||
}
|
||||
// In an inch design the same bare numbers are inches.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.units", json!({"units": "in"}));
|
||||
run(&mut s, "solid.coil", json!({"diameter": 2, "revolutions": 2, "height": 0.8, "section_size": 0.2, "body_name": "A"}));
|
||||
let a = shape(&mut s, "A");
|
||||
assert!(rel(a.volume, PI * 2.54f64.powi(2) * TAU * 25.4 * 2.0) < 1e-3, "{a:?}");
|
||||
let before = extent(&s, "A");
|
||||
run(&mut s, "document.units", json!({"units": "mm"}));
|
||||
assert!(rel(extent(&s, "A"), before) < 1e-9, "{before} became {}", extent(&s, "A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_placed_by_its_centre_reads_its_sizes_in_the_design_units() {
|
||||
// An inch design: bare sizes are inches, the centre's bare numbers millimetres.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.units", json!({"units": "in"}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 1, "height": 1, "center": [0, 0, 0], "body_name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 1, "height": 1, "center": ["10 mm", 100, 0], "body_name": "B"}));
|
||||
let a = shape(&mut s, "A");
|
||||
assert!((a.bbox[0] + 25.4).abs() < 1e-6 && (a.bbox[3] - 25.4).abs() < 1e-6 && (a.bbox[1] + 12.7).abs() < 1e-6, "{a:?}");
|
||||
let b = shape(&mut s, "B");
|
||||
assert!((b.bbox[0] + 15.4).abs() < 1e-6 && (b.bbox[3] - 35.4).abs() < 1e-6 && (b.bbox[1] - 87.3).abs() < 1e-6, "{b:?}");
|
||||
keeps_shape_through(&mut s, &["mm", "in"]);
|
||||
// A millimetre design: a centre in inches and a bare size in millimetres.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "center": ["0.5 in", 0, 0], "body_name": "C"}));
|
||||
let c = shape(&mut s, "C");
|
||||
assert!((c.bbox[0] - 12.2).abs() < 1e-6 && (c.bbox[3] - 13.2).abs() < 1e-6, "{c:?}");
|
||||
keeps_shape_through(&mut s, &["in", "mm"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn press_pull_keeps_its_distance_when_the_units_change() {
|
||||
// A face pushed in: the cut's depth is the distance's size.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Block"}));
|
||||
run(&mut s, "solid.press_pull", json!({"face": [20, 15, 20], "distance": -5}));
|
||||
assert!(rel(shape(&mut s, "Block").volume, 40.0 * 30.0 * 15.0) < 1e-9);
|
||||
keeps_shape_through(&mut s, &["in", "mm"]);
|
||||
// A fillet's face: its radius grows by the distance.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Block"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 10]], "radius": 3, "name": "Round"}));
|
||||
let corner = |r: f64| (4.0 - PI) * r * r / 4.0 * 20.0;
|
||||
let on = |r: f64| {
|
||||
let d = r - r / 2f64.sqrt();
|
||||
json!([40.0 - d, d, 10.0])
|
||||
};
|
||||
run(&mut s, "solid.press_pull", json!({"face": on(3.0), "distance": 2}));
|
||||
assert!(rel(shape(&mut s, "Block").volume, 24000.0 - corner(5.0)) < 1e-4);
|
||||
keeps_shape_through(&mut s, &["in", "cm", "mm"]);
|
||||
// Pulled again in an inch design: 0.1 more is 2.54 mm more.
|
||||
run(&mut s, "document.units", json!({"units": "in"}));
|
||||
run(&mut s, "solid.press_pull", json!({"face": on(5.0), "distance": 0.1}));
|
||||
assert!(rel(shape(&mut s, "Block").volume, 24000.0 - corner(7.54)) < 1e-4, "{:?}", inputs(&s, 1));
|
||||
keeps_shape_through(&mut s, &["mm", "in"]);
|
||||
}
|
||||
|
||||
/// A diameter that refers to a unit-less parameter is left as typed: the parameter is read in
|
||||
/// the design's units, and when they change it keeps the old one written in, so the part keeps
|
||||
/// its size as it did before.
|
||||
#[test]
|
||||
fn a_diameter_from_a_unitless_parameter_is_left_as_typed() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "w", "expression": "20", "unit": ""}));
|
||||
run(&mut s, "solid.sphere", json!({"diameter": "w", "body_name": "Ball"}));
|
||||
assert_eq!(inputs(&s, 0), ["(w) / 2"]);
|
||||
assert!(rel(shape(&mut s, "Ball").volume, 4.0 / 3.0 * PI * 1000.0) < 1e-3);
|
||||
keeps_shape_through(&mut s, &["in", "mm"]);
|
||||
}
|
||||
|
||||
/// Beside a unit-less expression that is not a bare number, a bare number stays bare, as it
|
||||
/// did: the whole expression is read in the design's units.
|
||||
#[test]
|
||||
fn bare_numbers_beside_a_unitless_expression_stay_bare() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.coil", json!({"diameter": 40, "height": 16, "pitch": "2 * 4", "section_size": 4, "body_name": "A"}));
|
||||
assert_eq!(inputs(&s, 0)[2], "(16) / (2 * 4)", "the revolutions");
|
||||
assert!(s.model.result(s.doc.features[0].id).is_some_and(|r| r.error.is_none()));
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 10, "center": ["2 * 5", 0, 0], "body_name": "B"}));
|
||||
assert_eq!(s.doc.features[1].kind.point_expr().map(|p| p[0].clone()), Some("(2 * 5) - (20) / 2".to_string()));
|
||||
let b = shape(&mut s, "B");
|
||||
assert!(b.bbox[0].abs() < 1e-6 && (b.bbox[3] - 20.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
@@ -63,7 +63,8 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
"plastic.boss" | "plastic.snap_fit" | "plastic.rest" => {
|
||||
&[("position", Pick), ("direction", Dir), ("hook", Dir), ("along", Dir), ("body", Body)]
|
||||
}
|
||||
"plastic.lip" | "sheet.convert" => &[("face", Pick), ("body", Body)],
|
||||
"plastic.lip" => &[("face", Pick), ("body", Body)],
|
||||
"sheet.convert" => &[("face", Pick), ("bends", Picks), ("body", Body)],
|
||||
"sheet.flange" | "sheet.hem" => &[("edges", Picks), ("body", Body)],
|
||||
"sheet.fold" => &[("points", Picks), ("fixed", Pick), ("body", Body)],
|
||||
"sketch.create" | "sketch.redefine" => &[("plane", HerePlane)],
|
||||
|
||||
@@ -803,6 +803,8 @@ mod construct_tests;
|
||||
#[cfg(test)]
|
||||
mod delete_face_tests;
|
||||
#[cfg(test)]
|
||||
mod derived_units_tests;
|
||||
#[cfg(test)]
|
||||
mod display_mesh_tests;
|
||||
#[cfg(test)]
|
||||
mod extent_tests;
|
||||
@@ -823,6 +825,8 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod thread_tests;
|
||||
#[cfg(test)]
|
||||
mod view_bounds_tests;
|
||||
#[cfg(test)]
|
||||
mod world_placement_cache_tests;
|
||||
|
||||
/// Per sketch feature: its parameter-driven dimensions' values (see [`own_values`]).
|
||||
|
||||
@@ -111,6 +111,22 @@ fn a_body_cut_in_pieces_twice_gets_pieces_of_their_own_names() {
|
||||
}
|
||||
|
||||
/// A join that bridges two bodies makes them one body, not an overlap of the first and the second.
|
||||
#[test]
|
||||
fn extrude_a_face_where_another_body_lies_flush_on_it() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 4, "body_name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"corner": [10, 0, 4], "length": 10, "width": 20, "height": 1, "body_name": "B"}));
|
||||
// A's top and B's bottom both lie at the point: which one is meant has to be said.
|
||||
let e = s.execute("solid.extrude", &json!({"face": [15, 5, 4], "distance": "2 mm", "operation": "new"})).unwrap_err().to_string();
|
||||
assert!(e.contains("A") && e.contains("B") && e.contains("face_body"), "{e}");
|
||||
let r = run(&mut s, "solid.extrude", json!({"face": [15, 5, 4], "face_body": "A", "distance": "2 mm", "operation": "new", "body_name": "C"}));
|
||||
let m = run(&mut s, "inspect.measure", json!({"bodies": ["C"]}));
|
||||
let v = m["total"]["volume_mm3"].as_f64().unwrap();
|
||||
assert!((v - 20.0 * 20.0 * 2.0).abs() < 1e-6, "{v} {r}");
|
||||
let e = s.execute("solid.extrude", &json!({"face": [15, 5, 4], "face_body": "Nope", "distance": "2 mm"})).unwrap_err().to_string();
|
||||
assert!(e.contains("Nope"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_bridging_two_bodies_merges_them() {
|
||||
let mut s = Session::default();
|
||||
@@ -1592,6 +1608,12 @@ fn chord_and_variable_fillets() {
|
||||
let removed = (1.0 - PI / 4.0) * 20.0 * (4.0 + 10.0 + 25.0) / 3.0;
|
||||
assert!(rel(v, 6000.0 - removed) < 1e-4, "{v}");
|
||||
assert!(s.execute("solid.fillet", &json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable"})).is_err());
|
||||
// Down to zero at the far end: the round closes to the corner.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "B"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[10, 10, 30]], "radius": 4, "type": "variable", "radius2": 0, "start": [10, 0, 30]}));
|
||||
let v = solidcraft_kernel::measure(&s.world_state().body("B").unwrap().body).unwrap().volume;
|
||||
assert!(rel(v, 6000.0 - (1.0 - PI / 4.0) * 20.0 * 16.0 / 3.0) < 1e-4, "{v}");
|
||||
// A radius through a middle point (a user parameter), straight between: 2 → 4 → 2.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "B"}));
|
||||
@@ -2904,9 +2926,80 @@ fn fillet_fit_reduce_uses_the_largest_radius_that_fits() {
|
||||
// Saved only when set.
|
||||
let json = serde_json::to_string(&s.doc.features).unwrap();
|
||||
assert_eq!(json.matches("\"fit\":\"reduce\"").count(), 1, "{json}");
|
||||
// An unknown mode is refused, and so is reduce with a radius that is not constant.
|
||||
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "taper"})).is_err());
|
||||
// An unknown mode is refused, and so are reduce and taper with a radius that is not constant.
|
||||
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "stretch"})).is_err());
|
||||
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "reduce", "type": "chord"})).is_err());
|
||||
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "taper", "type": "chord"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_fit_taper_follows_the_room_along_the_edge() {
|
||||
// #293: the top front edge of a plate whose top narrows from 20 to `tip` mm along 40 mm.
|
||||
// At a 2 mm tip it can't take 3 mm; taper keeps 3 where it fits and comes down to 0.9 of the
|
||||
// 2 mm there. With an 8 mm tip 3 fits and stays constant; a plate closing to a point
|
||||
// tapers to 0. The warning says what was done.
|
||||
let (k, edge) = (1.0 - PI / 4.0, json!([[20, 0, 15]]));
|
||||
let plate = |tip: f64| {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let pts = if tip > 0.0 { json!([[0, 0], [40, 0], [40, tip], [0, 20]]) } else { json!([[0, 0], [40, 0], [0, 20]]) };
|
||||
run(&mut s, "sketch.line", json!({"points": pts, "closed": true}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 15}));
|
||||
s
|
||||
};
|
||||
let warning = |s: &Session, out: &Value| out["feature"].as_u64().and_then(|id| s.model.result(id)).and_then(|r| r.warning.clone());
|
||||
|
||||
let mut s = plate(2.0);
|
||||
assert!(s.execute("solid.fillet", &json!({"edges": edge, "radius": 3})).is_err());
|
||||
let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 3, "fit": "taper"}));
|
||||
let w = warning(&s, &out).unwrap_or_default();
|
||||
assert!(w.contains("tapers to the room on 1 of 1 edges, down to 1.8 mm"), "{w}");
|
||||
// Between the material a constant 1.8 and a constant 3 would remove.
|
||||
let (v, full) = (volume(&mut s), 40.0 * 22.0 / 2.0 * 15.0);
|
||||
assert!(v < full - k * 1.8 * 1.8 * 40.0 && v > full - k * 9.0 * 40.0, "{v}");
|
||||
let json = serde_json::to_string(&s.doc.features).unwrap();
|
||||
assert_eq!(json.matches("\"fit\":\"taper\"").count(), 1, "{json}");
|
||||
|
||||
let mut s = plate(8.0);
|
||||
let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 3, "fit": "taper"}));
|
||||
assert_eq!(warning(&s, &out), None);
|
||||
assert!(rel(volume(&mut s), 40.0 * 28.0 / 2.0 * 15.0 - k * 9.0 * 40.0) < 2e-4);
|
||||
|
||||
let mut s = plate(0.0);
|
||||
let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 16, "fit": "taper"}));
|
||||
let w = warning(&s, &out).unwrap_or_default();
|
||||
assert!(w.contains("on 1 of 1 edges, down to 0 mm"), "{w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_fillet_takes_over_a_taper() {
|
||||
// A tapered fillet keeps each edge's radius spline; given back as a `spline` fillet it makes
|
||||
// the same part, and its control radii are then the user's to change.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 2], [0, 20]], "closed": true}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 15}));
|
||||
let edge = json!([[20, 0, 15]]);
|
||||
let out = run(&mut s, "solid.fillet", json!({"edges": edge, "radius": 3, "fit": "taper"}));
|
||||
let id = out["feature"].as_u64().unwrap();
|
||||
let tapered = s.model.result(id).and_then(|r| r.state.tapered(id).map(<[_]>::to_vec)).unwrap();
|
||||
let [(_, t)] = &tapered[..] else { panic!("{tapered:?}") };
|
||||
let tapered_volume = volume(&mut s);
|
||||
let radii: Vec<Value> = t.spline.radii.iter().skip(1).map(|r| json!(r)).collect();
|
||||
let spline = json!({"edges": edge, "radius": t.spline.radii[0], "type": "spline", "start": [t.start.x, t.start.y, t.start.z], "knots": t.spline.knots, "radii": radii});
|
||||
run(&mut s, "timeline.redefine", json!({"feature": id, "command": "solid.fillet", "params": spline}));
|
||||
assert!(rel(volume(&mut s), tapered_volume) < 1e-9, "{} vs {tapered_volume}", volume(&mut s));
|
||||
let json = serde_json::to_string(&s.doc.features).unwrap();
|
||||
assert!(json.contains("\"type\":\"spline\""), "{json}");
|
||||
// A control radius raised in the middle: more material goes.
|
||||
let mut raised = spline.clone();
|
||||
raised["radii"][2] = json!("4 mm");
|
||||
run(&mut s, "timeline.redefine", json!({"feature": id, "command": "solid.fillet", "params": raised}));
|
||||
assert!(volume(&mut s) < tapered_volume);
|
||||
// Knots that don't match the radii are refused.
|
||||
let mut bad = spline;
|
||||
bad["knots"] = json!([0, 1]);
|
||||
assert!(s.execute("solid.fillet", &bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -91,3 +91,39 @@ fn exported_binary_stl_with_a_bore_reconstructs_analytic_faces() {
|
||||
assert_eq!(body.faces(0.01).unwrap().len(), 10);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
/// #784: STL is closed as written where faces meet (a fillet, a bored hole): every edge of the
|
||||
/// file, keyed by its exact f32 ends, is shared by exactly two triangles, as in 3MF.
|
||||
#[test]
|
||||
fn stl_export_is_closed_where_faces_meet() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 10, "operation": "new", "body_name": "BoxFillet"}));
|
||||
run(&mut s, "solid.fillet", json!({"body": "BoxFillet", "edges": [[15, 0, 10]], "radius": 2}));
|
||||
run(&mut s, "solid.cylinder", json!({"diameter": 30, "height": 12, "base": [60, 0, 0], "operation": "new", "body_name": "CylHole"}));
|
||||
run(&mut s, "solid.hole", json!({"position": [60, 0, 12], "diameter": 6}));
|
||||
let state = s.model.state();
|
||||
for name in ["BoxFillet", "CylHole"] {
|
||||
let body = &state.bodies.iter().find(|b| b.name == name).unwrap().body;
|
||||
let expected = solidcraft_kernel::measure(body).unwrap().volume;
|
||||
for format in [solidcraft_io::Format::StlBinary, solidcraft_io::Format::StlAscii] {
|
||||
let bytes = solidcraft_io::export(&state, &[name.to_string()], format, name).unwrap();
|
||||
let tris = solidcraft_io::read_stl(&bytes).unwrap();
|
||||
let key = |p: [f32; 3]| p.map(f32::to_bits);
|
||||
let mut edges = std::collections::HashMap::new();
|
||||
for t in &tris {
|
||||
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
|
||||
let (a, b) = (key(a), key(b));
|
||||
*edges.entry(if a < b { (a, b) } else { (b, a) }).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
let open = edges.values().filter(|n| **n != 2).count();
|
||||
assert_eq!(open, 0, "{name} {format:?}: {open} edges not shared by exactly two triangles");
|
||||
let p = |v: [f32; 3]| Vec3::new(v[0] as f64, v[1] as f64, v[2] as f64);
|
||||
let v: f64 = tris.iter().map(|t| p(t[0]).dot(p(t[1]).cross(p(t[2]))) / 6.0).sum();
|
||||
assert!((v - expected).abs() <= expected * 1e-3, "{name} {format:?}: STL volume {v}, body {expected}");
|
||||
let mf = solidcraft_io::export(&state, &[name.to_string()], solidcraft_io::Format::ThreeMf, name).unwrap();
|
||||
let (objs, _) = solidcraft_io::read_3mf(&mf).unwrap();
|
||||
assert_eq!(tris.len(), objs.iter().map(|o| o.triangles.len()).sum::<usize>(), "{name} {format:?}: STL and 3MF triangles");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +202,8 @@ fn fillet_into_an_earlier_round() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean seeds and the original seed 5 edge contact, now rejected without distortion.
|
||||
/// Clean seeds (seed 16: a partial face contact, fused), and the original seed 5 edge contact,
|
||||
/// now rejected without distortion.
|
||||
#[test]
|
||||
fn fixed_seeds_run_clean() {
|
||||
for seed in FIXED_SEEDS {
|
||||
@@ -226,6 +227,27 @@ fn fixed_seeds_run_clean() {
|
||||
assert!(s.model.state().bodies.first().unwrap().body.validity().is_empty());
|
||||
continue;
|
||||
}
|
||||
if *seed == 16 {
|
||||
// Seed 16 patterns a short cylinder around a block: one copy's end lies on a side of
|
||||
// the block with half of it past the block's bottom edge (#674). The end and the side
|
||||
// are split along each other's outline and the half disc they share goes: the block,
|
||||
// half of the first cylinder (the other half is inside the block) and three whole ones.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 25.0, "width": 47.5, "height": 37.5}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.cylinder",
|
||||
json!({"axis": [1.0, 0.0, 0.0], "base": [0.0, 7.5, 0.0], "height": 2.5, "operation": "join", "radius": 5.0}),
|
||||
);
|
||||
run(&mut s, "solid.pattern.circular", json!({"axis": "Z", "count": 4, "features": ["Cylinder1"]}));
|
||||
// (Within the measured round sides' mesh error, about 0.03 mm³ a cylinder.)
|
||||
let want = 25.0 * 47.5 * 37.5 + 3.5 * PI * 25.0 * 2.5;
|
||||
assert!((total_volume(&s) - want).abs() < 0.2, "{} vs {want}", total_volume(&s));
|
||||
assert!(s.model.state().bodies.iter().all(|b| b.body.validity().is_empty()));
|
||||
let r = fuzz::run(*seed, 8);
|
||||
assert!(!r.outcome.is_bug(), "seed {seed}: {:?}\n{}", r.outcome, r.script());
|
||||
continue;
|
||||
}
|
||||
let r = fuzz::run(*seed, 8);
|
||||
assert!(!r.outcome.is_bug(), "seed {seed}: {:?}\n{}", r.outcome, r.script());
|
||||
}
|
||||
|
||||
@@ -39,20 +39,32 @@ pub mod colors {
|
||||
pub const SECTION_CAP: Rgb = solidcraft_render::SECTION_CAP;
|
||||
}
|
||||
|
||||
/// Bounds of everything visible.
|
||||
/// Bounds of everything visible now, in world space: shown bodies where their occurrences,
|
||||
/// joints and moves place them, and the sketches the view draws (not ones used by a feature,
|
||||
/// hidden ones, or ones in hidden components). What Home, Fit and the orbit pivot frame.
|
||||
pub fn bounds(s: &Session) -> Aabb3 {
|
||||
visible_bounds(s, true)
|
||||
}
|
||||
|
||||
/// [`bounds`], with the browser's Sketches folder shown (`sketches`) or hidden.
|
||||
pub fn visible_bounds(s: &Session, sketches: bool) -> Aabb3 {
|
||||
let st = s.world_state();
|
||||
let mut b = Aabb3::EMPTY;
|
||||
let v = &s.visibility;
|
||||
for body in st.bodies.iter().filter(|x| s.body_visible(&x.name)) {
|
||||
b = b.union(&body.mesh().bounds());
|
||||
}
|
||||
for ss in st.sketches.iter().filter(|x| !v.hidden_sketches.contains(&x.feature)) {
|
||||
for ss in st.sketches.iter().filter(|x| sketch_drawn(s, x.feature, sketches)) {
|
||||
// All four corners: on a tilted plane two opposite ones don't span the rectangle.
|
||||
if let Some((lo, hi)) = ss.sketch.bounds() {
|
||||
for p in [lo, hi] {
|
||||
for p in [lo, hi, solidcraft_geom::Vec2::new(lo.x, hi.y), solidcraft_geom::Vec2::new(hi.x, lo.y)] {
|
||||
b.add(ss.plane.to_world(p));
|
||||
}
|
||||
}
|
||||
for w in ss.sketch.wires.iter().filter(|w| !w.fit.is_empty() || !ss.sketch.view.hide_projected) {
|
||||
for p in &w.pts {
|
||||
b.add(*p);
|
||||
}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -95,7 +107,7 @@ pub fn sketch_lines_with_sampler(
|
||||
) -> Vec<(Vec<Vec3>, Rgb, bool)> {
|
||||
let mut out = Vec::new();
|
||||
for (i, c) in sk.curves.iter().enumerate() {
|
||||
if c.link.is_some() && sk.view.hide_projected && !sk.is_text_curve(i) {
|
||||
if !sketch_curve_visible(sk, i) {
|
||||
continue;
|
||||
}
|
||||
let col = if c.link.is_some() && !sk.is_text_curve(i) {
|
||||
@@ -139,6 +151,24 @@ pub fn sketch_lines_with_sampler(
|
||||
out
|
||||
}
|
||||
|
||||
/// Should sketch curve `i` be drawn and picked? The Sketch Palette's two geometry toggles each
|
||||
/// hide one category, and a curve belongs to the category its colour already names in
|
||||
/// [`sketch_lines_with_sampler`]: a linked curve is projected reference geometry (Projected
|
||||
/// Geometries), anything else carrying the `construction` line type is construction geometry
|
||||
/// (Construction Geometries). Text outlines are linked curves that the Text feature draws, so
|
||||
/// they are never reference geometry and always show. Centerlines are their own line type and
|
||||
/// no toggle hides them.
|
||||
///
|
||||
/// Drawing and picking share this one answer so they cannot drift: geometry that is not on
|
||||
/// screen is never clickable, and geometry on screen always is.
|
||||
pub fn sketch_curve_visible(sk: &Sketch, i: usize) -> bool {
|
||||
let Some(c) = sk.curves.get(i) else { return false };
|
||||
if c.link.is_some() && !sk.is_text_curve(i) {
|
||||
return !sk.view.hide_projected;
|
||||
}
|
||||
!(c.construction && sk.view.hide_construction)
|
||||
}
|
||||
|
||||
/// Should sketch point `i` be drawn? Text outlines' points are not (they are not picked or
|
||||
/// constrained individually).
|
||||
pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool {
|
||||
@@ -284,8 +314,38 @@ pub fn section_segments(m: &solidcraft_geom::Mesh, o: Vec3, n: Vec3) -> Vec<(Vec
|
||||
/// Is a sketch drawn? The active one is; others unless hidden. Finished sketches are hidden
|
||||
/// once a feature uses them, like Fusion's default, unless shown one by one.
|
||||
pub fn sketch_shown(s: &Session, id: u64) -> bool {
|
||||
sketch_drawn(s, id, true)
|
||||
}
|
||||
|
||||
/// Is a sketch drawn, with the browser's Sketches folder shown (`folder`) or hidden? The sketch
|
||||
/// being edited always is; otherwise not when hidden, nor in a hidden component, and one used by
|
||||
/// a feature only when shown on its own.
|
||||
pub fn sketch_drawn(s: &Session, id: u64, folder: bool) -> bool {
|
||||
let v = &s.visibility;
|
||||
s.active_sketch == Some(id) || (!v.hidden_sketches.contains(&id) && (v.shown_sketches.contains(&id) || !sketch_consumed(s, id)))
|
||||
if s.active_sketch == Some(id) {
|
||||
return true;
|
||||
}
|
||||
if v.hidden_sketches.contains(&id) || !sketch_component_visible(s, id) {
|
||||
return false;
|
||||
}
|
||||
v.shown_sketches.contains(&id) || (folder && !sketch_consumed(s, id))
|
||||
}
|
||||
|
||||
/// Is the placement a sketch is drawn at shown (the active one while its component is being
|
||||
/// edited, else the component's first)?
|
||||
fn sketch_component_visible(s: &Session, id: u64) -> bool {
|
||||
if s.visibility.hidden_occurrences.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let component = s.doc.feature(id).map_or(0, |f| f.component);
|
||||
if component == 0 {
|
||||
return true;
|
||||
}
|
||||
let path = match &s.active_occurrence_path {
|
||||
Some(p) if s.active_component == component => p.clone(),
|
||||
_ => s.doc.first_path(component),
|
||||
};
|
||||
s.occurrence_visible(&path)
|
||||
}
|
||||
|
||||
/// Is a sketch used by a later feature?
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Home and Fit frame what is visible now: bodies where they are placed (occurrence moves,
|
||||
// joints, motion), sketches only while they are drawn, nothing hidden or consumed.
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the viewport draws: visible bodies' meshes and the sketches it shows, from the scene.
|
||||
fn drawn(s: &Session) -> Aabb3 {
|
||||
let cam = solidcraft_render::Camera { distance: 1e5, ..Default::default() };
|
||||
let scene = crate::view::scene(s, &cam);
|
||||
let mut b = Aabb3::EMPTY;
|
||||
for m in &scene.meshes {
|
||||
b = b.union(&m.mesh.bounds());
|
||||
}
|
||||
use crate::view::colors::{AXIS_X, AXIS_Y, AXIS_Z, GRID, GRID_MAJOR};
|
||||
for l in scene.lines.iter().filter(|l| ![GRID, GRID_MAJOR, AXIS_X, AXIS_Y, AXIS_Z].contains(&l.color)) {
|
||||
for p in &l.points {
|
||||
b.add(*p);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn same(a: Aabb3, b: Aabb3) {
|
||||
assert!(!a.is_empty() && !b.is_empty(), "{a:?} {b:?}");
|
||||
assert!(a.min.dist(b.min) < 1e-6 && a.max.dist(b.max) < 1e-6, "Home frames {a:?}, the view shows {b:?}");
|
||||
}
|
||||
|
||||
fn near(a: Aabb3, lo: [f64; 3], hi: [f64; 3]) {
|
||||
let (lo, hi) = (Vec3::new(lo[0], lo[1], lo[2]), Vec3::new(hi[0], hi[1], hi[2]));
|
||||
assert!(a.min.dist(lo) < 1e-6 && a.max.dist(hi) < 1e-6, "{a:?} != {lo:?}..{hi:?}");
|
||||
}
|
||||
|
||||
/// A 10 x 10 x 5 block drawn as a sketch and extruded, in the active component.
|
||||
fn block(s: &mut Session) {
|
||||
run(s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"distance": 5}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_body_leaves_its_used_sketch_out() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
near(crate::view::bounds(&s), [0., 0., 0.], [10., 10., 5.]);
|
||||
let body = s.world_state().bodies[0].name.clone();
|
||||
run(&mut s, "solid.move", json!({"bodies": [body], "translate": [500, 0, 0]}));
|
||||
near(crate::view::bounds(&s), [500., 0., 0.], [510., 10., 5.]);
|
||||
same(crate::view::bounds(&s), drawn(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_component_frames_where_it_is_now() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "A"}));
|
||||
block(&mut s);
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let b = run(&mut s, "component.create", json!({"name": "B"}))["occurrence"].as_u64().unwrap();
|
||||
block(&mut s);
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": [800, 0, 0]}));
|
||||
near(crate::view::bounds(&s), [0., 0., 0.], [810., 10., 5.]);
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": [-780, 0, 0]}));
|
||||
near(crate::view::bounds(&s), [0., 0., 0.], [30., 10., 5.]);
|
||||
same(crate::view::bounds(&s), drawn(&s));
|
||||
// Pending (not captured) moves too.
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": [0, 300, 0], "capture": false}));
|
||||
near(crate::view::bounds(&s), [0., 0., 0.], [30., 310., 5.]);
|
||||
same(crate::view::bounds(&s), drawn(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_consumed_by_a_join_or_cut_is_not_framed() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [200, 0, 0], "operation": "new"}));
|
||||
let names: Vec<String> = s.world_state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
run(&mut s, "solid.move", json!({"bodies": [names[1].clone()], "translate": [-195, 0, 0]}));
|
||||
run(&mut s, "solid.combine", json!({"target": names[0].clone(), "tools": [names[1].clone()], "operation": "cut"}));
|
||||
near(crate::view::bounds(&s), [0., 0., 0.], [5., 10., 10.]);
|
||||
same(crate::view::bounds(&s), drawn(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_component_is_not_framed() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "A"}));
|
||||
block(&mut s);
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let b = run(&mut s, "component.create", json!({"name": "B"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
// An unused sketch in B, drawn while B is shown.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": [400, 0, 0]}));
|
||||
near(crate::view::bounds(&s), [0., 0., 0.], [440., 40., 5.]);
|
||||
same(crate::view::bounds(&s), drawn(&s));
|
||||
run(&mut s, "browser.visibility", json!({"visible": false, "paths": [[b]]}));
|
||||
near(crate::view::bounds(&s), [0., 0., 0.], [10., 10., 5.]);
|
||||
same(crate::view::bounds(&s), drawn(&s));
|
||||
}
|
||||
|
||||
/// Two leaves joined by a hinge (as in the joint tests).
|
||||
fn hinge(s: &mut Session) {
|
||||
for name in ["LeafA", "LeafB"] {
|
||||
run(s, "component.activate", json!({"component": "root"}));
|
||||
run(s, "component.create", json!({"name": name}));
|
||||
block_leaf(s);
|
||||
}
|
||||
run(s, "component.activate", json!({"component": "root"}));
|
||||
let a = s.doc.occurrence_of(s.doc.find_component("LeafA").unwrap()).unwrap().id;
|
||||
run(s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
|
||||
let b = s.doc.occurrence_of(s.doc.find_component("LeafB").unwrap()).unwrap().id;
|
||||
// B starts far away; the joint brings it onto A.
|
||||
run(s, "occurrence.move", json!({"occurrence": b, "translate": [600, 0, 0]}));
|
||||
run(
|
||||
s,
|
||||
"joint.create",
|
||||
json!({"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [601, 37, 4]}, "flip": true, "name": "Hinge"}),
|
||||
);
|
||||
}
|
||||
|
||||
fn block_leaf(s: &mut Session) {
|
||||
// The plate drawn as a sketch (used by its extrude), and the knuckle.
|
||||
run(s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"distance": 3}));
|
||||
run(s, "solid.cylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joint_and_drive_frame_the_joined_place() {
|
||||
let mut s = Session::default();
|
||||
hinge(&mut s);
|
||||
let b = crate::view::bounds(&s);
|
||||
assert!(b.max.x < 50.0, "the joint brought B onto A: {b:?}");
|
||||
same(b, drawn(&s));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Hinge", "value": 90}));
|
||||
let b = crate::view::bounds(&s);
|
||||
assert!(b.max.x < 50.0 && b.max.z > 30.0, "{b:?}");
|
||||
same(b, drawn(&s));
|
||||
run(&mut s, "motion.study", json!({"name": "Swing", "steps": 10, "keys": [{"joint": "Hinge", "points": [[0, 0], [10, 180]]}]}));
|
||||
run(&mut s, "motion.play", json!({"study": "Swing", "step": 10}));
|
||||
let b = crate::view::bounds(&s);
|
||||
assert!(b.max.x < 50.0 && b.max.y > 60.0, "{b:?}");
|
||||
same(b, drawn(&s));
|
||||
}
|
||||
@@ -71,6 +71,47 @@ fn sampler_keeps_style_precedence_hidden_references_text_and_disjoint_parts() {
|
||||
assert!(sk.link(&projected).is_some() && sk.link(&text).is_some());
|
||||
}
|
||||
|
||||
/// Construction Geometries hides the `construction` line type and nothing else. Centerlines
|
||||
/// are their own line type. A projected curve that carries the construction flag (the trace of
|
||||
/// a plane or an axis) is drawn in the projected colour, so Projected Geometries owns it and
|
||||
/// Construction Geometries leaves it alone. `sketch_curve_visible` is the one answer the GPU
|
||||
/// viewport's picking uses as well, so nothing hidden here can still be clicked there.
|
||||
#[test]
|
||||
fn hiding_construction_geometry_keeps_ordinary_curves_centerlines_and_projected_traces() {
|
||||
let mut sk = Sketch::new();
|
||||
let ordinary = line(&mut sk, 0.);
|
||||
let construction = line(&mut sk, 1.);
|
||||
sk.curves[construction].construction = true;
|
||||
let centerline = line(&mut sk, 2.);
|
||||
sk.curves[centerline].centerline = true;
|
||||
let axis = sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(Vec2::new(0., 3.), Vec2::new(2., 3.))]).unwrap();
|
||||
let trace = sk.link_curves(&axis)[0];
|
||||
sk.curves[trace].construction = true;
|
||||
let determined = vec![false; sk.curves.len()];
|
||||
let drawn = |sk: &Sketch| -> Vec<usize> {
|
||||
let mut seen = Vec::new();
|
||||
sketch_lines_with_sampler(sk, &Plane::XY, true, &determined, |_, i, _| {
|
||||
seen.push(i);
|
||||
vec![vec![Vec3::ZERO, Vec3::X]]
|
||||
});
|
||||
seen
|
||||
};
|
||||
assert_eq!(drawn(&sk), vec![ordinary, construction, centerline, trace]);
|
||||
|
||||
sk.view.hide_construction = true;
|
||||
assert_eq!(drawn(&sk), vec![ordinary, centerline, trace], "only the construction curve goes");
|
||||
for (i, want) in [(ordinary, true), (construction, false), (centerline, true), (trace, true)] {
|
||||
assert_eq!(sketch_curve_visible(&sk, i), want, "curve {i}");
|
||||
}
|
||||
// The projected trace answers to its own toggle, in either order.
|
||||
sk.view.hide_projected = true;
|
||||
assert_eq!(drawn(&sk), vec![ordinary, centerline]);
|
||||
sk.view.hide_construction = false;
|
||||
assert_eq!(drawn(&sk), vec![ordinary, construction, centerline]);
|
||||
// A curve index that does not exist is not visible, and nothing panics asking.
|
||||
assert!(!sketch_curve_visible(&sk, sk.curves.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_paths_match_sampler_wrapper_and_placed_wires_are_unchanged() {
|
||||
let mut sk = Sketch::new();
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
//! Arc fitting: free-form segments (conics, cubic and higher Béziers) as chains of circular
|
||||
//! arcs and lines within a tolerance, tangent-continuous inside each segment (biarcs).
|
||||
//!
|
||||
//! Code that works on lines and arcs alone (planar booleans, the folded sheet-metal solid)
|
||||
//! keeps smooth curves this way with far fewer pieces than a chord polyline of the same
|
||||
//! tolerance: a biarc's error falls with the cube of its length, a chord's with the square.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use crate::{Loop2, Region2, Seg2, Vec2};
|
||||
|
||||
/// Most pieces one segment is fitted with.
|
||||
pub const MAX_FIT_PIECES: usize = 2048;
|
||||
/// Deepest subdivision of one segment.
|
||||
const MAX_DEPTH: u32 = 30;
|
||||
/// Samples inside each candidate span that must lie within the tolerance.
|
||||
const SAMPLES: usize = 12;
|
||||
|
||||
impl Seg2 {
|
||||
/// A straight line or a circular arc (what [`Seg2::fit_arcs`] produces).
|
||||
pub fn is_line_or_arc(&self) -> bool {
|
||||
matches!(self, Seg2::Line { .. } | Seg2::Arc { .. })
|
||||
}
|
||||
|
||||
/// Lines and circular arcs that follow the segment within `tol` (mm), in order, the first
|
||||
/// starting at the segment's start and the last ending at its end; consecutive pieces share
|
||||
/// their end points and tangents. Lines and arcs come back unchanged. `None` when the
|
||||
/// segment is not finite or would need more than [`MAX_FIT_PIECES`] pieces.
|
||||
pub fn fit_arcs(&self, tol: f64) -> Option<Vec<Seg2>> {
|
||||
if self.is_line_or_arc() {
|
||||
return Some(vec![*self]);
|
||||
}
|
||||
if !(tol.is_finite() && tol > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let tol = tol.max(1e-9);
|
||||
let mut out: Vec<Seg2> = Vec::new();
|
||||
// Spans still to fit; the last pushed is fitted first, so pushing the right half before
|
||||
// the left one keeps the output in parameter order.
|
||||
let mut todo = vec![(0.0f64, 1.0f64, 0u32)];
|
||||
let mut steps = 0usize;
|
||||
while let Some((t0, t1, depth)) = todo.pop() {
|
||||
steps += 1;
|
||||
if steps > 4 * MAX_FIT_PIECES {
|
||||
return None;
|
||||
}
|
||||
let (p0, p1) = (self.point_at(t0), self.point_at(t1));
|
||||
if !(p0.is_finite() && p1.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
if p0.dist(p1) < 1e-12 && self.span_extent(t0, t1) < tol {
|
||||
continue;
|
||||
}
|
||||
let fit = match (self.tangent_out(t0, t1), self.tangent_in(t0, t1)) {
|
||||
(Some(d0), Some(d1)) => biarc(p0, d0, p1, d1).filter(|arcs| self.within(arcs, t0, t1, tol)),
|
||||
_ => None,
|
||||
};
|
||||
match fit {
|
||||
Some(arcs) => {
|
||||
out.extend(arcs);
|
||||
if out.len() > MAX_FIT_PIECES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None if depth < MAX_DEPTH => {
|
||||
let tm = 0.5 * (t0 + t1);
|
||||
todo.push((tm, t1, depth + 1));
|
||||
todo.push((t0, tm, depth + 1));
|
||||
}
|
||||
None => {
|
||||
// Too fine to split further (a cusp): the chord is within the tolerance
|
||||
// here or nothing is.
|
||||
let chord = [Seg2::Line { a: p0, b: p1 }];
|
||||
if !self.within(&chord, t0, t1, tol) {
|
||||
return None;
|
||||
}
|
||||
out.push(chord[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The pieces join where their spans meet; pin the outer ends to the segment's own.
|
||||
if let Some(first) = out.first_mut() {
|
||||
*first = with_start(*first, self.start());
|
||||
}
|
||||
if let Some(last) = out.last_mut() {
|
||||
*last = with_end(*last, self.end());
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Unit direction of travel leaving `t` towards `t1`.
|
||||
fn tangent_out(&self, t: f64, t1: f64) -> Option<Vec2> {
|
||||
self.derivative(t).normalized().or_else(|| (self.point_at(t + (t1 - t) * 1e-4) - self.point_at(t)).normalized())
|
||||
}
|
||||
|
||||
/// Unit direction of travel arriving at `t1` from `t0`.
|
||||
fn tangent_in(&self, t0: f64, t1: f64) -> Option<Vec2> {
|
||||
self.derivative(t1).normalized().or_else(|| (self.point_at(t1) - self.point_at(t1 - (t1 - t0) * 1e-4)).normalized())
|
||||
}
|
||||
|
||||
/// Largest distance of the span's sample points from its start (a loop's size).
|
||||
fn span_extent(&self, t0: f64, t1: f64) -> f64 {
|
||||
let p0 = self.point_at(t0);
|
||||
(1..=SAMPLES).map(|i| self.point_at(t0 + (t1 - t0) * i as f64 / (SAMPLES + 1) as f64).dist(p0)).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Do `pieces` follow the span `t0..t1` within `tol` both ways?
|
||||
fn within(&self, pieces: &[Seg2], t0: f64, t1: f64, tol: f64) -> bool {
|
||||
if pieces.iter().any(|p| matches!(p, Seg2::Arc { sweep, .. } if sweep.abs() > PI)) {
|
||||
return false;
|
||||
}
|
||||
let samples: Vec<Vec2> = (0..=SAMPLES + 1).map(|i| self.point_at(t0 + (t1 - t0) * i as f64 / (SAMPLES + 1) as f64)).collect();
|
||||
// The curve near the pieces...
|
||||
if samples.iter().any(|p| pieces.iter().map(|s| dist_to(s, *p)).fold(f64::INFINITY, f64::min) > tol) {
|
||||
return false;
|
||||
}
|
||||
// ...and the pieces near the curve (their quarter points against the curve's closest
|
||||
// point on the span).
|
||||
pieces.iter().all(|s| {
|
||||
[0.25, 0.5, 0.75].iter().all(|f| {
|
||||
let q = s.point_at(*f);
|
||||
self.dist_on_span(q, t0, t1, &samples) <= tol
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Distance from `q` to the span `t0..t1` (closest sample, then Newton steps).
|
||||
fn dist_on_span(&self, q: Vec2, t0: f64, t1: f64, samples: &[Vec2]) -> f64 {
|
||||
let n = samples.len().saturating_sub(1).max(1);
|
||||
let (k, mut best) = samples.iter().enumerate().map(|(i, p)| (i, p.dist(q))).fold((0, f64::INFINITY), |a, b| if b.1 < a.1 { b } else { a });
|
||||
let mut t = t0 + (t1 - t0) * k as f64 / n as f64;
|
||||
for _ in 0..12 {
|
||||
let (p, d) = (self.point_at(t), self.derivative(t));
|
||||
let dd = d.dot(d);
|
||||
if !(dd > 1e-300) {
|
||||
break;
|
||||
}
|
||||
let nt = (t - (p - q).dot(d) / dd).clamp(t0, t1);
|
||||
best = best.min(self.point_at(nt).dist(q));
|
||||
if (nt - t).abs() < 1e-15 {
|
||||
break;
|
||||
}
|
||||
t = nt;
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
impl Loop2 {
|
||||
/// The loop with every free-form segment fitted by lines and arcs (see
|
||||
/// [`Seg2::fit_arcs`]). `None` when a segment cannot be fitted.
|
||||
pub fn fit_arcs(&self, tol: f64) -> Option<Loop2> {
|
||||
let mut segs = Vec::with_capacity(self.segs.len());
|
||||
for s in &self.segs {
|
||||
segs.extend(s.fit_arcs(tol)?);
|
||||
if segs.len() > MAX_FIT_PIECES * 8 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Loop2 { segs })
|
||||
}
|
||||
|
||||
/// Only lines and arcs?
|
||||
pub fn is_line_arc(&self) -> bool {
|
||||
self.segs.iter().all(Seg2::is_line_or_arc)
|
||||
}
|
||||
}
|
||||
|
||||
impl Region2 {
|
||||
/// Only lines and arcs?
|
||||
pub fn is_line_arc(&self) -> bool {
|
||||
self.outer.is_line_arc() && self.holes.iter().all(Loop2::is_line_arc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance from `p` to a line or arc segment (other segments: to their closest point).
|
||||
fn dist_to(s: &Seg2, p: Vec2) -> f64 {
|
||||
match *s {
|
||||
Seg2::Line { a, b } => {
|
||||
let d = b - a;
|
||||
let l2 = d.len2();
|
||||
let t = if l2 > 1e-300 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(a + d * t).dist(p)
|
||||
}
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
let ang = (p - center).angle();
|
||||
let delta = if sweep >= 0.0 { (ang - start).rem_euclid(TAU) } else { (start - ang).rem_euclid(TAU) };
|
||||
if delta <= sweep.abs() { (p.dist(center) - radius).abs() } else { p.dist(s.start()).min(p.dist(s.end())) }
|
||||
}
|
||||
_ => s.point_at(s.closest_param(p)).dist(p),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two arcs (or lines) from `p0` leaving along unit `d0` to `p1` arriving along unit `d1`,
|
||||
/// meeting tangentially: the tangent lines from both ends are cut at one length `d`, and the
|
||||
/// arcs join at the middle of the line between the two cut points (which is `2 d` long).
|
||||
fn biarc(p0: Vec2, d0: Vec2, p1: Vec2, d1: Vec2) -> Option<Vec<Seg2>> {
|
||||
let v = p1 - p0;
|
||||
let vv = v.dot(v);
|
||||
if vv < 1e-24 {
|
||||
return None;
|
||||
}
|
||||
let len = vv.sqrt();
|
||||
let chord = v / len;
|
||||
if d0.cross(chord).abs() < 1e-12 && d1.cross(chord).abs() < 1e-12 && d0.dot(chord) > 0.0 && d1.dot(chord) > 0.0 {
|
||||
return Some(vec![Seg2::Line { a: p0, b: p1 }]);
|
||||
}
|
||||
// |v − d (d0 + d1)| = 2 d, the positive root.
|
||||
let t = d0 + d1;
|
||||
let vt = v.dot(t);
|
||||
let a = 2.0 * (1.0 - d0.dot(d1));
|
||||
let d = if a < 1e-12 {
|
||||
if vt <= 1e-12 * len {
|
||||
return None;
|
||||
}
|
||||
vv / (2.0 * vt)
|
||||
} else {
|
||||
(-vt + (vt * vt + a * vv).max(0.0).sqrt()) / a
|
||||
};
|
||||
if !(d.is_finite() && d > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let j = ((p0 + d0 * d) + (p1 - d1 * d)) * 0.5;
|
||||
let mut out = Vec::with_capacity(2);
|
||||
if j.dist(p0) > 1e-12 {
|
||||
out.push(arc_from(p0, d0, j)?);
|
||||
}
|
||||
if j.dist(p1) > 1e-12 {
|
||||
out.push(arc_from(p1, -d1, j)?.reversed());
|
||||
}
|
||||
(!out.is_empty()).then_some(out)
|
||||
}
|
||||
|
||||
/// The arc leaving `p` along unit `d` that ends at `q` (a line when `q` lies straight ahead).
|
||||
fn arc_from(p: Vec2, d: Vec2, q: Vec2) -> Option<Seg2> {
|
||||
let c = q - p;
|
||||
let cc = c.dot(c);
|
||||
let n = d.perp();
|
||||
let h = n.dot(c);
|
||||
// Sagitta of the arc over the chord: a line when the bulge is negligible.
|
||||
if h.abs() * cc.sqrt() < 1e-12 * cc || (cc / (2.0 * h)).abs() > 1e7 {
|
||||
return (d.dot(c) > 0.0).then_some(Seg2::Line { a: p, b: q });
|
||||
}
|
||||
let r = cc / (2.0 * h);
|
||||
let center = p + n * r;
|
||||
let start = (p - center).angle();
|
||||
let mut sweep = (q - center).angle() - start;
|
||||
if r > 0.0 {
|
||||
while sweep <= 0.0 {
|
||||
sweep += TAU;
|
||||
}
|
||||
} else {
|
||||
while sweep >= 0.0 {
|
||||
sweep -= TAU;
|
||||
}
|
||||
}
|
||||
(sweep.is_finite() && sweep.abs() <= TAU).then_some(Seg2::Arc { center, radius: r.abs(), start, sweep })
|
||||
}
|
||||
|
||||
/// `s` moved to start exactly at `p` (an arc keeps its centre; `p` is within a rounding error).
|
||||
fn with_start(s: Seg2, p: Vec2) -> Seg2 {
|
||||
match s {
|
||||
Seg2::Line { b, .. } => Seg2::Line { a: p, b },
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` moved to end exactly at `p`.
|
||||
fn with_end(s: Seg2, p: Vec2) -> Seg2 {
|
||||
match s {
|
||||
Seg2::Line { a, .. } => Seg2::Line { a, b: p },
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Largest distance of 401 points of `seg` from `pieces`: 30 times as many as the fit
|
||||
/// itself looks at in a span.
|
||||
fn max_dev(seg: &Seg2, pieces: &[Seg2]) -> f64 {
|
||||
(0..=400)
|
||||
.map(|i| seg.point_at(i as f64 / 400.0))
|
||||
.map(|p| pieces.iter().map(|s| dist_to(s, p)).fold(f64::INFINITY, f64::min))
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
fn chained(seg: &Seg2, pieces: &[Seg2]) {
|
||||
assert!(pieces.first().unwrap().start().dist(seg.start()) < 1e-9);
|
||||
assert!(pieces.last().unwrap().end().dist(seg.end()) < 1e-9);
|
||||
for w in pieces.windows(2) {
|
||||
assert!(w[0].end().dist(w[1].start()) < 1e-9, "{w:?}");
|
||||
// Tangent-continuous inside the segment (biarc joints and span joints).
|
||||
assert!(w[0].end_tangent().dot(w[1].start_tangent()) > 1.0 - 1e-6, "{w:?}");
|
||||
}
|
||||
assert!(pieces.iter().all(Seg2::is_line_or_arc));
|
||||
}
|
||||
|
||||
fn ellipse(c: Vec2, a: f64, b: f64) -> Loop2 {
|
||||
let corners = [c + Vec2::new(a, 0.0), c + Vec2::new(0.0, b), c - Vec2::new(a, 0.0), c - Vec2::new(0.0, b)];
|
||||
Loop2 {
|
||||
segs: (0..4)
|
||||
.map(|i| Seg2::Conic { a: corners[i], apex: corners[i] + corners[(i + 1) % 4] - c, b: corners[(i + 1) % 4], w: FRAC_1_SQRT_2 })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_circular_arc_given_as_a_conic_comes_back_as_arcs_of_its_circle() {
|
||||
// A quarter circle of radius 10 about (5, −3): the biarc through its ends and end
|
||||
// tangents lies on the circle itself.
|
||||
let c = Vec2::new(5.0, -3.0);
|
||||
let q = Seg2::Conic { a: c + Vec2::new(10.0, 0.0), apex: c + Vec2::new(10.0, 10.0), b: c + Vec2::new(0.0, 10.0), w: FRAC_1_SQRT_2 };
|
||||
let arcs = q.fit_arcs(1e-6).unwrap();
|
||||
assert_eq!(arcs.len(), 2, "{arcs:?}");
|
||||
let mut total = 0.0;
|
||||
for s in &arcs {
|
||||
let Seg2::Arc { center, radius, sweep, .. } = *s else { panic!("{s:?}") };
|
||||
assert!(center.dist(c) < 1e-9 && (radius - 10.0).abs() < 1e-9, "{s:?}");
|
||||
total += sweep;
|
||||
}
|
||||
assert!((total - FRAC_PI_2).abs() < 1e-9, "{total}");
|
||||
chained(&q, &arcs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarter_ellipse_fits_within_the_tolerance_with_few_arcs() {
|
||||
let q = ellipse(Vec2::new(200.0, 150.0), 30.0, 12.0).segs[0];
|
||||
for tol in [1e-2, 1e-3, 1e-4] {
|
||||
let arcs = q.fit_arcs(tol).unwrap();
|
||||
chained(&q, &arcs);
|
||||
assert!(max_dev(&q, &arcs) <= tol * 1.01, "tol {tol}: {}", max_dev(&q, &arcs));
|
||||
// Far fewer pieces than a chord polyline of the same tolerance.
|
||||
let chords = q.polyline(tol).len() - 1;
|
||||
assert!(arcs.len() * 2 < chords, "tol {tol}: {} arcs vs {chords} chords", arcs.len());
|
||||
}
|
||||
// The chords of 0.001 mm: √(2·√(30² + 12²) / 0.008) = 89.9, so 90.
|
||||
assert_eq!(q.polyline(1e-3).len() - 1, 90);
|
||||
assert!(q.fit_arcs(1e-3).unwrap().len() <= 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_like_quadratics_cubics_and_s_curves_fit() {
|
||||
let segs = [
|
||||
Seg2::Conic { a: Vec2::new(0.0, 0.0), apex: Vec2::new(1.0, 2.0), b: Vec2::new(3.0, 0.5), w: 1.0 },
|
||||
Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(1.0, 3.0), p2: Vec2::new(2.0, -3.0), p3: Vec2::new(3.0, 0.0) },
|
||||
// A cusp-like cubic and one with a coincident control point.
|
||||
Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(4.0, 2.0), p2: Vec2::new(-1.0, 2.0), p3: Vec2::new(3.0, 0.0) },
|
||||
Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(0.0, 0.0), p2: Vec2::new(2.0, 1.0), p3: Vec2::new(3.0, 0.0) },
|
||||
Seg2::Conic { a: Vec2::new(0.0, 0.0), apex: Vec2::new(5.0, 5.0), b: Vec2::new(10.0, 0.0), w: 3.0 },
|
||||
crate::bezier(&[Vec2::new(0.0, 0.0), Vec2::new(1.0, 2.0), Vec2::new(2.0, -1.0), Vec2::new(3.0, 2.0), Vec2::new(4.0, 0.0)]).unwrap(),
|
||||
];
|
||||
for s in segs {
|
||||
let arcs = s.fit_arcs(1e-3).unwrap();
|
||||
assert!(max_dev(&s, &arcs) <= 1.01e-3, "{s:?}: {}", max_dev(&s, &arcs));
|
||||
assert!(arcs.first().unwrap().start().dist(s.start()) < 1e-9 && arcs.last().unwrap().end().dist(s.end()) < 1e-9);
|
||||
for w in arcs.windows(2) {
|
||||
assert!(w[0].end().dist(w[1].start()) < 1e-9);
|
||||
}
|
||||
}
|
||||
// A straight "curve" is one line; lines and arcs come back as they are.
|
||||
let flat = Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(1.0, 0.0), p2: Vec2::new(2.0, 0.0), p3: Vec2::new(3.0, 0.0) };
|
||||
assert_eq!(flat.fit_arcs(1e-3).unwrap(), vec![Seg2::Line { a: Vec2::ZERO, b: Vec2::new(3.0, 0.0) }]);
|
||||
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 1.0 };
|
||||
assert_eq!(arc.fit_arcs(1e-3).unwrap(), vec![arc]);
|
||||
// Hostile input.
|
||||
let bad = Seg2::Conic { a: Vec2::new(f64::NAN, 0.0), apex: Vec2::ZERO, b: Vec2::new(1.0, 0.0), w: 1.0 };
|
||||
assert!(bad.fit_arcs(1e-3).is_none());
|
||||
assert!(segs[0].fit_arcs(0.0).is_none() && segs[0].fit_arcs(f64::NAN).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitted_ellipse_loop_keeps_its_area() {
|
||||
let lp = ellipse(Vec2::ZERO, 15.0, 8.0);
|
||||
let fitted = lp.fit_arcs(1e-3).unwrap();
|
||||
assert!(fitted.is_closed(1e-9) && fitted.is_line_arc() && !lp.is_line_arc());
|
||||
// π·a·b, within the tolerance times the perimeter (74 mm).
|
||||
let exact = PI * 15.0 * 8.0;
|
||||
assert!((lp.signed_area() - exact).abs() < 1e-9, "{}", lp.signed_area());
|
||||
assert!((fitted.signed_area() - exact).abs() < 1e-3 * 74.0, "{} vs {exact}", fitted.signed_area());
|
||||
assert!(fitted.segs.len() * 2 < lp.polyline(1e-3).len(), "{} pieces", fitted.segs.len());
|
||||
// As a hole of a 40 × 20 plate.
|
||||
let plate = Loop2::polygon(&[Vec2::new(-20.0, -10.0), Vec2::new(20.0, -10.0), Vec2::new(20.0, 10.0), Vec2::new(-20.0, 10.0)]);
|
||||
let (free, arcs) = (Region2 { outer: plate.clone(), holes: vec![lp.reversed()] }, Region2 { outer: plate, holes: vec![fitted.reversed()] });
|
||||
assert!(arcs.is_line_arc() && !free.is_line_arc());
|
||||
assert!((free.area() - (800.0 - exact)).abs() < 1e-9 && (arcs.area() - (800.0 - exact)).abs() < 1e-3 * 74.0, "{}", arcs.area());
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod arcfit;
|
||||
pub mod brep;
|
||||
pub mod bspline;
|
||||
pub mod env;
|
||||
@@ -15,9 +16,11 @@ pub mod fill;
|
||||
mod mesh;
|
||||
mod plane;
|
||||
mod profile;
|
||||
pub mod tess;
|
||||
pub mod tspline;
|
||||
mod vec;
|
||||
|
||||
pub use arcfit::MAX_FIT_PIECES;
|
||||
pub use mesh::{Aabb3, InsideIndex, Mesh, MeshMeasure};
|
||||
pub use plane::Plane;
|
||||
pub use profile::{Loop2, MAX_BEZIER_DEGREE, Region2, Seg2, bezier};
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
//! Display tessellation plans: the parameter-space triangulation of every face of a body,
|
||||
//! with the surfaces to evaluate it on, kept apart from the evaluation itself.
|
||||
//!
|
||||
//! The kernel decides *where* the vertices go (in each face's (u, v) parameters, with the
|
||||
//! shared edge nodes fixed in 3D so neighbouring faces meet exactly) and *how* they are
|
||||
//! connected. Evaluating the surfaces at those parameters is the bulk of the work and has no
|
||||
//! branching on topology, so it can run anywhere: [`TessPlan::evaluate`] does it on the CPU,
|
||||
//! and the GPU evaluator runs the same maths in a compute shader. Both evaluate the
|
||||
//! [`EvalSurface`] forms defined here, so the two cannot drift apart.
|
||||
//!
|
||||
//! References: Piegl & Tiller, *The NURBS Book* (2nd ed.), A2.1 (span), A2.3 (basis
|
||||
//! functions and derivatives), §4.5 (derivatives of a rational surface).
|
||||
|
||||
use crate::bspline::MAX_SPLINE_DEGREE;
|
||||
use crate::{Mesh, Vec3};
|
||||
|
||||
/// Most knots in one evaluable knot vector (bounds the shader's span search).
|
||||
pub const MAX_EVAL_KNOTS: usize = 1 << 12;
|
||||
/// Most control points in one evaluable surface.
|
||||
pub const MAX_EVAL_POINTS: usize = 1 << 18;
|
||||
/// Basis slots for the highest evaluable degree.
|
||||
const W: usize = MAX_SPLINE_DEGREE + 1;
|
||||
|
||||
/// A rational B-spline curve: homogeneous control points `[x·w, y·w, z·w, w]`. Outside its
|
||||
/// domain it continues as the polynomial of the end span (a line extends for ever).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EvalCurve {
|
||||
pub degree: usize,
|
||||
pub knots: Vec<f64>,
|
||||
pub ctrl: Vec<[f64; 4]>,
|
||||
}
|
||||
|
||||
/// A surface in a form both evaluators understand.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum EvalSurface {
|
||||
/// `origin + u·du + v·dv`.
|
||||
Plane { origin: Vec3, du: Vec3, dv: Vec3 },
|
||||
/// A rational tensor B-spline; control point `(i, j)` is `ctrl[j·count[0] + i]`.
|
||||
Nurbs { degree: [usize; 2], knots: [Vec<f64>; 2], count: [usize; 2], ctrl: Vec<[f64; 4]> },
|
||||
/// `profile(u)` turned by the angle `v` (right-handed) about the axis through `origin`
|
||||
/// along the unit `axis`, then mapped by the affine `transform` (row-major 3×4); with
|
||||
/// `swap` the parameters are exchanged first (the surface is evaluated at `(v, u)`).
|
||||
Revolved { profile: EvalCurve, origin: Vec3, axis: Vec3, transform: [[f64; 4]; 3], swap: bool },
|
||||
}
|
||||
|
||||
/// One face's share of a [`TessPlan`].
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FacePlan {
|
||||
/// The B-rep face (index in the body's face order).
|
||||
pub face: u32,
|
||||
/// Index into [`TessPlan::surfaces`]; `None` when every position and normal is given.
|
||||
pub surface: Option<u32>,
|
||||
/// Parameters of every vertex.
|
||||
pub uv: Vec<[f64; 2]>,
|
||||
/// Positions of the first `fixed.len()` vertices: the shared edge nodes, used exactly as
|
||||
/// they are so the neighbouring face gets bit-identical boundary points.
|
||||
pub fixed: Vec<Vec3>,
|
||||
/// Normals of every vertex, only for faces without an evaluable surface.
|
||||
pub normals: Vec<Vec3>,
|
||||
/// The face is reversed against its surface: normals point the other way.
|
||||
pub flip: bool,
|
||||
/// Counter-clockwise seen from outside (orientation already applied).
|
||||
pub triangles: Vec<[u32; 3]>,
|
||||
}
|
||||
|
||||
/// A body's display tessellation, ready to evaluate.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TessPlan {
|
||||
pub surfaces: Vec<EvalSurface>,
|
||||
pub faces: Vec<FacePlan>,
|
||||
/// B-rep edges as polylines (the shared edge nodes), in the body's edge order.
|
||||
pub edges: Vec<Vec<Vec3>>,
|
||||
/// Per edge: the faces it bounds.
|
||||
pub edge_faces: Vec<Vec<u32>>,
|
||||
}
|
||||
|
||||
impl TessPlan {
|
||||
/// Evaluate on the CPU into an indexed mesh (vertices are shared within a face, not
|
||||
/// across faces, so creases keep their own normals).
|
||||
pub fn evaluate(&self) -> Mesh {
|
||||
let mut out = Mesh::default();
|
||||
for f in &self.faces {
|
||||
let base = out.positions.len();
|
||||
let surface = f.surface.and_then(|i| self.surfaces.get(i as usize));
|
||||
for (k, uv) in f.uv.iter().enumerate() {
|
||||
let (p, n) = match surface {
|
||||
Some(s) => s.eval(uv[0], uv[1]),
|
||||
None => (Vec3::ZERO, f.normals.get(k).copied().unwrap_or(Vec3::Z)),
|
||||
};
|
||||
let p = f.fixed.get(k).copied().unwrap_or(p);
|
||||
out.positions.push(p);
|
||||
out.normals.push(if f.flip { -n } else { n });
|
||||
}
|
||||
let n = f.uv.len();
|
||||
for t in &f.triangles {
|
||||
if t.iter().all(|&i| (i as usize) < n) {
|
||||
let at = |i: u32| u32::try_from(base + i as usize).unwrap_or(u32::MAX);
|
||||
out.triangles.push([at(t[0]), at(t[1]), at(t[2])]);
|
||||
out.tri_face.push(f.face);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.edges = self.edges.clone();
|
||||
out.edge_faces = self.edge_faces.clone();
|
||||
out
|
||||
}
|
||||
|
||||
/// Vertices across all faces.
|
||||
pub fn vertex_count(&self) -> usize {
|
||||
self.faces.iter().map(|f| f.uv.len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
impl EvalCurve {
|
||||
/// Point and first derivative at `t`; `None` if the curve is malformed or its weight
|
||||
/// vanishes there.
|
||||
pub fn eval(&self, t: f64) -> Option<(Vec3, Vec3)> {
|
||||
let p = self.degree;
|
||||
let n = self.ctrl.len();
|
||||
if p == 0 || p > MAX_SPLINE_DEGREE || n <= p || self.knots.len() != n + p + 1 || !t.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let span = span_of(&self.knots, p, n, t);
|
||||
let ders = basis_ders(&self.knots, span, p, t)?;
|
||||
let (mut a, mut da) = ([0.0; 4], [0.0; 4]);
|
||||
for (k, (n0, n1)) in ders[0].iter().zip(&ders[1]).take(p + 1).enumerate() {
|
||||
let c = self.ctrl.get(span + k - p)?;
|
||||
for d in 0..4 {
|
||||
a[d] += n0 * c[d];
|
||||
da[d] += n1 * c[d];
|
||||
}
|
||||
}
|
||||
rational(a, da)
|
||||
}
|
||||
}
|
||||
|
||||
impl EvalSurface {
|
||||
/// Point and unit normal (`S_u × S_v`, oriented as the surface) at `(u, v)`. A degenerate
|
||||
/// point (a cone's apex, a sphere's pole) takes its normal from a step inside; anything
|
||||
/// that still fails gives the origin and +Z rather than a NaN.
|
||||
pub fn eval(&self, u: f64, v: f64) -> (Vec3, Vec3) {
|
||||
let Some((p, du, dv)) = self.derivs(u, v) else { return (Vec3::ZERO, Vec3::Z) };
|
||||
let n = self.oriented(du.cross(dv));
|
||||
if let Some(n) = n.normalized().filter(|_| n.len2() > 1e-20 * (du.len2() * dv.len2()).max(1e-300)) {
|
||||
return (p, n);
|
||||
}
|
||||
// Step into the parameter domain around the degenerate point.
|
||||
let h = 1e-6 * (1.0 + u.abs().max(v.abs()));
|
||||
for (su, sv) in [(1.0, 1.0), (-1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)] {
|
||||
if let Some((_, a, b)) = self.derivs(u + su * h, v + sv * h)
|
||||
&& let Some(n) = self.oriented(a.cross(b)).normalized()
|
||||
{
|
||||
return (p, n);
|
||||
}
|
||||
}
|
||||
(p, Vec3::Z)
|
||||
}
|
||||
|
||||
/// Mirror transforms reverse the cross product of the mapped derivatives.
|
||||
fn oriented(&self, n: Vec3) -> Vec3 {
|
||||
match self {
|
||||
EvalSurface::Revolved { transform: m, .. } if det3(m) < 0.0 => -n,
|
||||
_ => n,
|
||||
}
|
||||
}
|
||||
|
||||
/// Point and first partial derivatives.
|
||||
pub fn derivs(&self, u: f64, v: f64) -> Option<(Vec3, Vec3, Vec3)> {
|
||||
if !(u.is_finite() && v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
match self {
|
||||
EvalSurface::Plane { origin, du, dv } => Some((*origin + *du * u + *dv * v, *du, *dv)),
|
||||
EvalSurface::Nurbs { degree, knots, count, ctrl } => nurbs_derivs(*degree, knots, *count, ctrl, u, v),
|
||||
EvalSurface::Revolved { profile, origin, axis, transform, swap } => {
|
||||
let (s, a) = if *swap { (v, u) } else { (u, v) };
|
||||
let (c, dc) = profile.eval(s)?;
|
||||
let (sin, cos) = a.sin_cos();
|
||||
let rot = |x: Vec3| x * cos + axis.cross(x) * sin + *axis * (axis.dot(x) * (1.0 - cos));
|
||||
let r = c - *origin;
|
||||
let p = *origin + rot(r);
|
||||
let ds = rot(dc);
|
||||
// d/dθ of the rotated point: axis × (rotated radius).
|
||||
let da = axis.cross(rot(r));
|
||||
let (p, ds, da) = (affine(transform, p), linear(transform, ds), linear(transform, da));
|
||||
Some(if *swap { (p, da, ds) } else { (p, ds, da) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn affine(m: &[[f64; 4]; 3], p: Vec3) -> Vec3 {
|
||||
linear(m, p) + Vec3::new(m[0][3], m[1][3], m[2][3])
|
||||
}
|
||||
|
||||
fn linear(m: &[[f64; 4]; 3], v: Vec3) -> Vec3 {
|
||||
let row = |r: &[f64; 4]| r[0] * v.x + r[1] * v.y + r[2] * v.z;
|
||||
Vec3::new(row(&m[0]), row(&m[1]), row(&m[2]))
|
||||
}
|
||||
|
||||
fn det3(m: &[[f64; 4]; 3]) -> f64 {
|
||||
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
}
|
||||
|
||||
/// The point and derivative of a rational function from its homogeneous value and derivative.
|
||||
fn rational(a: [f64; 4], da: [f64; 4]) -> Option<(Vec3, Vec3)> {
|
||||
let w = a[3];
|
||||
if !(w.abs() > 1e-300) {
|
||||
return None;
|
||||
}
|
||||
let p = Vec3::new(a[0] / w, a[1] / w, a[2] / w);
|
||||
let d = (Vec3::new(da[0], da[1], da[2]) - p * da[3]) * (1.0 / w);
|
||||
(p.is_finite() && d.is_finite()).then_some((p, d))
|
||||
}
|
||||
|
||||
fn nurbs_derivs(degree: [usize; 2], knots: &[Vec<f64>; 2], count: [usize; 2], ctrl: &[[f64; 4]], u: f64, v: f64) -> Option<(Vec3, Vec3, Vec3)> {
|
||||
let [pu, pv] = degree;
|
||||
let [nu, nv] = count;
|
||||
let ok = |p: usize, n: usize, k: &[f64]| (1..=MAX_SPLINE_DEGREE).contains(&p) && n > p && k.len() == n + p + 1;
|
||||
if !ok(pu, nu, &knots[0]) || !ok(pv, nv, &knots[1]) || nu.checked_mul(nv)? != ctrl.len() {
|
||||
return None;
|
||||
}
|
||||
let (su, sv) = (span_of(&knots[0], pu, nu, u), span_of(&knots[1], pv, nv, v));
|
||||
let (bu, bv) = (basis_ders(&knots[0], su, pu, u)?, basis_ders(&knots[1], sv, pv, v)?);
|
||||
let (mut s, mut s_u, mut s_v) = ([0.0; 4], [0.0; 4], [0.0; 4]);
|
||||
for (b, (nv0, nv1)) in bv[0].iter().zip(&bv[1]).take(pv + 1).enumerate() {
|
||||
for (a, (nu0, nu1)) in bu[0].iter().zip(&bu[1]).take(pu + 1).enumerate() {
|
||||
let c = ctrl.get((sv + b - pv) * nu + su + a - pu)?;
|
||||
for d in 0..4 {
|
||||
s[d] += nu0 * nv0 * c[d];
|
||||
s_u[d] += nu1 * nv0 * c[d];
|
||||
s_v[d] += nu0 * nv1 * c[d];
|
||||
}
|
||||
}
|
||||
}
|
||||
let (p, du) = rational(s, s_u)?;
|
||||
let (_, dv) = rational(s, s_v)?;
|
||||
Some((p, du, dv))
|
||||
}
|
||||
|
||||
/// The knot span used at `t`: the last non-empty span starting at or before `t`, clamped to
|
||||
/// the valid spans `p ≤ k < n` so parameters just outside the domain extend the end spans.
|
||||
fn span_of(knots: &[f64], p: usize, n: usize, t: f64) -> usize {
|
||||
let mut k = p;
|
||||
for i in p..n {
|
||||
if let (Some(&a), Some(&b)) = (knots.get(i), knots.get(i + 1))
|
||||
&& a < b
|
||||
{
|
||||
if a <= t {
|
||||
k = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// Non-zero basis functions of degree `p` on span `span` and their first derivatives
|
||||
/// (NURBS Book A2.3 with n = 1). `ders[0][j]` is `N_{span−p+j}`, `ders[1][j]` its derivative.
|
||||
fn basis_ders(knots: &[f64], span: usize, p: usize, t: f64) -> Option<[[f64; W]; 2]> {
|
||||
if p == 0 || p >= W {
|
||||
return None;
|
||||
}
|
||||
let kn = |i: usize| knots.get(i).copied();
|
||||
// ndu[j][r]: basis values (upper triangle incl. diagonal), knot differences (lower).
|
||||
let mut ndu = [[0.0f64; W]; W];
|
||||
let (mut left, mut right) = ([0.0f64; W], [0.0f64; W]);
|
||||
ndu[0][0] = 1.0;
|
||||
for j in 1..=p {
|
||||
left[j] = t - kn((span + 1).checked_sub(j)?)?;
|
||||
right[j] = kn(span + j)? - t;
|
||||
let mut saved = 0.0;
|
||||
for r in 0..j {
|
||||
ndu[j][r] = right[r + 1] + left[j - r];
|
||||
let temp = if ndu[j][r] != 0.0 { ndu[r][j - 1] / ndu[j][r] } else { 0.0 };
|
||||
ndu[r][j] = saved + right[r + 1] * temp;
|
||||
saved = left[j - r] * temp;
|
||||
}
|
||||
ndu[j][j] = saved;
|
||||
}
|
||||
let mut ders = [[0.0f64; W]; 2];
|
||||
for j in 0..=p {
|
||||
ders[0][j] = ndu[j][p];
|
||||
}
|
||||
// First derivative: p·(N_{r,p−1}/(u_{r+p}−u_r) − N_{r+1,p−1}/(u_{r+p+1}−u_{r+1})).
|
||||
for r in 0..=p {
|
||||
let mut d = 0.0;
|
||||
if r >= 1 {
|
||||
let den = ndu[p][r - 1];
|
||||
if den != 0.0 {
|
||||
d += ndu[r - 1][p - 1] / den;
|
||||
}
|
||||
}
|
||||
if r < p {
|
||||
let den = ndu[p][r];
|
||||
if den != 0.0 {
|
||||
d -= ndu[r][p - 1] / den;
|
||||
}
|
||||
}
|
||||
ders[1][r] = d * p as f64;
|
||||
}
|
||||
Some(ders)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: Vec3, b: Vec3, tol: f64) -> bool {
|
||||
a.dist(b) <= tol
|
||||
}
|
||||
|
||||
fn line(a: Vec3, b: Vec3) -> EvalCurve {
|
||||
EvalCurve { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], ctrl: vec![[a.x, a.y, a.z, 1.0], [b.x, b.y, b.z, 1.0]] }
|
||||
}
|
||||
|
||||
const IDENTITY: [[f64; 4]; 3] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
|
||||
|
||||
#[test]
|
||||
fn plane_point_and_normal() {
|
||||
let s = EvalSurface::Plane { origin: Vec3::new(1.0, 2.0, 3.0), du: Vec3::X, dv: Vec3::Y };
|
||||
let (p, n) = s.eval(2.0, 3.0);
|
||||
assert!(close(p, Vec3::new(3.0, 5.0, 3.0), 1e-12));
|
||||
assert!(close(n, Vec3::Z, 1e-12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolved_line_is_a_cylinder() {
|
||||
// A vertical line at radius 2 turned about Z.
|
||||
let s = EvalSurface::Revolved {
|
||||
profile: line(Vec3::new(2.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 5.0)),
|
||||
origin: Vec3::ZERO,
|
||||
axis: Vec3::Z,
|
||||
transform: IDENTITY,
|
||||
swap: false,
|
||||
};
|
||||
for &(u, v) in &[(0.0, 0.0), (0.5, 1.0), (1.0, 4.0), (0.25, 6.0)] {
|
||||
let (p, n) = s.eval(u, v);
|
||||
assert!(close(p, Vec3::new(2.0 * v.cos(), 2.0 * v.sin(), 5.0 * u), 1e-12), "{p:?}");
|
||||
// S_u = +Z, S_v = tangential: the normal points inward (−radial) for this profile.
|
||||
assert!(close(n, Vec3::new(-v.cos(), -v.sin(), 0.0), 1e-12), "{n:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapped_parameters_reverse_the_normal() {
|
||||
let mk = |swap| EvalSurface::Revolved {
|
||||
profile: line(Vec3::new(2.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 5.0)),
|
||||
origin: Vec3::ZERO,
|
||||
axis: Vec3::Z,
|
||||
transform: IDENTITY,
|
||||
swap,
|
||||
};
|
||||
let (p0, n0) = mk(false).eval(0.3, 1.1);
|
||||
let (p1, n1) = mk(true).eval(1.1, 0.3);
|
||||
assert!(close(p0, p1, 1e-12));
|
||||
assert!(close(n0, -n1, 1e-12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_transform_keeps_the_normal_outward() {
|
||||
let mirror = [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
|
||||
let s = EvalSurface::Revolved {
|
||||
profile: line(Vec3::new(2.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 5.0)),
|
||||
origin: Vec3::ZERO,
|
||||
axis: Vec3::Z,
|
||||
transform: mirror,
|
||||
swap: false,
|
||||
};
|
||||
let (p, n) = s.eval(0.5, 0.0);
|
||||
assert!(close(p, Vec3::new(-2.0, 0.0, 2.5), 1e-12));
|
||||
// The unmirrored normal at θ = 0 is −X; mirrored it is +X, still pointing to the axis.
|
||||
assert!(close(n, Vec3::X, 1e-12), "{n:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rational_quarter_circle_profile_is_exact() {
|
||||
// A quarter circle of radius 3 in the XZ plane, turned about Z: a sphere zone.
|
||||
let w = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let profile = EvalCurve {
|
||||
degree: 2,
|
||||
knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
ctrl: vec![[3.0, 0.0, 0.0, 1.0], [3.0 * w, 0.0, 3.0 * w, w], [0.0, 0.0, 3.0, 1.0]],
|
||||
};
|
||||
let s = EvalSurface::Revolved { profile, origin: Vec3::ZERO, axis: Vec3::Z, transform: IDENTITY, swap: false };
|
||||
for i in 0..=10 {
|
||||
for j in 0..=6 {
|
||||
let (u, v) = (i as f64 / 10.0, j as f64);
|
||||
let (p, n) = s.eval(u, v);
|
||||
assert!((p.len() - 3.0).abs() < 1e-12, "{p:?}");
|
||||
// At the pole (u = 1) the normal comes from a step inside: within ~1e-6 rad.
|
||||
assert!(n.cross(p).len() < 3e-5, "normal {n:?} not radial at {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bilinear_nurbs_patch_and_derivatives() {
|
||||
// Degree (1, 1) over [0, 2] × [0, 1] with corners lifted: a hyperbolic paraboloid.
|
||||
let ctrl = vec![[0.0, 0.0, 0.0, 1.0], [2.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [2.0, 1.0, 1.0, 1.0]];
|
||||
let s = EvalSurface::Nurbs { degree: [1, 1], knots: [vec![0.0, 0.0, 2.0, 2.0], vec![0.0, 0.0, 1.0, 1.0]], count: [2, 2], ctrl };
|
||||
let Some((p, du, dv)) = s.derivs(1.0, 0.5) else { panic!("evaluates") };
|
||||
assert!(close(p, Vec3::new(1.0, 0.5, 0.25), 1e-12), "{p:?}");
|
||||
assert!(close(du, Vec3::new(1.0, 0.0, 0.25), 1e-12), "{du:?}");
|
||||
assert!(close(dv, Vec3::new(0.0, 1.0, 0.5), 1e-12), "{dv:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubic_nurbs_matches_the_geom_surface() {
|
||||
// Cross-check the evaluator against `bspline::NurbsSurface::eval` on a weighted patch.
|
||||
let knots = [vec![0.0, 0.0, 0.0, 0.0, 0.4, 1.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 0.0, 1.0, 2.0, 2.0, 2.0]];
|
||||
let (nu, nv) = (5, 4);
|
||||
let mut pts = Vec::new();
|
||||
let mut wts = Vec::new();
|
||||
for j in 0..nv {
|
||||
for i in 0..nu {
|
||||
pts.push(Vec3::new(i as f64, j as f64, ((i * 7 + j * 3) % 5) as f64 * 0.3));
|
||||
wts.push(1.0 + ((i + 2 * j) % 3) as f64 * 0.25);
|
||||
}
|
||||
}
|
||||
let Ok(reference) = crate::bspline::NurbsSurface::new([3, 2], knots.clone(), &pts, &wts) else { panic!("valid surface") };
|
||||
let ctrl = reference.homogeneous().iter().map(|h| [h.x, h.y, h.z, h.w]).collect();
|
||||
let s = EvalSurface::Nurbs { degree: [3, 2], knots, count: [nu, nv], ctrl };
|
||||
for i in 0..=8 {
|
||||
for j in 0..=8 {
|
||||
let (u, v) = (i as f64 / 8.0, j as f64 / 4.0);
|
||||
let Ok(want) = reference.eval(u, v) else { panic!("in domain") };
|
||||
let Some((p, du, dv)) = s.derivs(u, v) else { panic!("evaluates") };
|
||||
assert!(close(p, want, 1e-12), "({u}, {v}): {p:?} vs {want:?}");
|
||||
// Derivatives against central differences (one-sided at the domain ends).
|
||||
let h = 1e-6;
|
||||
let fd = |a: (f64, f64), b: (f64, f64)| {
|
||||
let (Some(pa), Some(pb)) = (s.derivs(a.0, a.1), s.derivs(b.0, b.1)) else { panic!("evaluates") };
|
||||
(pb.0 - pa.0) * (1.0 / (b.0 - a.0 + b.1 - a.1))
|
||||
};
|
||||
let fu = fd((u.max(h) - h, v), ((u + h).min(1.0), v));
|
||||
let fv = fd((u, v.max(h) - h), (u, (v + h).min(2.0)));
|
||||
assert!(close(du, fu, 1e-4 * (1.0 + du.len())), "du at ({u}, {v}): {du:?} vs {fu:?}");
|
||||
assert!(close(dv, fv, 1e-4 * (1.0 + dv.len())), "dv at ({u}, {v}): {dv:?} vs {fv:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_surfaces_do_not_panic() {
|
||||
let bad = [
|
||||
EvalSurface::Nurbs { degree: [9, 1], knots: [vec![0.0; 3], vec![0.0; 3]], count: [1, 1], ctrl: vec![] },
|
||||
EvalSurface::Nurbs {
|
||||
degree: [1, 1],
|
||||
knots: [vec![0.0, 0.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]],
|
||||
count: [2, 2],
|
||||
ctrl: vec![[0.0; 4]; 4],
|
||||
},
|
||||
EvalSurface::Revolved {
|
||||
profile: EvalCurve { degree: 0, knots: vec![], ctrl: vec![] },
|
||||
origin: Vec3::ZERO,
|
||||
axis: Vec3::Z,
|
||||
transform: IDENTITY,
|
||||
swap: false,
|
||||
},
|
||||
];
|
||||
for s in &bad {
|
||||
for &(u, v) in &[(0.5, 0.5), (f64::NAN, 0.0), (1e300, -1e300)] {
|
||||
let (p, n) = s.eval(u, v);
|
||||
assert!(p.is_finite() && n.is_finite());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_uses_fixed_points_and_drops_bad_indices() {
|
||||
let plan = TessPlan {
|
||||
surfaces: vec![EvalSurface::Plane { origin: Vec3::ZERO, du: Vec3::X, dv: Vec3::Y }],
|
||||
faces: vec![FacePlan {
|
||||
face: 3,
|
||||
surface: Some(0),
|
||||
uv: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
|
||||
fixed: vec![Vec3::new(0.0, 0.0, 1e-9)],
|
||||
normals: vec![],
|
||||
flip: true,
|
||||
triangles: vec![[0, 1, 2], [0, 1, 7]],
|
||||
}],
|
||||
edges: vec![],
|
||||
edge_faces: vec![],
|
||||
};
|
||||
let m = plan.evaluate();
|
||||
assert_eq!(m.positions.len(), 3);
|
||||
assert_eq!(m.positions.first().copied(), Some(Vec3::new(0.0, 0.0, 1e-9)));
|
||||
assert_eq!(m.triangles, vec![[0, 1, 2]]);
|
||||
assert_eq!(m.tri_face, vec![3]);
|
||||
assert!(m.normals.iter().all(|n| close(*n, -Vec3::Z, 1e-12)));
|
||||
}
|
||||
}
|
||||
+21
-7
@@ -222,6 +222,11 @@ pub fn export_mesh(b: &solidcraft_kernel::Body) -> Result<Mesh> {
|
||||
Ok(b.tessellate((b.size() * 2e-4).max(1e-3))?)
|
||||
}
|
||||
|
||||
/// Distance within which an exported mesh's vertices are welded (STL and 3MF alike).
|
||||
fn weld_eps(b: &solidcraft_kernel::Body) -> f64 {
|
||||
(b.size() * 1e-6).max(1e-7)
|
||||
}
|
||||
|
||||
/// Binary STL of several meshes (one solid).
|
||||
pub fn stl_binary(meshes: &[Mesh], name: &str) -> Vec<u8> {
|
||||
let n: usize = meshes.iter().map(|m| m.triangles.len()).sum();
|
||||
@@ -292,13 +297,22 @@ pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str)
|
||||
return Err(IoError::Empty);
|
||||
}
|
||||
Ok(match format {
|
||||
Format::StlBinary | Format::StlAscii | Format::Obj => {
|
||||
Format::StlBinary | Format::StlAscii => {
|
||||
// Each body welded on its own (as for 3MF), so shared vertices are written identically
|
||||
// and the file is closed where faces meet.
|
||||
let meshes: Vec<Mesh> = sel
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let m = export_mesh(&b.body)?;
|
||||
let (positions, triangles) = weld(&m.positions, &m.triangles, weld_eps(&b.body));
|
||||
Ok(Mesh { positions, triangles, ..Mesh::default() })
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
if format == Format::StlBinary { stl_binary(&meshes, name) } else { stl_ascii(&meshes, name).into_bytes() }
|
||||
}
|
||||
Format::Obj => {
|
||||
let meshes: Vec<(String, Mesh)> = sel.iter().map(|b| Ok((b.name.clone(), export_mesh(&b.body)?))).collect::<Result<_>>()?;
|
||||
match format {
|
||||
Format::StlBinary => stl_binary(&meshes.into_iter().map(|(_, m)| m).collect::<Vec<_>>(), name),
|
||||
Format::StlAscii => stl_ascii(&meshes.into_iter().map(|(_, m)| m).collect::<Vec<_>>(), name).into_bytes(),
|
||||
_ => obj(&meshes).into_bytes(),
|
||||
}
|
||||
obj(&meshes).into_bytes()
|
||||
}
|
||||
Format::Step => {
|
||||
let bs: Vec<solidcraft_kernel::ExportBody> =
|
||||
@@ -317,7 +331,7 @@ pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str)
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let m = export_mesh(&b.body)?;
|
||||
let eps = (b.body.size() * 1e-6).max(1e-7);
|
||||
let eps = weld_eps(&b.body);
|
||||
// Faces with colours of their own tag their triangles.
|
||||
let paint = b.body.paint();
|
||||
let mut looks = Vec::new();
|
||||
|
||||
@@ -109,7 +109,7 @@ pub fn weld_tagged(positions: &[Vec3], triangles: &[[u32; 3]], tags: &[u32], eps
|
||||
'search: for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
for &i in grid.get(&[c[0] + dx, c[1] + dy, c[2] + dz]).into_iter().flatten() {
|
||||
for &i in grid.get(&[c[0].saturating_add(dx), c[1].saturating_add(dy), c[2].saturating_add(dz)]).into_iter().flatten() {
|
||||
if verts.get(i as usize).is_some_and(|q| q.dist(*p) <= eps) {
|
||||
found = Some(i);
|
||||
break 'search;
|
||||
@@ -798,4 +798,15 @@ mod tests {
|
||||
assert!(objs.len() < 4_000, "the shared budget must stop the later items: {} built", objs.len());
|
||||
assert!(warnings.iter().any(|w| w.contains("too many triangles")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// A body far from the origin (a pattern spacing of "1e12 mm" gets there) welds without
|
||||
/// overflowing the grid's cell indices.
|
||||
#[test]
|
||||
fn weld_survives_extreme_coordinates() {
|
||||
let o = Vec3::new(1e12, 0.0, 0.0);
|
||||
let positions = [o, o + Vec3::new(0.1, 0.0, 0.0), o + Vec3::new(0.0, 0.1, 0.0), o + Vec3::new(0.0, 0.0, 0.1)];
|
||||
let triangles = [[0, 2, 1], [0, 1, 3], [1, 2, 3], [2, 0, 3]];
|
||||
let (v, t) = weld(&positions, &triangles, 1e-7);
|
||||
assert_eq!((v.len(), t.len()), (4, 4));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,7 @@ truck-shapeops = { workspace = true }
|
||||
truck-meshalgo = { workspace = true }
|
||||
truck-stepio = { workspace = true }
|
||||
truck-topology = { workspace = true }
|
||||
spade = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rayon = { workspace = true }
|
||||
|
||||
@@ -823,6 +823,11 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
// that earlier blends left between selected edges, so corners round off), a single edge,
|
||||
// or a chain of curved edges.
|
||||
let mut todo: Vec<Vec3> = edges.to_vec();
|
||||
// Edges refused since the last blend that could have changed them: (pick, the box around the
|
||||
// edge, the refusal). A blend only changes the body near the edges it rounds, so an edge
|
||||
// refused away from every later blend would be refused the same way again: it is not tried
|
||||
// again (a refusal costs a rolling-ball attempt, seconds each on a long curved edge).
|
||||
let mut refused: Vec<(Vec3, (Vec3, Vec3), KernelError)> = Vec::new();
|
||||
for _ in 0..(2 * edges.len() + 4) {
|
||||
if todo.is_empty() {
|
||||
return Ok(cur);
|
||||
@@ -835,13 +840,21 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
&& let Some(Ok((res, rec))) = crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round)
|
||||
&& let Ok(b) = guard(what, || Ok(res)).and_then(|res| record::rebuilt(res, &[&solid], &rec))
|
||||
{
|
||||
let near = edges_box(&solid, &ids, r + tol);
|
||||
refused.retain(|(_, b, _)| !boxes_meet(*b, near));
|
||||
cur = record::after(b, &cur, &counts);
|
||||
todo.retain(|p| !covered.iter().any(|q| q.dist(*p) < 1e-12));
|
||||
continue;
|
||||
}
|
||||
let mut first_err: Option<KernelError> = None;
|
||||
let mut done: Option<(Body, Vec<usize>)> = None;
|
||||
let mut done: Option<(Body, Vec<usize>, (Vec3, Vec3))> = None;
|
||||
for (j, p) in todo.iter().enumerate() {
|
||||
if let Some((_, _, e)) = refused.iter().find(|(q, _, _)| q.dist(*p) < 1e-12) {
|
||||
if first_err.is_none() || (matches!(e, KernelError::TooLarge(_)) && !matches!(first_err, Some(KernelError::TooLarge(_)))) {
|
||||
first_err = Some(e.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some((idx, dist)) = cur.nearest_edge(*p, tol)? else { return Err(KernelError::Invalid("the body has no edges".into())) };
|
||||
if dist > size * 0.05 + 1e-3 {
|
||||
return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z])));
|
||||
@@ -861,6 +874,8 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
#[cfg(test)]
|
||||
ROLLS.with(|n| n.set(n.get() + 1));
|
||||
guard(what, || crate::curveblend::curve_blend(&solid, ids, size, r, shape == Shape::Round))
|
||||
.map(|o| (o, if on_chain.is_empty() { vec![j] } else { on_chain }))
|
||||
};
|
||||
@@ -888,22 +903,26 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
},
|
||||
},
|
||||
};
|
||||
// What this attempt could change: the edge and its smooth chain, with room for the blend.
|
||||
let reach = edges_box(&solid, chain.as_deref().unwrap_or(&[edge.id()]), r + tol);
|
||||
match attempt.and_then(|((o, rec), js)| record::rebuilt(o, &[&solid], &rec).map(|b| (b, js))) {
|
||||
Ok(x) => {
|
||||
done = Some(x);
|
||||
Ok((b, js)) => {
|
||||
done = Some((b, js, reach));
|
||||
break;
|
||||
}
|
||||
// Reported: the first refusal, unless a later one is a size a smaller radius may
|
||||
// fit (the others may be refused only until that edge is blended).
|
||||
Err(e) => {
|
||||
if first_err.is_none() || (matches!(e, KernelError::TooLarge(_)) && !matches!(first_err, Some(KernelError::TooLarge(_)))) {
|
||||
first_err = Some(e);
|
||||
first_err = Some(e.clone());
|
||||
}
|
||||
refused.push((*p, reach, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
match done {
|
||||
Some((b, js)) => {
|
||||
Some((b, js, near)) => {
|
||||
refused.retain(|(_, b2, _)| !boxes_meet(*b2, near));
|
||||
cur = record::after(b, &cur, &counts);
|
||||
let mut k = 0;
|
||||
todo.retain(|_| {
|
||||
@@ -918,6 +937,36 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
if todo.is_empty() { Ok(cur) } else { Err(unsupported("blending these edges together")) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Rolling-ball attempts made by [`blend`] on this thread (tests count them).
|
||||
pub(crate) static ROLLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The box around these edges of `solid` (their ends and middles), grown by `margin`.
|
||||
fn edges_box(solid: &Solid, ids: &[mt::EdgeID], margin: f64) -> (Vec3, Vec3) {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
|
||||
let mut hi = -lo;
|
||||
for e in Body::unique_edges(solid).iter().filter(|e| ids.contains(&e.id())) {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
for k in 0..=8 {
|
||||
let q = from_p3(c.subs(t0 + (t1 - t0) * f64::from(k) / 8.0));
|
||||
lo = lo.min(q);
|
||||
hi = hi.max(q);
|
||||
}
|
||||
}
|
||||
let m = Vec3::new(margin, margin, margin);
|
||||
(lo - m, hi + m)
|
||||
}
|
||||
|
||||
/// Whether two boxes overlap (an empty box meets every box: nothing is known about it).
|
||||
fn boxes_meet(a: (Vec3, Vec3), b: (Vec3, Vec3)) -> bool {
|
||||
let empty = |x: (Vec3, Vec3)| !(x.0.x <= x.1.x && x.0.y <= x.1.y && x.0.z <= x.1.z);
|
||||
empty(a) || empty(b) || (a.0.x <= b.1.x && b.0.x <= a.1.x && a.0.y <= b.1.y && b.0.y <= a.1.y && a.0.z <= b.1.z && b.0.z <= a.1.z)
|
||||
}
|
||||
|
||||
/// Neighbouring edges along one boundary loop of a planar face that between them hold at least
|
||||
/// two of the points' edges, joined by arcs that meet them smoothly (left by earlier blends):
|
||||
/// the run's edge ids and the points it covers.
|
||||
|
||||
+520
-166
@@ -5,14 +5,15 @@
|
||||
//! touch one more planar face square to the edge (the configuration of `blend`). Every section
|
||||
//! square to the edge is a circular arc tangent to both faces, its radius running linearly from
|
||||
//! one end to the other; the arcs' control points move linearly, so the blend is exactly a
|
||||
//! rational B-spline of degree 2 across and 1 along.
|
||||
//! rational B-spline of degree 2 across and 1 along. The radius may run down to zero at one end:
|
||||
//! there the blend closes to the edge's end point, a three-sided face, and the end face is kept.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::blend::{plane_normal, rebuild_face, unsupported, vtx};
|
||||
use crate::blend::{plane_normal, rebuild_face, too_large, unsupported, vtx};
|
||||
use crate::body::{Body, Solid, p3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
@@ -59,23 +60,25 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64
|
||||
fillet_variable_points(body, edge_at, start, &[(0.0, r0), (1.0, r1)], false)
|
||||
}
|
||||
|
||||
/// The radius along the edge as a B-spline of the edge parameter (0 at the edge's front):
|
||||
/// degree, knots and control radii.
|
||||
struct RadiusLaw {
|
||||
deg: usize,
|
||||
knots: Vec<f64>,
|
||||
radii: Vec<f64>,
|
||||
/// A fillet radius along an edge: a clamped B-spline of the position along it (0 at one end, 1
|
||||
/// at the other), given by its degree, knots and control radii. The radius is a smooth curve
|
||||
/// shaped by its control radii, each pulling it toward itself near its Greville position.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RadiusSpline {
|
||||
pub degree: usize,
|
||||
pub knots: Vec<f64>,
|
||||
pub radii: Vec<f64>,
|
||||
}
|
||||
|
||||
impl RadiusLaw {
|
||||
impl RadiusSpline {
|
||||
/// Through (position, radius) points sorted by position from 0 to 1: straight between them,
|
||||
/// or `smooth` (cubic pieces with Catmull-Rom slopes, C¹ through every point).
|
||||
fn through(pts: &[(f64, f64)], smooth: bool) -> RadiusLaw {
|
||||
pub fn through(pts: &[(f64, f64)], smooth: bool) -> RadiusSpline {
|
||||
if !smooth || pts.len() < 3 {
|
||||
let mut knots = vec![0.0];
|
||||
knots.extend(pts.iter().map(|p| p.0));
|
||||
knots.push(1.0);
|
||||
return RadiusLaw { deg: 1, knots, radii: pts.iter().map(|p| p.1).collect() };
|
||||
return RadiusSpline { degree: 1, knots, radii: pts.iter().map(|p| p.1).collect() };
|
||||
}
|
||||
let n = pts.len();
|
||||
let at = |i: usize| pts.get(i).copied().unwrap_or((0.0, 0.0));
|
||||
@@ -91,37 +94,65 @@ impl RadiusLaw {
|
||||
radii.extend([r0 + slope(i) * h / 3.0, r1 - slope(i + 1) * h / 3.0, r1]);
|
||||
knots.extend(if i + 2 == n { vec![1.0; 4] } else { vec![t1; 3] });
|
||||
}
|
||||
RadiusLaw { deg: 3, knots, radii }
|
||||
RadiusSpline { degree: 3, knots, radii }
|
||||
}
|
||||
|
||||
/// Greville abscissae: where each control radius sits along the edge.
|
||||
fn greville(&self) -> Vec<f64> {
|
||||
(0..self.radii.len()).map(|j| self.knots.iter().skip(j + 1).take(self.deg).sum::<f64>() / self.deg as f64).collect()
|
||||
pub fn greville(&self) -> Vec<f64> {
|
||||
(0..self.radii.len()).map(|j| self.knots.iter().skip(j + 1).take(self.degree).sum::<f64>() / self.degree as f64).collect()
|
||||
}
|
||||
|
||||
/// The radius at `t` (sampled for checks).
|
||||
fn eval(&self, t: f64) -> f64 {
|
||||
/// The radius at position `t`.
|
||||
pub fn eval(&self, t: f64) -> f64 {
|
||||
let kv = mt::KnotVec::from(self.knots.clone());
|
||||
let b = kv.try_bspline_basis_functions(self.deg, t.clamp(0.0, 1.0)).unwrap_or_default();
|
||||
let b = kv.try_bspline_basis_functions(self.degree, t.clamp(0.0, 1.0)).unwrap_or_default();
|
||||
b.iter().zip(&self.radii).map(|(x, r)| x * r).sum()
|
||||
}
|
||||
|
||||
/// The same radius with its positions taken from the other end.
|
||||
pub fn reversed(&self) -> RadiusSpline {
|
||||
RadiusSpline {
|
||||
degree: self.degree,
|
||||
knots: self.knots.iter().rev().map(|k| 1.0 - k).collect(),
|
||||
radii: self.radii.iter().rev().copied().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refused unless it is a clamped B-spline from 0 to 1 of degree 1 to 3 with 2 to 100 control
|
||||
/// radii, positive along the edge and zero at most at one end.
|
||||
fn check(&self) -> Result<()> {
|
||||
let (n, deg) = (self.radii.len(), self.degree);
|
||||
if !(1..=3).contains(°) || !(2..=100).contains(&n) || n <= deg || self.knots.len() != n + deg + 1 {
|
||||
return Err(KernelError::Invalid("a radius spline has degree 1…3, 2…100 control radii and as many knots as both and one more".into()));
|
||||
}
|
||||
let clamped = self.knots.iter().take(deg + 1).all(|k| *k == 0.0) && self.knots.iter().skip(n).all(|k| *k == 1.0);
|
||||
if !clamped || !self.knots.iter().all(|k| k.is_finite()) || self.knots.windows(2).any(|w| w[1] < w[0]) {
|
||||
return Err(KernelError::Invalid("radius spline knots rise from 0 to 1, repeated degree + 1 times at each end".into()));
|
||||
}
|
||||
if self.radii.iter().any(|r| !(r.is_finite() && (*r == 0.0 || *r > 1e-6) && *r < 1e6)) {
|
||||
return Err(KernelError::Invalid("fillet radii must be positive (zero only at an end)".into()));
|
||||
}
|
||||
let zeros: Vec<usize> = (0..n).filter(|i| self.radii.get(*i) == Some(&0.0)).collect();
|
||||
if !matches!(zeros[..], [] | [0]) && zeros[..] != [n - 1] {
|
||||
return Err(KernelError::Invalid("the radius may be zero at one end of the edge only".into()));
|
||||
}
|
||||
// Positive inside the edge (a zero end is given exactly, as a control radius).
|
||||
if (1..200).any(|k| self.eval(k as f64 / 200.0) <= 1e-6) {
|
||||
return Err(KernelError::Invalid("the radius must stay positive along the edge".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fillet one straight edge with a radius that varies along it: (position, radius) points with
|
||||
/// positions 0 (the end nearest `start`) to 1 (the other end) and any between, the radius
|
||||
/// straight between them or `smooth`.
|
||||
/// straight between them or `smooth`. One end's radius may be zero.
|
||||
pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &[(f64, f64)], smooth: bool) -> Result<Body> {
|
||||
body.require_brep("fillet")?;
|
||||
if points.len() < 2 || points.len() > 100 {
|
||||
return Err(KernelError::Invalid("a variable radius needs 2…100 points".into()));
|
||||
}
|
||||
for (t, r) in points {
|
||||
if !(r.is_finite() && *r > 1e-6 && *r < 1e6) {
|
||||
return Err(KernelError::Invalid("fillet radii must be positive".into()));
|
||||
}
|
||||
if !(t.is_finite() && (0.0..=1.0).contains(t)) {
|
||||
return Err(KernelError::Invalid("radius positions run from 0 to 1 along the edge".into()));
|
||||
}
|
||||
if points.iter().any(|(t, _)| !(t.is_finite() && (0.0..=1.0).contains(t))) {
|
||||
return Err(KernelError::Invalid("radius positions run from 0 to 1 along the edge".into()));
|
||||
}
|
||||
let mut pts = points.to_vec();
|
||||
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
@@ -129,171 +160,375 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &
|
||||
if pts.first().map(|p| p.0) != Some(0.0) || pts.last().map(|p| p.0) != Some(1.0) || pts.len() < 2 {
|
||||
return Err(KernelError::Invalid("give the radius at both ends of the edge".into()));
|
||||
}
|
||||
fillet_variable_spline(body, edge_at, start, &RadiusSpline::through(&pts, smooth))
|
||||
}
|
||||
|
||||
/// Fillet one straight edge with the radius `spline` along it, its positions from the end
|
||||
/// nearest `start`. One end's radius may be zero.
|
||||
pub fn fillet_variable_spline(body: &Body, edge_at: Vec3, start: Vec3, spline: &RadiusSpline) -> Result<Body> {
|
||||
body.require_brep("fillet")?;
|
||||
spline.check()?;
|
||||
let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?;
|
||||
let size = body.size();
|
||||
guard("variable fillet", || {
|
||||
let solid: &Solid = &body.solid;
|
||||
let s = EdgeSetup::new(body, &edge)?;
|
||||
// Positions from the end nearest `start` (the edge keeps its own direction).
|
||||
let law = if s.p1.dist(start) < s.p0.dist(start) { spline.reversed() } else { spline.clone() };
|
||||
build(body, &s, &edge, &law)
|
||||
})
|
||||
}
|
||||
|
||||
/// A straight edge between two planar faces as the variable blend sees it: the shell it is in
|
||||
/// and that shell's faces, the frame at the edge, and its ends (front first).
|
||||
struct EdgeSetup<'a> {
|
||||
si: usize,
|
||||
faces: Vec<&'a mt::Face>,
|
||||
fr: EdgeFrame,
|
||||
edge: mt::EdgeID,
|
||||
v0: mt::Vertex,
|
||||
v1: mt::Vertex,
|
||||
p0: Vec3,
|
||||
p1: Vec3,
|
||||
d: Vec3,
|
||||
tol: f64,
|
||||
}
|
||||
|
||||
impl<'a> EdgeSetup<'a> {
|
||||
fn new(body: &'a Body, edge: &mt::Edge) -> Result<EdgeSetup<'a>> {
|
||||
if !matches!(edge.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("only straight edges take a variable radius"));
|
||||
}
|
||||
let shells = solid.boundaries();
|
||||
let (si, shell) = shells
|
||||
let (si, shell) = body
|
||||
.solid
|
||||
.boundaries()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, sh)| sh.edge_iter().any(|e| e.id() == edge.id()))
|
||||
.ok_or_else(|| KernelError::Failed("edge not found".into()))?;
|
||||
let faces: Vec<&mt::Face> = shell.face_iter().collect();
|
||||
let fr = edge_frame(&faces, &edge)?;
|
||||
let fr = edge_frame(&faces, edge)?;
|
||||
let (v0, v1) = (edge.absolute_front().clone(), edge.absolute_back().clone());
|
||||
// Positions from the end nearest `start` (the edge keeps its own direction).
|
||||
if vtx(&v1).dist(start) < vtx(&v0).dist(start) {
|
||||
pts = pts.iter().rev().map(|(t, r)| (1.0 - t, *r)).collect();
|
||||
}
|
||||
let law = RadiusLaw::through(&pts, smooth);
|
||||
let (ra, rb) = (law.eval(0.0), law.eval(1.0));
|
||||
// The largest radius along the edge decides whether the blend fits.
|
||||
let rmax = (0..=200).map(|k| law.eval(k as f64 / 200.0)).fold(0.0, f64::max);
|
||||
if (0..=200).any(|k| law.eval(k as f64 / 200.0) <= 1e-6) {
|
||||
return Err(KernelError::Invalid("the radius must stay positive along the edge".into()));
|
||||
}
|
||||
let (p0, p1) = (vtx(&v0), vtx(&v1));
|
||||
let d = (p1 - p0).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?;
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
let back = |r: f64| r / (fr.phi / 2.0).tan();
|
||||
// Each end: the end face and its edges along the two faces.
|
||||
struct End {
|
||||
v: mt::Vertex,
|
||||
g: usize,
|
||||
e1: mt::Edge,
|
||||
e2: mt::Edge,
|
||||
Ok(EdgeSetup { si, faces, fr, edge: edge.id(), v0, v1, p0, p1, d, tol: (body.size() * 1e-7).max(1e-9) })
|
||||
}
|
||||
|
||||
/// How far a blend of radius `r` reaches into each face from the edge.
|
||||
fn back(&self, r: f64) -> f64 {
|
||||
r / (self.fr.phi / 2.0).tan()
|
||||
}
|
||||
|
||||
/// Whether the band a blend of radius `r` takes from the faces at position `u` (strictly
|
||||
/// between the ends) stays inside both, clear of their other edges and holes.
|
||||
fn band_fits(&self, u: f64, r: f64) -> bool {
|
||||
let p = self.p0 + (self.p1 - self.p0) * u;
|
||||
let s = self.back(r);
|
||||
[(self.fr.i1, self.fr.t1), (self.fr.i2, self.fr.t2)]
|
||||
.into_iter()
|
||||
.all(|(i, t)| self.faces.get(i).is_some_and(|f| crate::blend::extension_fits(f, p, p + t * s, self.tol)))
|
||||
}
|
||||
|
||||
/// The largest radius up to `cap` whose band fits at position `u`.
|
||||
fn room(&self, u: f64, cap: f64) -> f64 {
|
||||
if self.band_fits(u, cap) {
|
||||
return cap;
|
||||
}
|
||||
let mut ends = Vec::new();
|
||||
for (v, p, r) in [(&v0, p0, ra), (&v1, p1, rb)] {
|
||||
let at_v: Vec<usize> = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.vertex_iter().any(|x| &x == v))).collect();
|
||||
let others: Vec<usize> = at_v.iter().copied().filter(|i| *i != fr.i1 && *i != fr.i2).collect();
|
||||
let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) };
|
||||
let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
|
||||
if ng.dot(d).abs() < 1.0 - 1e-9 {
|
||||
let (mut lo, mut hi) = (0.0, cap);
|
||||
while hi - lo > self.tol {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if self.band_fits(u, mid) { lo = mid } else { hi = mid }
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
/// The room at the end `v` (`out` pointing away from the edge there), as a radius: zero
|
||||
/// where a face closes to a point (its next side runs back along the edge), the reach of the
|
||||
/// sides along the faces where they are square to the edge, None where a side leans outward.
|
||||
fn end_room(&self, v: &mt::Vertex, out: Vec3) -> Option<f64> {
|
||||
let mut room = f64::INFINITY;
|
||||
for (i, t) in [(self.fr.i1, self.fr.t1), (self.fr.i2, self.fr.t2)] {
|
||||
let side = self.faces.get(i)?.edge_iter().find(|e| e.id() != self.edge && (e.front() == v || e.back() == v))?;
|
||||
let dir = leaving(&side, v, self.tol)?;
|
||||
let lean = dir.dot(out);
|
||||
if lean < -1e-9 {
|
||||
return Some(0.0);
|
||||
}
|
||||
if lean > 1e-9 || !matches!(side.curve(), mt::Curve::Line(_)) {
|
||||
return None;
|
||||
}
|
||||
let other = if side.front() == v { side.back() } else { side.front() };
|
||||
room = room.min((vtx(other) - vtx(v)).dot(t) * (self.fr.phi / 2.0).tan());
|
||||
}
|
||||
Some(room)
|
||||
}
|
||||
}
|
||||
|
||||
/// The direction `edge` leaves its end `v` in (along its first piece within `tol`).
|
||||
fn leaving(edge: &mt::Edge, v: &mt::Vertex, tol: f64) -> Option<Vec3> {
|
||||
use mt::{BoundedCurve, ParameterDivision1D};
|
||||
let curve = edge.curve();
|
||||
let pts = curve.parameter_division(curve.range_tuple(), tol.max(1e-6)).1;
|
||||
let p = vtx(v);
|
||||
let near = if crate::body::from_p3(*pts.first()?).dist(p) <= crate::body::from_p3(*pts.last()?).dist(p) {
|
||||
pts.get(1)?
|
||||
} else {
|
||||
pts.get(pts.len().checked_sub(2)?)?
|
||||
};
|
||||
(crate::body::from_p3(*near) - p).normalized()
|
||||
}
|
||||
|
||||
/// Build the blend of the edge of `s` with the radius `law` (its positions from the edge's front).
|
||||
fn build(body: &Body, s: &EdgeSetup, edge: &mt::Edge, law: &RadiusSpline) -> Result<Body> {
|
||||
let (faces, fr, tol) = (&s.faces, &s.fr, s.tol);
|
||||
let (v0, v1, p0, p1, d) = (&s.v0, &s.v1, s.p0, s.p1, s.d);
|
||||
// The end radii are the first and last control radii (exact: a zero end closes the blend).
|
||||
let (ra, rb) = (law.radii.first().copied().unwrap_or(0.0), law.radii.last().copied().unwrap_or(0.0));
|
||||
let back = |r: f64| s.back(r);
|
||||
let arc_mid = |p: Vec3, sb: f64, r: f64| {
|
||||
let c = p + fr.t1 * sb + fr.n1 * (r * fr.side);
|
||||
c + (p - c).normalized().unwrap_or(-fr.n1) * r
|
||||
};
|
||||
// Each end: the blend's corners on the two faces and, with a radius, the arc between them,
|
||||
// the end face and its edges along the two faces. At a zero radius the blend closes to the
|
||||
// end vertex, and the faces around it stay as they are.
|
||||
struct Arc {
|
||||
v: mt::Vertex,
|
||||
g: usize,
|
||||
e1: mt::Edge,
|
||||
e2: mt::Edge,
|
||||
edge: mt::Edge,
|
||||
}
|
||||
struct End {
|
||||
a: mt::Vertex,
|
||||
b: mt::Vertex,
|
||||
arc: Option<Arc>,
|
||||
}
|
||||
let mut ends = Vec::new();
|
||||
for (v, p, r) in [(v0, p0, ra), (v1, p1, rb)] {
|
||||
if r == 0.0 {
|
||||
ends.push(End { a: v.clone(), b: v.clone(), arc: None });
|
||||
continue;
|
||||
}
|
||||
let at_v: Vec<usize> = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.vertex_iter().any(|x| &x == v))).collect();
|
||||
let others: Vec<usize> = at_v.iter().copied().filter(|i| *i != fr.i1 && *i != fr.i2).collect();
|
||||
let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) };
|
||||
let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
|
||||
if ng.dot(d).abs() < 1.0 - 1e-9 {
|
||||
return Err(unsupported("the faces at the edge ends must be perpendicular to the edge"));
|
||||
}
|
||||
let shared = |fi: usize| -> Result<mt::Edge> {
|
||||
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
f.edge_iter()
|
||||
.find(|e| e.id() != edge.id() && (e.front() == v || e.back() == v) && gf.edge_iter().any(|x| x.id() == e.id()))
|
||||
.ok_or_else(|| unsupported("unexpected corner topology"))
|
||||
};
|
||||
let (e1, e2) = (shared(fr.i1)?, shared(fr.i2)?);
|
||||
for (e, t) in [(&e1, fr.t1), (&e2, fr.t2)] {
|
||||
if !matches!(e.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("the edges at the corners must be straight"));
|
||||
}
|
||||
let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
|
||||
let along = (other - p).dot(t);
|
||||
if (other - p - t * along).len() > tol * 100.0 + 1e-7 {
|
||||
return Err(unsupported("the faces at the edge ends must be perpendicular to the edge"));
|
||||
}
|
||||
let shared = |fi: usize| -> Result<mt::Edge> {
|
||||
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
f.edge_iter()
|
||||
.find(|e| e.id() != edge.id() && (e.front() == v || e.back() == v) && gf.edge_iter().any(|x| x.id() == e.id()))
|
||||
.ok_or_else(|| unsupported("unexpected corner topology"))
|
||||
};
|
||||
let (e1, e2) = (shared(fr.i1)?, shared(fr.i2)?);
|
||||
for (e, t) in [(&e1, fr.t1), (&e2, fr.t2)] {
|
||||
if !matches!(e.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("the edges at the corners must be straight"));
|
||||
}
|
||||
let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
|
||||
let along = (other - p).dot(t);
|
||||
if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= back(r.max(rmax)) + tol {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
}
|
||||
ends.push(End { v: v.clone(), g, e1, e2 });
|
||||
}
|
||||
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
|
||||
if end0.g == end1.g {
|
||||
return Err(unsupported("both ends touch the same face"));
|
||||
}
|
||||
let (s0, s1) = (back(ra), back(rb));
|
||||
let (pa0, pb0, pa1, pb1) = (p0 + fr.t1 * s0, p0 + fr.t2 * s0, p1 + fr.t1 * s1, p1 + fr.t2 * s1);
|
||||
let (a0, b0, a1, b1) = (builder::vertex(p3(pa0)), builder::vertex(p3(pb0)), builder::vertex(p3(pa1)), builder::vertex(p3(pb1)));
|
||||
let arc_mid = |p: Vec3, s: f64, r: f64| {
|
||||
let c = p + fr.t1 * s + fr.n1 * (r * fr.side);
|
||||
c + (p - c).normalized().unwrap_or(-fr.n1) * r
|
||||
};
|
||||
let c0 = builder::circle_arc(&a0, &b0, p3(arc_mid(p0, s0, ra)));
|
||||
let c1 = builder::circle_arc(&a1, &b1, p3(arc_mid(p1, s1, rb)));
|
||||
// Contact curves: the edge moved along each face by the setback of the radius there.
|
||||
let g = law.greville();
|
||||
let contact = |t: Vec3, a: &mt::Vertex, b: &mt::Vertex| -> Result<mt::Edge> {
|
||||
if law.deg == 1 && law.radii.len() == 2 {
|
||||
return Ok(builder::line(a, b));
|
||||
}
|
||||
let ctrl: Vec<mt::Point3> = g.iter().zip(&law.radii).map(|(gj, rj)| p3(p0 + (p1 - p0) * *gj + t * back(*rj))).collect();
|
||||
let c = mt::BSplineCurve::try_new(mt::KnotVec::from(law.knots.clone()), ctrl).map_err(|e| KernelError::Failed(format!("fillet: {e}")))?;
|
||||
mt::Edge::try_new(a, b, mt::Curve::BSplineCurve(c)).map_err(|e| KernelError::Failed(format!("fillet: {e}")))
|
||||
};
|
||||
let la = contact(fr.t1, &a0, &a1)?;
|
||||
let lb = contact(fr.t2, &b0, &b1)?;
|
||||
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
for (end, a, b) in [(end0, &a0, &b0), (end1, &a1, &b1)] {
|
||||
for (e, nv) in [(&end.e1, a), (&end.e2, b)] {
|
||||
let (f, bk) = (e.absolute_front(), e.absolute_back());
|
||||
let ne = if f == &end.v { builder::line(nv, bk) } else { builder::line(f, nv) };
|
||||
subst.insert(e.id(), ne);
|
||||
if along <= back(r) + tol {
|
||||
return Err(too_large("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
}
|
||||
// The side edges at the ends are pieces of themselves; the contacts and end arcs are new.
|
||||
let mut rec = crate::record::Rebuild::default();
|
||||
rec.pieces(&subst);
|
||||
let mut new_faces: Vec<mt::Face> = Vec::with_capacity(faces.len() + 1);
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let nf = if i == fr.i1 {
|
||||
let mut m = subst.clone();
|
||||
m.insert(edge.id(), la.clone());
|
||||
rebuild_face(f, &m, &[])?
|
||||
} else if i == fr.i2 {
|
||||
let mut m = subst.clone();
|
||||
m.insert(edge.id(), lb.clone());
|
||||
rebuild_face(f, &m, &[])?
|
||||
} else if i == end0.g {
|
||||
rebuild_face(f, &subst, std::slice::from_ref(&c0))?
|
||||
} else if i == end1.g {
|
||||
rebuild_face(f, &subst, std::slice::from_ref(&c1))?
|
||||
} else {
|
||||
(*f).clone()
|
||||
};
|
||||
rec.face(&nf, f);
|
||||
new_faces.push(nf);
|
||||
let sb = back(r);
|
||||
let (a, b) = (builder::vertex(p3(p + fr.t1 * sb)), builder::vertex(p3(p + fr.t2 * sb)));
|
||||
let arc = builder::circle_arc(&a, &b, p3(arc_mid(p, sb, r)));
|
||||
ends.push(End { a, b, arc: Some(Arc { v: v.clone(), g, e1, e2, edge: arc }) });
|
||||
}
|
||||
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
|
||||
if let (Some(x), Some(y)) = (&end0.arc, &end1.arc)
|
||||
&& x.g == y.g
|
||||
{
|
||||
return Err(unsupported("both ends touch the same face"));
|
||||
}
|
||||
// Along the edge the band the blend takes from each face stays inside it.
|
||||
const STATIONS: usize = 256;
|
||||
if (1..STATIONS).any(|k| !s.band_fits(k as f64 / STATIONS as f64, law.eval(k as f64 / STATIONS as f64))) {
|
||||
return Err(too_large("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
// Contact curves: the edge moved along each face by the setback of the radius there.
|
||||
let g = law.greville();
|
||||
let contact = |t: Vec3, a: &mt::Vertex, b: &mt::Vertex| -> Result<mt::Edge> {
|
||||
if law.degree == 1 && law.radii.len() == 2 {
|
||||
return Ok(builder::line(a, b));
|
||||
}
|
||||
// The blend surface: rational quadratic arcs (tangent points and the edge point, middle
|
||||
// weight sin(φ/2)) at each end, joined linearly.
|
||||
let w = (fr.phi / 2.0).sin();
|
||||
let h = |p: Vec3, wt: f64| mt::Vector4::new(p.x * wt, p.y * wt, p.z * wt, wt);
|
||||
let row = |t: Vec3, wt: f64, off: bool| -> Vec<mt::Vector4> {
|
||||
g.iter().zip(&law.radii).map(|(gj, rj)| h(p0 + (p1 - p0) * *gj + if off { t * back(*rj) } else { Vec3::ZERO }, wt)).collect()
|
||||
};
|
||||
let ctrl = vec![row(fr.t1, 1.0, true), row(fr.t1, w, false), row(fr.t2, 1.0, true)];
|
||||
let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::from(law.knots.clone())), ctrl);
|
||||
let mut surf = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bsp));
|
||||
// Outward: away from the arc centre on a convex edge, toward it on a concave one.
|
||||
let (c, mid) = (p0 + fr.t1 * s0 + fr.n1 * (ra * fr.side), arc_mid(p0, s0, ra));
|
||||
let outward = (mid - c) * (-fr.side);
|
||||
{
|
||||
use mt::{ParametricSurface3D, SearchParameter};
|
||||
if let Some((u, v)) = surf.search_parameter(p3(mid), None, 100) {
|
||||
let nn = surf.normal(u, v);
|
||||
if Vec3::new(nn.x, nn.y, nn.z).dot(outward) < 0.0 {
|
||||
surf = mt::Invertible::inverse(&surf);
|
||||
}
|
||||
}
|
||||
let ctrl: Vec<mt::Point3> = g.iter().zip(&law.radii).map(|(gj, rj)| p3(p0 + (p1 - p0) * *gj + t * back(*rj))).collect();
|
||||
let c = mt::BSplineCurve::try_new(mt::KnotVec::from(law.knots.clone()), ctrl).map_err(|e| KernelError::Failed(format!("fillet: {e}")))?;
|
||||
mt::Edge::try_new(a, b, mt::Curve::BSplineCurve(c)).map_err(|e| KernelError::Failed(format!("fillet: {e}")))
|
||||
};
|
||||
let la = contact(fr.t1, &end0.a, &end1.a)?;
|
||||
let lb = contact(fr.t2, &end0.b, &end1.b)?;
|
||||
let arcs = || [end0, end1].into_iter().filter_map(|end| end.arc.as_ref().map(|arc| (end, arc)));
|
||||
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
for (end, arc) in arcs() {
|
||||
for (e, nv) in [(&arc.e1, &end.a), (&arc.e2, &end.b)] {
|
||||
let (f, bk) = (e.absolute_front(), e.absolute_back());
|
||||
let ne = if f == &arc.v { builder::line(nv, bk) } else { builder::line(f, nv) };
|
||||
subst.insert(e.id(), ne);
|
||||
}
|
||||
// The blend face uses each new edge opposite to its neighbour.
|
||||
let f1_forward =
|
||||
faces.get(fr.i1).is_some_and(|f| f.boundaries().iter().any(|w| w.edge_iter().any(|e| e.id() == edge.id() && e.front() == &v0)));
|
||||
let wire: mt::Wire = if f1_forward {
|
||||
vec![c0.clone(), lb.clone(), c1.inverse(), la.inverse()].into()
|
||||
}
|
||||
// The side edges at the ends are pieces of themselves; the contacts and end arcs are new.
|
||||
let mut rec = crate::record::Rebuild::default();
|
||||
rec.pieces(&subst);
|
||||
let mut new_faces: Vec<mt::Face> = Vec::with_capacity(faces.len() + 1);
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let nf = if i == fr.i1 {
|
||||
let mut m = subst.clone();
|
||||
m.insert(edge.id(), la.clone());
|
||||
rebuild_face(f, &m, &[])?
|
||||
} else if i == fr.i2 {
|
||||
let mut m = subst.clone();
|
||||
m.insert(edge.id(), lb.clone());
|
||||
rebuild_face(f, &m, &[])?
|
||||
} else if let Some((_, arc)) = arcs().find(|(_, arc)| arc.g == i) {
|
||||
rebuild_face(f, &subst, std::slice::from_ref(&arc.edge))?
|
||||
} else {
|
||||
vec![la.clone(), c1.clone(), lb.inverse(), c0.inverse()].into()
|
||||
(*f).clone()
|
||||
};
|
||||
let blend = mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?;
|
||||
rec.blend(&blend, [edge.id()]);
|
||||
new_faces.push(blend);
|
||||
let mut shells = solid.boundaries().clone();
|
||||
if let Some(sh) = shells.get_mut(si) {
|
||||
*sh = new_faces.into();
|
||||
rec.face(&nf, f);
|
||||
new_faces.push(nf);
|
||||
}
|
||||
// The blend surface: rational quadratic arcs (tangent points and the edge point, middle
|
||||
// weight sin(φ/2)) at each end, joined along the radius spline.
|
||||
let w = (fr.phi / 2.0).sin();
|
||||
let h = |p: Vec3, wt: f64| mt::Vector4::new(p.x * wt, p.y * wt, p.z * wt, wt);
|
||||
let row = |t: Vec3, wt: f64, off: bool| -> Vec<mt::Vector4> {
|
||||
g.iter().zip(&law.radii).map(|(gj, rj)| h(p0 + (p1 - p0) * *gj + if off { t * back(*rj) } else { Vec3::ZERO }, wt)).collect()
|
||||
};
|
||||
let ctrl = vec![row(fr.t1, 1.0, true), row(fr.t1, w, false), row(fr.t2, 1.0, true)];
|
||||
let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::from(law.knots.clone())), ctrl);
|
||||
let mut surf = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bsp));
|
||||
// Outward: away from the arc centre on a convex edge, toward it on a concave one (at an
|
||||
// end with a radius).
|
||||
let (p, r) = if end0.arc.is_none() { (p1, rb) } else { (p0, ra) };
|
||||
let (c, mid) = (p + fr.t1 * back(r) + fr.n1 * (r * fr.side), arc_mid(p, back(r), r));
|
||||
let outward = (mid - c) * (-fr.side);
|
||||
{
|
||||
use mt::{ParametricSurface3D, SearchParameter};
|
||||
if let Some((u, v)) = surf.search_parameter(p3(mid), None, 100) {
|
||||
let nn = surf.normal(u, v);
|
||||
if Vec3::new(nn.x, nn.y, nn.z).dot(outward) < 0.0 {
|
||||
surf = mt::Invertible::inverse(&surf);
|
||||
}
|
||||
}
|
||||
let out = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("variable fillet produced an invalid solid: {e}")))?;
|
||||
crate::record::rebuilt(out, &[solid], &rec)
|
||||
}
|
||||
// The blend face uses each new edge opposite to its neighbour; a closed end has no arc.
|
||||
let arc_of = |end: &End| end.arc.as_ref().map(|a| a.edge.clone());
|
||||
let f1_forward = faces.get(fr.i1).is_some_and(|f| f.boundaries().iter().any(|w| w.edge_iter().any(|e| e.id() == edge.id() && e.front() == v0)));
|
||||
let wire: mt::Wire = if f1_forward {
|
||||
[arc_of(end0), Some(lb.clone()), arc_of(end1).map(|a| a.inverse()), Some(la.inverse())].into_iter().flatten().collect()
|
||||
} else {
|
||||
[Some(la.clone()), arc_of(end1), Some(lb.inverse()), arc_of(end0).map(|a| a.inverse())].into_iter().flatten().collect()
|
||||
};
|
||||
let blend = mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?;
|
||||
rec.blend(&blend, [edge.id()]);
|
||||
new_faces.push(blend);
|
||||
let solid: &Solid = &body.solid;
|
||||
let mut shells = solid.boundaries().clone();
|
||||
if let Some(sh) = shells.get_mut(s.si) {
|
||||
*sh = new_faces.into();
|
||||
}
|
||||
let out = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("variable fillet produced an invalid solid: {e}")))?;
|
||||
crate::record::rebuilt(out, &[solid], &rec)
|
||||
}
|
||||
|
||||
/// How much of the room along an edge a taper takes: its band stays within this fraction of each
|
||||
/// face's width, so the faces keep a strip beside the round, narrowing to nothing only where a
|
||||
/// face closes to a point.
|
||||
const TAPER_SHARE: f64 = 0.9;
|
||||
/// Spans of the cubic radius spline a taper builds.
|
||||
const TAPER_SPANS: usize = 8;
|
||||
|
||||
/// The radius a taper gives one edge: its positions from `start`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Taper {
|
||||
pub start: Vec3,
|
||||
pub spline: RadiusSpline,
|
||||
}
|
||||
|
||||
/// The radius spline that tapers `radius` on the straight edge at `edge_at` to the room along
|
||||
/// it: `radius` where that fits, following the room down where it doesn't, to zero at an end
|
||||
/// where a face closes to a point. Its control radii are the target min(radius, share × room)
|
||||
/// at their Greville positions (Schoenberg's variation-diminishing spline), which stays under the
|
||||
/// target wherever the room is concave along the edge (a convex face); the blend checks the rest.
|
||||
pub fn taper(body: &Body, edge_at: Vec3, radius: f64) -> Result<Taper> {
|
||||
body.require_brep("fillet")?;
|
||||
if !(radius.is_finite() && radius > 1e-6 && radius < 1e6) {
|
||||
return Err(KernelError::Invalid("fillet radii must be positive".into()));
|
||||
}
|
||||
let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?;
|
||||
guard("taper", || {
|
||||
let s = EdgeSetup::new(body, &edge)?;
|
||||
let leans = || unsupported("a taper needs each end of the edge square to it or closing to a point");
|
||||
let room0 = s.end_room(&s.v0, -s.d).ok_or_else(leans)?;
|
||||
let room1 = s.end_room(&s.v1, s.d).ok_or_else(leans)?;
|
||||
if room0 == 0.0 && room1 == 0.0 {
|
||||
return Err(too_large("the faces close to a point at both ends of the edge"));
|
||||
}
|
||||
let mut knots = vec![0.0; 4];
|
||||
knots.extend((1..TAPER_SPANS).map(|k| k as f64 / TAPER_SPANS as f64));
|
||||
knots.extend([1.0; 4]);
|
||||
let mut law = RadiusSpline { degree: 3, knots, radii: vec![0.0; TAPER_SPANS + 3] };
|
||||
let g = law.greville();
|
||||
let last = g.len() - 1;
|
||||
law.radii = g
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, u)| match j {
|
||||
0 => (TAPER_SHARE * room0).min(radius),
|
||||
j if j == last => (TAPER_SHARE * room1).min(radius),
|
||||
_ => TAPER_SHARE * s.room(*u, radius / TAPER_SHARE),
|
||||
})
|
||||
.collect();
|
||||
if law.radii.iter().take(last).skip(1).any(|r| *r <= 1e-6) {
|
||||
return Err(too_large("the room for the blend runs out inside the edge"));
|
||||
}
|
||||
law.check()?;
|
||||
Ok(Taper { start: s.p0, spline: law })
|
||||
})
|
||||
}
|
||||
|
||||
/// A constant fillet of `edges` at `radius`; where that is too large, each edge it doesn't fit
|
||||
/// on alone gets a [`taper`], and the rest the constant radius. The result and the taper of each
|
||||
/// edge (in `edges`' order; None where the radius is constant).
|
||||
pub fn fillet_tapered(body: &Body, edges: &[Vec3], radius: f64) -> Result<(Body, Vec<Option<Taper>>)> {
|
||||
let why = match crate::fillet(body, edges, radius) {
|
||||
Err(KernelError::TooLarge(why)) => why,
|
||||
other => return other.map(|b| (b, vec![None; edges.len()])),
|
||||
};
|
||||
let mut tapers = vec![None; edges.len()];
|
||||
let mut constant = Vec::new();
|
||||
for (p, slot) in edges.iter().zip(tapers.iter_mut()) {
|
||||
match crate::fillet(body, &[*p], radius) {
|
||||
Ok(_) => constant.push(*p),
|
||||
Err(KernelError::TooLarge(_)) => *slot = Some(taper(body, *p, radius)?),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
// Each edge fits alone but not together: nothing to taper.
|
||||
if tapers.iter().all(Option::is_none) {
|
||||
return Err(KernelError::TooLarge(why));
|
||||
}
|
||||
// Each step's result carries its history over `[body]`.
|
||||
let counts = crate::record::counts(&[body]);
|
||||
let mut cur = crate::boolean_record::operand(body, 0, &counts);
|
||||
for (p, t) in edges.iter().zip(&tapers) {
|
||||
if let Some(t) = t {
|
||||
cur = crate::record::after(fillet_variable_spline(&cur, *p, t.start, &t.spline)?, &cur, &counts);
|
||||
}
|
||||
}
|
||||
if !constant.is_empty() {
|
||||
cur = crate::record::after(crate::fillet(&cur, &constant, radius)?, &cur, &counts);
|
||||
}
|
||||
Ok((cur, tapers))
|
||||
}
|
||||
|
||||
/// Chord-length fillet: each edge (straight, between planar faces) rounded so that the blend
|
||||
/// is `chord` wide across; edges with the same face angle share a radius.
|
||||
pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result<Body> {
|
||||
@@ -371,7 +606,7 @@ mod tests {
|
||||
assert!(rel(v, 6000.0 - removed) < 1e-4, "{v} vs {}", 6000.0 - removed);
|
||||
assert_eq!(f.unmeshed_faces(), 0);
|
||||
let s = fillet_variable_points(&b, at, start, &pts, true).unwrap();
|
||||
let law = RadiusLaw::through(&pts, true);
|
||||
let law = RadiusSpline::through(&pts, true);
|
||||
let n = 20000;
|
||||
let integral: f64 = (0..n).map(|i| law.eval((i as f64 + 0.5) / n as f64).powi(2)).sum::<f64>() / n as f64 * 20.0;
|
||||
assert!((law.eval(0.5) - 4.0).abs() < 1e-12 && (law.eval(0.0) - 2.0).abs() < 1e-12);
|
||||
@@ -387,6 +622,125 @@ mod tests {
|
||||
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (1.0, -1.0)], false).is_err());
|
||||
}
|
||||
|
||||
/// The radius running down to zero at one end: the blend closes to the corner, a three-sided
|
||||
/// face, and the end face there is untouched. Removed (1 − π/4) L r0² / 3 when linear.
|
||||
#[test]
|
||||
fn variable_radius_down_to_zero_at_one_end() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
|
||||
let (at, start, end) = (Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0), Vec3::new(10.0, 20.0, 30.0));
|
||||
let k = 1.0 - std::f64::consts::FRAC_PI_4;
|
||||
for (r0, r1) in [(4.0, 0.0), (0.0, 4.0)] {
|
||||
let f = fillet_variable(&b, at, start, r0, r1).unwrap();
|
||||
let v = measure(&f).unwrap().volume;
|
||||
assert!(rel(v, 6000.0 - k * 20.0 * 16.0 / 3.0) < 1e-4, "{r0} → {r1}: {v}");
|
||||
assert_eq!(f.unmeshed_faces(), 0);
|
||||
let faces: Vec<&mt::Face> = f.solid.boundaries().iter().flat_map(|sh| sh.face_iter()).collect();
|
||||
assert_eq!(faces.len(), 7);
|
||||
// The blend is three-sided; the closed end's corner is still a box corner.
|
||||
assert!(faces.iter().any(|fc| fc.edge_iter().count() == 3));
|
||||
let corner = if r1 == 0.0 { end } else { start };
|
||||
assert!(faces.iter().flat_map(|fc| fc.vertex_iter()).any(|x| vtx(&x).dist(corner) < 1e-9));
|
||||
}
|
||||
// Through a middle point, smooth, closing at the far end.
|
||||
let pts = [(0.0, 3.0), (0.5, 4.0), (1.0, 0.0)];
|
||||
let s = fillet_variable_points(&b, at, start, &pts, true).unwrap();
|
||||
let law = RadiusSpline::through(&pts, true);
|
||||
let n = 20000;
|
||||
let integral: f64 = (0..n).map(|i| law.eval((i as f64 + 0.5) / n as f64).powi(2)).sum::<f64>() / n as f64 * 20.0;
|
||||
let v = measure(&s).unwrap().volume;
|
||||
assert!(rel(v, 6000.0 - k * integral) < 1e-4, "{v} vs {}", 6000.0 - k * integral);
|
||||
// Zero at both ends, zero in the middle, a smooth radius dipping below zero, or a radius
|
||||
// too small to build: refused.
|
||||
assert!(fillet_variable(&b, at, start, 0.0, 0.0).is_err());
|
||||
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (0.5, 0.0), (1.0, 2.0)], false).is_err());
|
||||
let dip = [(0.0, 0.0), (0.2, 0.1), (0.4, 6.0), (1.0, 6.0)];
|
||||
assert!((1..200).any(|i| RadiusSpline::through(&dip, true).eval(i as f64 / 200.0) <= 1e-6));
|
||||
assert!(fillet_variable_points(&b, at, start, &dip, true).is_err());
|
||||
assert!(fillet_variable(&b, at, start, 4.0, 1e-9).is_err());
|
||||
}
|
||||
|
||||
/// A prism of the polygon `pts` (in xy) from z = 0 to `h`.
|
||||
fn prism(pts: &[(f64, f64)], h: f64) -> Body {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let outer = Loop2::polygon(&pts.iter().map(|(x, y)| Vec2::new(*x, *y)).collect::<Vec<_>>());
|
||||
crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, h).unwrap().pop().unwrap()
|
||||
}
|
||||
|
||||
/// The material a right-angled blend with the radius `law` removes along an edge of length L:
|
||||
/// ∫ (1 − π/4) r² over the edge.
|
||||
fn removed(law: &RadiusSpline, len: f64) -> f64 {
|
||||
let n = 20000;
|
||||
(1.0 - std::f64::consts::FRAC_PI_4) * (0..n).map(|i| law.eval((i as f64 + 0.5) / n as f64).powi(2)).sum::<f64>() / n as f64 * len
|
||||
}
|
||||
|
||||
/// The volume of a fine tessellation: at a blend closed to a point `measure` takes a
|
||||
/// coarser mesh of the blend, a few mm³ off on these parts.
|
||||
fn fine_volume(b: &Body) -> f64 {
|
||||
b.tessellate_with(5e-4, true).unwrap().measure().volume
|
||||
}
|
||||
|
||||
/// A taper on the top front edge of a wedge, whose top face narrows to a point, and of a
|
||||
/// trapezoid, whose top face narrows to 2 mm: the radius asked for where it fits, below
|
||||
/// 0.9 of the room everywhere, down to zero at the wedge's tip and to 1.8 mm at the
|
||||
/// trapezoid's narrow end; the volume integrates the spline.
|
||||
#[test]
|
||||
fn taper_follows_the_room_along_the_edge() {
|
||||
let (at, start) = (Vec3::new(20.0, 0.0, 15.0), Vec3::new(0.0, 0.0, 15.0));
|
||||
let wedge = prism(&[(0.0, 0.0), (40.0, 0.0), (0.0, 20.0)], 15.0);
|
||||
assert!(matches!(crate::fillet(&wedge, &[at], 16.0), Err(KernelError::TooLarge(_))));
|
||||
let t = taper(&wedge, at, 16.0).unwrap();
|
||||
let law = if t.start.dist(start) < 1e-9 { t.spline.clone() } else { t.spline.reversed() };
|
||||
// The room: the top face is 20 (1 − u) wide, the front 15 high.
|
||||
for i in 0..=100 {
|
||||
let u = i as f64 / 100.0;
|
||||
let r = law.eval(u);
|
||||
assert!(r <= 0.9 * (20.0 * (1.0 - u)).min(15.0) + 1e-9 && r <= 16.0, "{u}: {r}");
|
||||
}
|
||||
assert_eq!(law.eval(1.0), 0.0);
|
||||
assert!((law.eval(0.0) - 13.5).abs() < 1e-9);
|
||||
let (f, tapers) = fillet_tapered(&wedge, &[at], 16.0).unwrap();
|
||||
assert_eq!(tapers, vec![Some(t)]);
|
||||
let v = fine_volume(&f);
|
||||
assert!(rel(v, 6000.0 - removed(&law, 40.0)) < 1e-4, "{v} vs {}", 6000.0 - removed(&law, 40.0));
|
||||
assert_eq!(f.unmeshed_faces(), 0);
|
||||
let faces: Vec<&mt::Face> = f.solid.boundaries().iter().flat_map(|sh| sh.face_iter()).collect();
|
||||
assert!(faces.iter().any(|fc| fc.edge_iter().count() == 3));
|
||||
|
||||
let trap = prism(&[(0.0, 0.0), (40.0, 0.0), (40.0, 2.0), (0.0, 20.0)], 15.0);
|
||||
assert!(matches!(crate::fillet(&trap, &[at], 3.0), Err(KernelError::TooLarge(_))));
|
||||
let (f, tapers) = fillet_tapered(&trap, &[at], 3.0).unwrap();
|
||||
let t = tapers.into_iter().flatten().next().unwrap();
|
||||
let law = if t.start.dist(start) < 1e-9 { t.spline } else { t.spline.reversed() };
|
||||
assert!((law.eval(0.0) - 3.0).abs() < 1e-9 && (law.eval(1.0) - 1.8).abs() < 1e-9);
|
||||
let v = fine_volume(&f);
|
||||
assert!(rel(v, 6600.0 - removed(&law, 40.0)) < 1e-4, "{v} vs {}", 6600.0 - removed(&law, 40.0));
|
||||
// A radius that fits stays constant.
|
||||
let (g, tapers) = fillet_tapered(&trap, &[at], 1.0).unwrap();
|
||||
assert_eq!(tapers, vec![None]);
|
||||
assert!(rel(measure(&g).unwrap().volume, measure(&crate::fillet(&trap, &[at], 1.0).unwrap()).unwrap().volume) < 1e-12);
|
||||
}
|
||||
|
||||
/// A radius spline given directly: a cubic with its control radii pulling the radius up in
|
||||
/// the middle; the same from the other end; refused when it isn't clamped or dips to zero.
|
||||
#[test]
|
||||
fn variable_radius_from_a_spline() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
|
||||
let (at, start) = (Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0));
|
||||
let law = RadiusSpline { degree: 3, knots: vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0], radii: vec![2.0, 2.0, 6.0, 3.0, 0.0] };
|
||||
let f = fillet_variable_spline(&b, at, start, &law).unwrap();
|
||||
let v = fine_volume(&f);
|
||||
assert!(rel(v, 6000.0 - removed(&law, 20.0)) < 1e-4, "{v} vs {}", 6000.0 - removed(&law, 20.0));
|
||||
let r = fillet_variable_spline(&b, at, Vec3::new(10.0, 20.0, 30.0), &law.reversed()).unwrap();
|
||||
assert!(rel(fine_volume(&r), v) < 1e-9);
|
||||
let unclamped = RadiusSpline { knots: vec![0.0, 0.0, 0.0, 0.1, 0.5, 1.0, 1.0, 1.0, 1.0], ..law.clone() };
|
||||
assert!(fillet_variable_spline(&b, at, start, &unclamped).is_err());
|
||||
let dips = RadiusSpline { radii: vec![2.0, 0.0, 0.0, 3.0, 2.0], ..law.clone() };
|
||||
assert!(fillet_variable_spline(&b, at, start, &dips).is_err());
|
||||
// A middle radius beyond the faces: too large.
|
||||
let big = RadiusSpline { radii: vec![2.0, 2.0, 40.0, 3.0, 2.0], ..law };
|
||||
assert!(matches!(fillet_variable_spline(&b, at, start, &big), Err(KernelError::TooLarge(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chord_length_on_a_right_angle() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
|
||||
|
||||
+121
-12
@@ -20,6 +20,13 @@ pub(crate) type Solid = mt::Solid;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Body {
|
||||
pub(crate) solid: std::sync::Arc<Solid>,
|
||||
/// Bounding-box diagonal, measured by [`Body::new`] while it read the vertices anyway; 0
|
||||
/// means "not measured, walk the geometry". Every kernel tolerance is a fraction of it.
|
||||
pub(crate) size: f64,
|
||||
/// The last volume measured for this solid and the tolerance it was measured at, shared by
|
||||
/// clones because they share the solid. A boolean measures its operands, and a later boolean
|
||||
/// takes the result as an operand again, so the same body is measured twice otherwise.
|
||||
pub(crate) volume: Option<std::sync::Arc<std::sync::Mutex<Option<(u64, f64)>>>>,
|
||||
/// Missing-face check from the orientation mesh, reusable at exactly its tolerance.
|
||||
pub(crate) coarse_missing: Option<(f64, usize)>,
|
||||
/// Set for mesh bodies (`solid` is then empty).
|
||||
@@ -441,6 +448,25 @@ fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
|
||||
(pts.first().copied().unwrap_or_default(), len)
|
||||
}
|
||||
|
||||
/// Deflection the orientation mesh uses: coarse enough for a large solid, fine enough that a
|
||||
/// small one still tessellates. `unmeshed_faces` counts at the same value, so the count the
|
||||
/// orientation mesh produced is reused instead of meshing the body again.
|
||||
fn orientation_tolerance(span: f64) -> f64 {
|
||||
(span * 0.01).clamp(1e-3, 1.0)
|
||||
}
|
||||
|
||||
/// Bounding-box diagonal of a solid's vertices (1 when it has no finite extent).
|
||||
fn span_of(solid: &Solid) -> f64 {
|
||||
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
|
||||
let mut hi = -lo;
|
||||
for v in solid.vertex_iter() {
|
||||
let p = from_p3(v.point());
|
||||
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
|
||||
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
|
||||
}
|
||||
if lo.x.is_finite() { (hi - lo).len() } else { 1.0 }
|
||||
}
|
||||
|
||||
impl Body {
|
||||
/// The very same body (shared data), not just an equal-looking one. Its history (how an
|
||||
/// operation made it) is not part of it: an operation that leaves a body as it is (a cut by
|
||||
@@ -466,17 +492,8 @@ impl Body {
|
||||
let mut solid = rephase_revolved(solid);
|
||||
// Orient outward: a closed solid must have positive volume (meshed finely enough that
|
||||
// every face comes out: a face missing from a coarse mesh can flip the sign).
|
||||
let span = {
|
||||
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
|
||||
let mut hi = -lo;
|
||||
for v in solid.vertex_iter() {
|
||||
let p = from_p3(v.point());
|
||||
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
|
||||
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
|
||||
}
|
||||
if lo.x.is_finite() { (hi - lo).len() } else { 1.0 }
|
||||
};
|
||||
let tol = (span * 0.01).clamp(1e-3, 1.0);
|
||||
let span = span_of(&solid);
|
||||
let tol = orientation_tolerance(span);
|
||||
let orient = |tol: f64| {
|
||||
guard("orient", || {
|
||||
let shells = mesh_shells(&solid, tol);
|
||||
@@ -502,6 +519,8 @@ impl Body {
|
||||
}
|
||||
Ok(Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: (v >= 0.0).then_some((tol, missing)),
|
||||
mesh: None,
|
||||
color: None,
|
||||
@@ -513,6 +532,35 @@ impl Body {
|
||||
})
|
||||
}
|
||||
|
||||
/// Like [`Body::new`], for a solid made by placing an already-oriented body (see
|
||||
/// [`crate::ops::transform_matrix`]): a placement keeps the faces pointing the way they did
|
||||
/// unless it mirrors them, so its orientation is read from the matrix rather than meshed
|
||||
/// again. Only for closed solids; `coarse` is the input's cached missing-face count, which a
|
||||
/// rigid placement preserves exactly.
|
||||
pub(crate) fn new_placed(solid: Solid, flip: bool, coarse: Option<(f64, usize)>) -> Result<Body> {
|
||||
if solid.boundaries().is_empty() {
|
||||
return Err(KernelError::Failed("empty result".into()));
|
||||
}
|
||||
let mut solid = rephase_revolved(solid);
|
||||
let span = span_of(&solid);
|
||||
if flip {
|
||||
solid.not();
|
||||
}
|
||||
Ok(Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: coarse,
|
||||
mesh: None,
|
||||
color: None,
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
regularization_protected: false,
|
||||
history: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
|
||||
self.regularization_protected |= input.regularization_protected;
|
||||
self
|
||||
@@ -843,8 +891,13 @@ impl Body {
|
||||
self
|
||||
}
|
||||
|
||||
/// Characteristic size (bounding box diagonal) for tolerances.
|
||||
/// Characteristic size (bounding box diagonal) for tolerances. [`Body::new`] measured it
|
||||
/// while it read the vertices anyway, so the answer is cached; a body built by hand carries
|
||||
/// 0 and is walked here once, and mesh bodies always measure their triangles.
|
||||
pub fn size(&self) -> f64 {
|
||||
if self.mesh.is_none() && self.size > 0.0 {
|
||||
return self.size;
|
||||
}
|
||||
let mut b = solidcraft_geom::Aabb3::EMPTY;
|
||||
if let Some(m) = &self.mesh {
|
||||
for p in &m.positions {
|
||||
@@ -858,6 +911,37 @@ impl Body {
|
||||
if d.is_finite() && d > 0.0 { d } else { 1.0 }
|
||||
}
|
||||
|
||||
/// The body's volume from a mesh at `tol`, measured once per (solid, tolerance): clones of
|
||||
/// a body share the measurement, so an operand a later boolean takes again is not meshed
|
||||
/// twice for the same number. `None` when the body cannot be meshed.
|
||||
pub fn volume_at(&self, tol: f64) -> Option<f64> {
|
||||
let key = if tol.is_finite() && tol > 0.0 { tol } else { 0.05 };
|
||||
let key = key.to_bits();
|
||||
if let Some(cell) = &self.volume
|
||||
&& let Ok(cached) = cell.lock()
|
||||
&& let Some((cached_tol, volume)) = *cached
|
||||
&& cached_tol == key
|
||||
{
|
||||
return Some(volume);
|
||||
}
|
||||
let volume = self.tessellate(f64::from_bits(key)).ok()?.measure().volume;
|
||||
if let Some(cell) = &self.volume
|
||||
&& let Ok(mut slot) = cell.lock()
|
||||
{
|
||||
*slot = Some((key, volume));
|
||||
}
|
||||
Some(volume)
|
||||
}
|
||||
|
||||
/// Rough heap footprint, for the evaluation cache's byte budget: the B-rep topology is what
|
||||
/// makes a kept timeline snapshot expensive, so it dominates the estimate.
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
if let Some(m) = &self.mesh {
|
||||
return m.positions.len() * 24 + m.triangles.len() * 12;
|
||||
}
|
||||
self.solid.face_iter().count() * 2048 + self.solid.edge_iter().count() * 512 + self.solid.vertex_iter().count() * 96
|
||||
}
|
||||
|
||||
/// Distinct edges in a stable order (the order of `edges()` and `tessellate().edges`).
|
||||
pub(crate) fn unique_edges(solid: &Solid) -> Vec<mt::Edge> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
@@ -963,6 +1047,31 @@ impl Body {
|
||||
})
|
||||
}
|
||||
|
||||
/// The display tessellation as a plan for the CPU or GPU evaluator: every face's
|
||||
/// triangulation in its own parameters, with each edge sampled once (chord tolerance
|
||||
/// `tol`, mm) so faces meet exactly.
|
||||
pub fn display_plan(&self, tol: f64) -> Result<solidcraft_geom::tess::TessPlan> {
|
||||
self.plan_within(tol, None)
|
||||
}
|
||||
|
||||
/// Like [`Body::display_plan`], and no facet turns more than `angle` radians (see
|
||||
/// [`Body::display_mesh_angular`]).
|
||||
pub fn display_plan_angular(&self, tol: f64, angle: f64) -> Result<solidcraft_geom::tess::TessPlan> {
|
||||
self.plan_within(tol, Some(angle))
|
||||
}
|
||||
|
||||
fn plan_within(&self, tol: f64, angle: Option<f64>) -> Result<solidcraft_geom::tess::TessPlan> {
|
||||
if self.is_mesh() {
|
||||
return Err(KernelError::Invalid("a mesh body has no surfaces to tessellate".into()));
|
||||
}
|
||||
let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.05 };
|
||||
let tol = tol.max(self.size() * 1e-6);
|
||||
// The same angle and floor as the reference mesher's angular display mesh.
|
||||
let turn =
|
||||
angle.filter(|a| a.is_finite()).map(|a| crate::tess::size::Turn { angle: a.max(MIN_DISPLAY_ANGLE), floor: tol * DISPLAY_ANGLE_FLOOR });
|
||||
guard("tessellate", || crate::tess::plan(&self.solid, tol, turn).map(|(plan, _)| plan))
|
||||
}
|
||||
|
||||
/// Faces with no triangles in a coarse tessellation (the mesher failed or gave up).
|
||||
pub fn unmeshed_faces(&self) -> usize {
|
||||
if self.is_mesh() {
|
||||
|
||||
@@ -146,6 +146,35 @@ pub(crate) fn heal_body(body: &Body, solid: Solid, size: f64, keep: &[Vec<Vec3>]
|
||||
heal_build(solid, size, keep, faces, edges, counts)
|
||||
}
|
||||
|
||||
/// A prism boolean's result `body` with the coplanar pieces its slabs leave merged (nothing
|
||||
/// else healed: its edges are exact as they are), its history carried through, and what else it carries kept: each face's
|
||||
/// construction source where every piece merged into it had that same one, its Split Face
|
||||
/// lines and its regularization protection.
|
||||
pub(crate) fn heal_prism_result(body: &Body, size: f64, keep: &[Vec<Vec3>], counts: &[(usize, usize)]) -> Result<Body> {
|
||||
let (faces, edges): (Option<Vec<FaceProv>>, Option<Vec<Vec<EdgeOrigin>>>) =
|
||||
body.history().map(|h| (h.faces.clone(), h.edges.iter().map(|e| e.origins.clone()).collect())).unzip();
|
||||
let (healed, map) = crate::heal::merge_faces_traced((*body.solid).clone(), size, keep);
|
||||
let sources = match (body.face_sources(), &map) {
|
||||
(Some(sources), Some(m)) => Some(
|
||||
m.faces
|
||||
.iter()
|
||||
.map(|under| {
|
||||
let mut each = under.iter().map(|i| sources.get(*i).cloned().flatten());
|
||||
let first = each.next().flatten()?;
|
||||
each.all(|s| s.as_ref() == Some(&first)).then_some(first)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
let carried = faces.zip(edges).zip(map).and_then(|((f, e), m)| m.carry(&f, &e));
|
||||
let (faces, edges) = carried.unzip();
|
||||
let mut out = record::build(healed, counts, faces, edges.as_ref())?;
|
||||
out = out.with_face_sources(sources.map(std::sync::Arc::new)).inherit_regularization_protection(body);
|
||||
out.splits = body.splits.clone();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `body` healed as the local modifiers take it (coplanar neighbours as one face, as after a
|
||||
/// join; faces split on purpose kept apart), with its history over `[body]`.
|
||||
pub(crate) fn healed(body: &Body) -> Result<Body> {
|
||||
|
||||
+59
-23
@@ -31,11 +31,41 @@ fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, S
|
||||
if !lp.is_closed(1e-6) {
|
||||
return Err(KernelError::Invalid("profile loop is not closed".into()));
|
||||
}
|
||||
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
|
||||
chain_sources(plane, &lp.segs, true)
|
||||
}
|
||||
|
||||
/// An open wire in 3D through a chain of sketch segments, each starting where the one before
|
||||
/// ends (a closed chain makes a closed wire).
|
||||
pub(crate) fn chain_wire(plane: &Plane, segs: &[Seg2]) -> Result<mt::Wire> {
|
||||
let n = segs.len();
|
||||
if n == 0 || n > MAX_SEGS {
|
||||
return Err(KernelError::Invalid(format!("curve chain with {n} segments")));
|
||||
}
|
||||
if !segs.iter().all(seg_ok) {
|
||||
return Err(KernelError::Invalid("non-finite curve".into()));
|
||||
}
|
||||
for (s, t) in segs.iter().zip(segs.iter().skip(1)) {
|
||||
if s.end().dist(t.start()) > 1e-6 {
|
||||
return Err(KernelError::Invalid("the curves are not connected end to end".into()));
|
||||
}
|
||||
}
|
||||
let closed = segs.first().zip(segs.last()).is_some_and(|(f, l)| l.end().dist(f.start()) <= 1e-6);
|
||||
chain_sources(plane, segs, closed).map(|(wire, _)| wire)
|
||||
}
|
||||
|
||||
/// The edges of a chain of segments (closed: the last one ends on the first's start vertex),
|
||||
/// with each edge's segment and that segment's position in the chain (a big arc makes two
|
||||
/// edges).
|
||||
fn chain_sources(plane: &Plane, segs: &[Seg2], closed: bool) -> Result<(mt::Wire, Vec<(mt::Edge, Seg2, usize)>)> {
|
||||
let n = segs.len();
|
||||
let mut verts: Vec<mt::Vertex> = segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
|
||||
if !closed && let Some(last) = segs.last() {
|
||||
verts.push(builder::vertex(p3(plane.to_world(last.end()))));
|
||||
}
|
||||
let mut edges = Vec::with_capacity(n);
|
||||
let mut sources = Vec::with_capacity(n);
|
||||
for (i, s) in lp.segs.iter().enumerate() {
|
||||
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { continue };
|
||||
for (i, s) in segs.iter().enumerate() {
|
||||
let (Some(a), Some(b)) = (verts.get(i), verts.get(if closed { (i + 1) % n } else { i + 1 })) else { continue };
|
||||
let e: mt::Edge = match *s {
|
||||
Seg2::Line { .. } => builder::line(a, b),
|
||||
Seg2::Arc { sweep, .. } => {
|
||||
@@ -128,32 +158,38 @@ fn region_ok(r: &Region2) -> Result<()> {
|
||||
if r.area().abs() < 1e-9 {
|
||||
return Err(KernelError::Invalid("profile has no area".into()));
|
||||
}
|
||||
for s in std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs) {
|
||||
let ok = match *s {
|
||||
Seg2::Line { a, b } => a.is_finite() && b.is_finite(),
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
center.is_finite() && radius.is_finite() && radius > 1e-9 && start.is_finite() && sweep.is_finite()
|
||||
}
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => p0.is_finite() && p1.is_finite() && p2.is_finite() && p3.is_finite(),
|
||||
Seg2::Conic { a, apex, b, w } => a.is_finite() && apex.is_finite() && b.is_finite() && w.is_finite() && w > 1e-9,
|
||||
Seg2::Bezier { n, p } => (4..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) && p.iter().all(|q| q.is_finite()),
|
||||
};
|
||||
if !ok {
|
||||
return Err(KernelError::Invalid("non-finite profile".into()));
|
||||
}
|
||||
if !std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).all(seg_ok) {
|
||||
return Err(KernelError::Invalid("non-finite profile".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A segment with finite, well-formed numbers.
|
||||
fn seg_ok(s: &Seg2) -> bool {
|
||||
match *s {
|
||||
Seg2::Line { a, b } => a.is_finite() && b.is_finite(),
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
center.is_finite() && radius.is_finite() && radius > 1e-9 && start.is_finite() && sweep.is_finite()
|
||||
}
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => p0.is_finite() && p1.is_finite() && p2.is_finite() && p3.is_finite(),
|
||||
Seg2::Conic { a, apex, b, w } => a.is_finite() && apex.is_finite() && b.is_finite() && w.is_finite() && w > 1e-9,
|
||||
Seg2::Bezier { n, p } => (4..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) && p.iter().all(|q| q.is_finite()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A finite, orthonormal sketch frame.
|
||||
pub(crate) fn frame_ok(plane: &Plane) -> bool {
|
||||
plane.origin.is_finite()
|
||||
&& plane.x.is_finite()
|
||||
&& plane.y.is_finite()
|
||||
&& (plane.x.len2() - 1.).abs() <= 1e-8
|
||||
&& (plane.y.len2() - 1.).abs() <= 1e-8
|
||||
&& plane.x.dot(plane.y).abs() <= 1e-8
|
||||
}
|
||||
|
||||
/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`).
|
||||
pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
|
||||
if !plane.origin.is_finite()
|
||||
|| !plane.x.is_finite()
|
||||
|| !plane.y.is_finite()
|
||||
|| (plane.x.len2() - 1.).abs() > 1e-8
|
||||
|| (plane.y.len2() - 1.).abs() > 1e-8
|
||||
|| plane.x.dot(plane.y).abs() > 1e-8
|
||||
{
|
||||
if !frame_ok(plane) {
|
||||
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
|
||||
}
|
||||
let (start, end) = (finite(start, "start")?, finite(end, "end")?);
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Has a face room to move?
|
||||
//!
|
||||
//! Coincident faces are pushed apart before a boolean (see [`crate::coplanar`]). That leaves
|
||||
//! the result as it was only where the space a face moves through holds nothing of the other
|
||||
//! body (a cut), or nothing but its material (a join). A [`Clearance`] decides that on the
|
||||
//! other body's faces: every triangle of its mesh is clipped against that space, so a wall, a
|
||||
//! rib or a hole anywhere in it is found. Sample points miss what lies between them.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use solidcraft_geom::{Aabb3, Mesh, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::offset::{Surf, surf_of};
|
||||
|
||||
/// How a body's material sits in a region of space.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Occupancy {
|
||||
/// The region holds none of it.
|
||||
Empty,
|
||||
/// The region lies wholly in it.
|
||||
Full,
|
||||
/// A face reaches into the region, or may: no answer.
|
||||
Mixed,
|
||||
}
|
||||
|
||||
/// A convex region: the points `p` with `n · p <= d` for every side `(n, d)`, `n` a unit vector
|
||||
/// pointing out of it.
|
||||
struct Convex(Vec<(Vec3, f64)>);
|
||||
|
||||
impl Convex {
|
||||
/// The prism a triangle sweeps when it moves along the unit vector `along` (not in its
|
||||
/// plane) from the distance `lo` to `hi`, its sides moved out by `margin` (in, when
|
||||
/// negative). `None` for a triangle without area or an empty interval.
|
||||
fn swept(tri: [Vec3; 3], along: Vec3, margin: f64, lo: f64, hi: f64) -> Option<Convex> {
|
||||
let [a, b, c] = tri;
|
||||
let centre = (a + b + c) / 3.0;
|
||||
if hi <= lo || !(margin + lo + hi).is_finite() || (b - a).cross(c - a).dot(along).abs() < 1e-300 {
|
||||
return None;
|
||||
}
|
||||
let mut sides = Vec::with_capacity(5);
|
||||
for (p, q) in [(a, b), (b, c), (c, a)] {
|
||||
let mut out = (q - p).cross(along).normalized()?;
|
||||
if out.dot(centre - p) > 0.0 {
|
||||
out = -out;
|
||||
}
|
||||
sides.push((out, out.dot(p) + margin));
|
||||
}
|
||||
let base = along.dot(a);
|
||||
sides.push((along, base + hi));
|
||||
sides.push((-along, -(base + lo)));
|
||||
Some(Convex(sides))
|
||||
}
|
||||
|
||||
fn contains(&self, p: Vec3) -> bool {
|
||||
self.0.iter().all(|(n, d)| n.dot(p) <= *d)
|
||||
}
|
||||
|
||||
/// Does the triangle reach into the region? Touching its boundary counts.
|
||||
fn meets(&self, tri: [Vec3; 3]) -> bool {
|
||||
// A side with the whole triangle beyond it settles most triangles at once.
|
||||
if self.0.iter().any(|(n, d)| tri.iter().all(|p| n.dot(*p) > *d)) {
|
||||
return false;
|
||||
}
|
||||
// What the sides leave of the triangle, one side after the other.
|
||||
let mut left: Vec<Vec3> = tri.to_vec();
|
||||
let mut next: Vec<Vec3> = Vec::with_capacity(tri.len() + self.0.len());
|
||||
for (n, d) in &self.0 {
|
||||
let Some(&last) = left.last() else { return false };
|
||||
next.clear();
|
||||
let (mut p, mut sp) = (last, n.dot(last) - d);
|
||||
for &q in &left {
|
||||
let sq = n.dot(q) - d;
|
||||
if (sp < 0.0 && sq > 0.0) || (sp > 0.0 && sq < 0.0) {
|
||||
next.push(p + (q - p) * (sp / (sp - sq)));
|
||||
}
|
||||
if sq <= 0.0 {
|
||||
next.push(q);
|
||||
}
|
||||
(p, sp) = (q, sq);
|
||||
}
|
||||
std::mem::swap(&mut left, &mut next);
|
||||
}
|
||||
!left.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a face can stand off its triangles, seen from a region along one direction.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Reach {
|
||||
/// Not at all: a planar face, a facet of a mesh body.
|
||||
Exact,
|
||||
/// Sideways only: a wall of straight lines along the direction (a hole drilled along it).
|
||||
Sideways,
|
||||
/// By the chord error, any way.
|
||||
Around,
|
||||
/// By the chord error, to one side: a face of a cylinder, a cone or a sphere lies beyond its
|
||||
/// triangles seen from the material when it is `convex`, seen from outside when it is not.
|
||||
Beyond { convex: bool },
|
||||
/// It ends before the region begins or begins past its end (a fillet running into the
|
||||
/// plane the region starts from).
|
||||
Never,
|
||||
}
|
||||
|
||||
/// A body's faces, for telling how its material sits in the space a face moves through.
|
||||
pub(crate) struct Clearance<'a> {
|
||||
body: &'a Body,
|
||||
mesh: Mesh,
|
||||
/// How far a curved face may stand off its triangles.
|
||||
sag: f64,
|
||||
tol: f64,
|
||||
}
|
||||
|
||||
/// Pairs of triangles one question may compare; beyond it there is no answer.
|
||||
const MAX_PAIRS: usize = 2_000_000;
|
||||
|
||||
impl<'a> Clearance<'a> {
|
||||
/// `chord`: the chord error of the mesh the body is asked on.
|
||||
pub(crate) fn new(body: &'a Body, chord: f64) -> Option<Clearance<'a>> {
|
||||
let mesh = body.tessellate_with(chord, true).ok()?;
|
||||
(!mesh.triangles.is_empty()).then(|| Clearance { body, mesh, sag: 2.0 * chord, tol: (body.size() * 1e-7).max(1e-9) })
|
||||
}
|
||||
|
||||
/// How the body's material sits in the space that `faces` move through when they go along
|
||||
/// the unit vector `along` from the distance `lo` to `hi`. `faces`: the triangles of
|
||||
/// planar faces square to `along`, each with how far its face reaches past it sideways
|
||||
/// (negative: the triangle's sides are drawn in, so that faces lying against them don't
|
||||
/// count). `Mixed` as soon as a face of the body reaches into that space.
|
||||
pub(crate) fn swept(&self, faces: &[([Vec3; 3], f64)], along: Vec3, lo: f64, hi: f64) -> Occupancy {
|
||||
let Some((first, _)) = faces.first() else { return Occupancy::Mixed };
|
||||
let base = along.dot(first[0]);
|
||||
// The body's triangles near that space.
|
||||
let mut space = Aabb3::EMPTY;
|
||||
let mut pad = self.sag;
|
||||
for (tri, margin) in faces {
|
||||
pad = pad.max(self.sag + margin);
|
||||
tri.iter().for_each(|p| [lo - self.sag, hi + self.sag].iter().for_each(|s| space.add(*p + along * *s)));
|
||||
}
|
||||
let (min, max) = (space.min - Vec3::new(pad, pad, pad), space.max + Vec3::new(pad, pad, pad));
|
||||
let outside = |tri: &[Vec3; 3]| {
|
||||
let all = |f: &dyn Fn(Vec3) -> bool| tri.iter().all(|p| f(*p));
|
||||
all(&|p| p.x < min.x)
|
||||
|| all(&|p| p.x > max.x)
|
||||
|| all(&|p| p.y < min.y)
|
||||
|| all(&|p| p.y > max.y)
|
||||
|| all(&|p| p.z < min.z)
|
||||
|| all(&|p| p.z > max.z)
|
||||
};
|
||||
let mut near: Vec<(u32, [Vec3; 3])> = Vec::new();
|
||||
for (t, face) in self.mesh.triangles.iter().zip(&self.mesh.tri_face) {
|
||||
let Some(tri) = self.mesh.tri(t) else { return Occupancy::Mixed };
|
||||
if !outside(&tri) {
|
||||
near.push((*face, tri));
|
||||
}
|
||||
}
|
||||
if faces.len().saturating_mul(near.len()) > MAX_PAIRS {
|
||||
return Occupancy::Mixed;
|
||||
}
|
||||
let inside = self.mesh.inside_index();
|
||||
let mut reach: HashMap<u32, Reach> = HashMap::new();
|
||||
let mut all = None;
|
||||
for (tri, margin) in faces {
|
||||
let prism = |side: f64, end: f64| Convex::swept(*tri, along, margin + side, lo - end, hi + end);
|
||||
let probe = (tri[0] + tri[1] + tri[2]) / 3.0 + along * ((lo + hi) * 0.5);
|
||||
// A sliver thinner than its margins covers nothing that its neighbours don't.
|
||||
let Some(tight) = prism(0.0, 0.0).filter(|p| p.contains(probe)) else { continue };
|
||||
let (Some(sideways), Some(around)) = (prism(self.sag, 0.0), prism(self.sag, self.sag)) else { return Occupancy::Mixed };
|
||||
// With no face in it, the prism is all material or all empty.
|
||||
let here = if inside.contains(probe) { Occupancy::Full } else { Occupancy::Empty };
|
||||
let reaches = |(face, t): &(u32, [Vec3; 3])| {
|
||||
around.meets(*t)
|
||||
&& match *reach.entry(*face).or_insert_with(|| self.reach(*face, along, base + lo, base + hi)) {
|
||||
Reach::Exact => tight.meets(*t),
|
||||
Reach::Sideways => sideways.meets(*t),
|
||||
// Its triangles lie between the prism and the face.
|
||||
Reach::Beyond { convex } if convex == (here == Occupancy::Full) => tight.meets(*t),
|
||||
Reach::Around | Reach::Beyond { .. } => true,
|
||||
Reach::Never => false,
|
||||
}
|
||||
};
|
||||
if near.iter().any(reaches) {
|
||||
return Occupancy::Mixed;
|
||||
}
|
||||
if all.is_some_and(|seen| seen != here) {
|
||||
return Occupancy::Mixed;
|
||||
}
|
||||
all = Some(here);
|
||||
}
|
||||
all.unwrap_or(Occupancy::Mixed)
|
||||
}
|
||||
|
||||
/// Has the body nothing between the heights `lo` and `hi` above the plane `n · p = d` (`n`
|
||||
/// a unit vector), anywhere?
|
||||
pub(crate) fn clear_of_slab(&self, n: Vec3, d: f64, lo: f64, hi: f64) -> bool {
|
||||
let mut reach: HashMap<u32, Reach> = HashMap::new();
|
||||
self.mesh.triangles.iter().zip(&self.mesh.tri_face).all(|(t, face)| {
|
||||
let Some(tri) = self.mesh.tri(t) else { return false };
|
||||
let pad = match *reach.entry(*face).or_insert_with(|| self.reach(*face, n, d + lo, d + hi)) {
|
||||
Reach::Never => return true,
|
||||
Reach::Around | Reach::Beyond { .. } => self.sag,
|
||||
Reach::Exact | Reach::Sideways => 0.0,
|
||||
};
|
||||
let heights = tri.map(|p| n.dot(p) - d);
|
||||
heights.iter().all(|s| s + pad < lo) || heights.iter().all(|s| s - pad > hi)
|
||||
})
|
||||
}
|
||||
|
||||
/// How the face's triangles stand for it in a region between the heights `lo` and `hi`
|
||||
/// along `along` (measured as `along · p`).
|
||||
fn reach(&self, face: u32, along: Vec3, lo: f64, hi: f64) -> Reach {
|
||||
if self.body.is_mesh() {
|
||||
return Reach::Exact;
|
||||
}
|
||||
crate::guard("face reach", || {
|
||||
let Some(f) = self.body.solid.face_iter().nth(face as usize) else { return Ok(Reach::Around) };
|
||||
let surface = f.oriented_surface();
|
||||
if matches!(surface, mt::Surface::Plane(_)) {
|
||||
return Ok(Reach::Exact);
|
||||
}
|
||||
let round = surf_of(f, self.tol * 100.0);
|
||||
let straight = matches!(round, Some(Surf::Cylinder { a, .. }) if a.dot(along).abs() > 1.0 - 1e-12)
|
||||
|| crate::prism::wall_along(&surface, along, self.tol * 10.0, &mut 262_144);
|
||||
Ok(if straight {
|
||||
Reach::Sideways
|
||||
} else if round.and_then(|s| span(&s, along)).is_some_and(|(s0, s1)| s1 <= lo || s0 >= hi) {
|
||||
Reach::Never
|
||||
} else if let Some(Surf::Cylinder { convex, .. } | Surf::Cone { convex, .. } | Surf::Sphere { convex, .. }) = round {
|
||||
Reach::Beyond { convex }
|
||||
} else {
|
||||
Reach::Around
|
||||
})
|
||||
})
|
||||
.unwrap_or(Reach::Around)
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole extent of a round surface along `along`, where it has one.
|
||||
fn span(s: &Surf, along: Vec3) -> Option<(f64, f64)> {
|
||||
let about = |c: Vec3, r: f64| Some((along.dot(c) - r.abs(), along.dot(c) + r.abs()));
|
||||
match *s {
|
||||
Surf::Cylinder { o, a, r, .. } if a.dot(along).abs() < 1e-9 => about(o, r),
|
||||
Surf::Sphere { c, r, .. } => about(c, r),
|
||||
Surf::Torus { o, a, h, r, .. } if a.dot(along).abs() > 1.0 - 1e-12 => about(o + a * h, r),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{BoolOp, boolean, box_solid, cylinder, fillet};
|
||||
|
||||
fn clearance(body: &Body) -> Clearance<'_> {
|
||||
Clearance::new(body, body.size() * 2e-4).unwrap()
|
||||
}
|
||||
|
||||
/// The top face of a 200 x 100 x 20 block between x = `x0` and `x1`, y = 10 and 90, as two
|
||||
/// triangles with their sides drawn in by 1e-4.
|
||||
fn top(x0: f64, x1: f64) -> Vec<([Vec3; 3], f64)> {
|
||||
let p = |x: f64, y: f64| Vec3::new(x, y, 20.0);
|
||||
vec![([p(x0, 10.0), p(x1, 10.0), p(x1, 90.0)], -1e-4), ([p(x0, 10.0), p(x1, 90.0), p(x0, 90.0)], -1e-4)]
|
||||
}
|
||||
|
||||
/// The space over a block's top face is empty and the space under it is material, up to
|
||||
/// the smallest thing that stands in it, wherever that is.
|
||||
#[test]
|
||||
fn the_space_a_face_moves_through_is_empty_or_material_until_something_stands_in_it() {
|
||||
let block = box_solid(Vec3::ZERO, Vec3::new(200.0, 100.0, 20.0)).unwrap();
|
||||
let c = clearance(&block);
|
||||
assert_eq!(c.swept(&top(10.0, 190.0), Vec3::Z, 1e-4, 3.0), Occupancy::Empty);
|
||||
assert_eq!(c.swept(&top(10.0, 190.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Full);
|
||||
// 25 down is through the bottom face (the block is 20 thick); 210 is past its side x = 200.
|
||||
assert_eq!(c.swept(&top(10.0, 190.0), -Vec3::Z, 1e-4, 25.0), Occupancy::Mixed);
|
||||
assert_eq!(c.swept(&top(10.0, 210.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
|
||||
assert_eq!(c.swept(&[], Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
|
||||
assert!(c.clear_of_slab(Vec3::Z, 20.0, 1e-4, 3.0) && !c.clear_of_slab(Vec3::Z, 20.0, -3.0, -1e-4));
|
||||
// A pin 0.4 x 0.4 on the top face, at x = 137.3 to 137.7: in the way of what covers it only.
|
||||
let pin = box_solid(Vec3::new(137.3, 61.1, 20.0), Vec3::new(137.7, 61.5, 26.0)).unwrap();
|
||||
let pinned = boolean(&block, &pin, BoolOp::Union).unwrap().unwrap();
|
||||
let c = clearance(&pinned);
|
||||
assert_eq!(c.swept(&top(10.0, 190.0), Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
|
||||
assert_eq!(c.swept(&top(10.0, 137.0), Vec3::Z, 1e-4, 3.0), Occupancy::Empty);
|
||||
assert!(!c.clear_of_slab(Vec3::Z, 20.0, 1e-4, 3.0) && c.clear_of_slab(Vec3::Z, 20.0, 6.0 + 1e-4, 9.0));
|
||||
// A 4 mm hole through the block at x = 60: no material all the way under what covers it.
|
||||
let hole = cylinder(Vec3::new(60.0, 50.0, -5.0), Vec3::Z, 2.0, 30.0).unwrap();
|
||||
let holed = boolean(&block, &hole, BoolOp::Cut).unwrap().unwrap();
|
||||
let c = clearance(&holed);
|
||||
assert_eq!(c.swept(&top(10.0, 190.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
|
||||
assert_eq!(c.swept(&top(70.0, 190.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Full);
|
||||
}
|
||||
|
||||
/// Round faces that end in the plane a face lies in leave the space beyond it free: a
|
||||
/// hole's wall, a fillet running into the top face. A round face that rises into that space
|
||||
/// by less than its chords show does not.
|
||||
#[test]
|
||||
fn round_faces_ending_at_the_plane_leave_the_space_beyond_it_free() {
|
||||
let block = box_solid(Vec3::ZERO, Vec3::new(200.0, 100.0, 20.0)).unwrap();
|
||||
let hole = cylinder(Vec3::new(60.0, 50.0, -5.0), Vec3::Z, 8.0, 30.0).unwrap();
|
||||
let holed = boolean(&block, &hole, BoolOp::Cut).unwrap().unwrap();
|
||||
// The fillet (r = 4) along the edge y = 0 of the top face ends on the line y = 4, z = 20.
|
||||
let rounded = fillet(&holed, &[Vec3::new(100.0, 0.0, 20.0)], 4.0).unwrap();
|
||||
let c = clearance(&rounded);
|
||||
// The top face from x = `x0` to 190 and y = `y0` to 90.
|
||||
let rect = |x0: f64, y0: f64| {
|
||||
let p = |x: f64, y: f64| Vec3::new(x, y, 20.0);
|
||||
vec![([p(x0, y0), p(190.0, y0), p(190.0, 90.0)], -1e-4), ([p(x0, y0), p(190.0, 90.0), p(x0, 90.0)], -1e-4)]
|
||||
};
|
||||
assert_eq!(c.swept(&rect(10.0, -10.0), Vec3::Z, 1e-4, 3.0), Occupancy::Empty);
|
||||
assert!(c.clear_of_slab(Vec3::Z, 20.0, 1e-4, 3.0));
|
||||
// Under the top face the fillet is in the way from its tangent line y = 4 down (and no
|
||||
// further: its chords lie inside it), the hole from x = 68 down.
|
||||
assert_eq!(c.swept(&rect(80.0, 4.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Full);
|
||||
assert_eq!(c.swept(&rect(80.0, 3.9), -Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
|
||||
assert_eq!(c.swept(&rect(60.0, 5.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
|
||||
// A bar lying along x (axis at z = 12): its top ends 0.05 um short of where the space
|
||||
// begins (z = 20.0001), then reaches 0.9 um into it.
|
||||
for (radius, want) in [(8.00005, Occupancy::Empty), (8.001, Occupancy::Mixed)] {
|
||||
let bar = cylinder(Vec3::new(20.0, 50.0, 12.0), Vec3::X, radius, 160.0).unwrap();
|
||||
assert_eq!(clearance(&bar).swept(&top(30.0, 150.0), Vec3::Z, 1e-4, 3.0), want, "radius {radius}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,17 @@
|
||||
//! - union of faces facing the same way: both faces move outward by different amounts, the
|
||||
//! union is taken, and the slab beyond the plane is cut off (now a transversal cut);
|
||||
//! - union of faces back to back: the tool's face moves into the target, which overlaps it.
|
||||
//!
|
||||
//! Each of these leaves the result unchanged only where the space a face moves through holds
|
||||
//! what the boolean leaves there anyway: none of the target for a cut or an intersection, the
|
||||
//! target's material for a union, nothing of either body beyond a plane whose slab is cut off
|
||||
//! again. The faces of a coincident plane lie on each other, but nothing says that what lies
|
||||
//! beside them does: a tool's side in the face of a wall has the neighbouring walls beside it,
|
||||
//! a channel's top in the face a rib stands on has the rib over it. So a face is asked first
|
||||
//! what it would move through: a join's face moving into the target at points behind it
|
||||
//! (`backed`), the others on the other body's faces (see [`crate::clearance`]). A face with
|
||||
//! no room stays where it is and the boolean fails with [`NO_ROOM`], for the strategies that
|
||||
//! take the bodies as they lie.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -18,6 +29,7 @@ use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::body::shell_groups::boxes_separate;
|
||||
use crate::body::{Body, Solid, from_p3, p3, v3};
|
||||
use crate::clearance::{Clearance, Occupancy};
|
||||
use crate::history::{Change, EdgeOrigin, FaceOrigin, FaceProv, History};
|
||||
use crate::{BoolOp, KernelError, Result, guard, record};
|
||||
|
||||
@@ -307,8 +319,12 @@ pub(crate) fn coincident_planes(a: &Body, b: &Body) -> Vec<(Vec3, f64, bool)> {
|
||||
found
|
||||
}
|
||||
|
||||
/// What [`boolean_apart`] fails with when a face has no room to move.
|
||||
pub(crate) const NO_ROOM: &str = "coincident faces were left in place: pushing them apart would change the result";
|
||||
|
||||
/// Retry a failed boolean with coincident planar faces pushed apart. `None` when there are no
|
||||
/// coincident faces or the faces can't be pushed.
|
||||
/// coincident faces or the faces can't be pushed; the error [`NO_ROOM`] when a face would
|
||||
/// move through something that changes the result.
|
||||
pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
let size = a.size().max(b.size());
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
@@ -320,12 +336,31 @@ pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
|
||||
let back: Vec<(Vec3, f64)> = coincident.iter().filter(|(_, _, s)| !*s).map(|(n, d, _)| (*n, *d)).collect();
|
||||
let (d1, d2) = (size * 0.0137, size * 0.0229);
|
||||
let counts = record::counts(&[a, b]);
|
||||
let (skin, chord) = (tol * 10.0, size * 2e-4);
|
||||
let no_room = || KernelError::Failed(NO_ROOM.into());
|
||||
let run = || -> Result<Option<Body>> {
|
||||
// The target's faces, meshed when the first face asks what it would move through.
|
||||
let faces_of_a = std::cell::OnceCell::new();
|
||||
let target = || faces_of_a.get_or_init(|| Clearance::new(a, chord)).as_ref().ok_or_else(no_room);
|
||||
let (mut a2, mut b2) = (a.clone(), b.clone());
|
||||
// What the pushed bodies are made of: their histories over [a] and [b].
|
||||
let (mut ha, mut hb) = (crate::boolean_record::kept(a, 0, 1), crate::boolean_record::kept(b, 0, 1));
|
||||
for (n, d) in &same {
|
||||
// b's face points along n too.
|
||||
let free = if op == BoolOp::Union {
|
||||
// Both faces move out and the slab beyond the plane is cut off the joined body
|
||||
// again, everywhere: neither body may have anything in it.
|
||||
let depth = d1.max(d2) * 3.0 * 1.25;
|
||||
let tool = Clearance::new(b, chord).ok_or_else(no_room)?;
|
||||
target()?.clear_of_slab(*n, *d, skin, depth) && tool.clear_of_slab(*n, *d, skin, depth)
|
||||
} else {
|
||||
// The tool takes whatever of `a` its face moves through (a rib standing on the
|
||||
// face a cut ends in).
|
||||
target()?.swept(&in_plane(&b2, *n, *d, skin), *n, skin, d2 * 1.25) == Occupancy::Empty
|
||||
};
|
||||
if !free {
|
||||
return Err(no_room());
|
||||
}
|
||||
b2 = push_faces(&b2, *n, *d, d2)?;
|
||||
hb = crate::boolean_record::through(&b2, 0, hb.as_ref());
|
||||
if op == BoolOp::Union {
|
||||
@@ -346,6 +381,11 @@ pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
|
||||
.find(|depth| backed(a, b, -*n, -*d, *depth))
|
||||
.ok_or_else(|| KernelError::Failed("pushing a face: part of it lies over a hole or past the edge of the other body".into()))?
|
||||
} else {
|
||||
// Back into the tool, the face gives up what the tool took of `a` there (a
|
||||
// wall standing beside the face the tool's foot lies on).
|
||||
if target()?.swept(&in_plane(&b2, -*n, -*d, skin), *n, skin, d2 * 1.25) != Occupancy::Empty {
|
||||
return Err(no_room());
|
||||
}
|
||||
-d2
|
||||
};
|
||||
b2 = push_faces(&b2, -*n, -*d, shift)?;
|
||||
@@ -445,6 +485,26 @@ pub(crate) fn faces_touch(a: &Body, b: &Body) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// The triangles of `tool`'s faces in the plane (`m`, `d`), `m` their outward normal, each with
|
||||
/// how far its face reaches past it sideways. A face bounded by straight edges is its
|
||||
/// triangles; they are drawn in by `skin`, so that faces of the other body lying against the
|
||||
/// face's sides are not in its way. A face with a curved edge reaches past its triangles by
|
||||
/// the chord error.
|
||||
fn in_plane(tool: &Body, m: Vec3, d: f64, skin: f64) -> Vec<([Vec3; 3], f64)> {
|
||||
let chord = (tool.size() * 2e-4).max(skin);
|
||||
let Ok(mesh) = tool.tessellate_with(chord, true) else { return Vec::new() };
|
||||
let faces: Vec<&mt::Face> = tool.solid.face_iter().collect();
|
||||
let margins: HashMap<usize, f64> = planes(tool)
|
||||
.into_iter()
|
||||
.filter(|(_, p)| p.n.dot(m) > 1.0 - 1e-9 && (p.d - d).abs() < skin)
|
||||
.filter_map(|(i, _)| {
|
||||
let straight = faces.get(i)?.edge_iter().all(|e| matches!(e.curve(), mt::Curve::Line(_)));
|
||||
Some((i, if straight { -skin } else { 2.0 * chord }))
|
||||
})
|
||||
.collect();
|
||||
mesh.triangles.iter().zip(&mesh.tri_face).filter_map(|(t, face)| Some((mesh.tri(t)?, *margins.get(&(*face as usize))?))).collect()
|
||||
}
|
||||
|
||||
/// Has `a` material behind every part of b's faces in the plane (m, d) (m their outward
|
||||
/// normal, pointing into `a`), to `depth`? Checked at points inside the faces' triangles.
|
||||
fn backed(a: &Body, b: &Body, m: Vec3, d: f64, depth: f64) -> bool {
|
||||
@@ -468,6 +528,128 @@ fn backed(a: &Body, b: &Body, m: Vec3, d: f64, depth: f64) -> bool {
|
||||
!points.is_empty() && points.iter().all(|p| [0.05, 0.5, 1.0].iter().all(|k| inside.contains(*p + m * (depth * k))))
|
||||
}
|
||||
|
||||
/// The faces of `body` lying in the plane (n, d), facing along n.
|
||||
fn faces_in(body: &Body, n: Vec3, d: f64, tol: f64) -> Vec<usize> {
|
||||
planes(body).into_iter().filter(|(_, p)| p.n.dot(n) > 1.0 - 1e-9 && (p.d - d).abs() < tol).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// Do two face surfaces lie on one plane or one cylinder?
|
||||
fn same_surface(x: &Option<crate::FaceSurface>, y: &Option<crate::FaceSurface>, tol: f64) -> bool {
|
||||
use crate::FaceSurface::{Cylinder, Plane};
|
||||
match (x, y) {
|
||||
(Some(Plane { normal: n1, point: p1 }), Some(Plane { normal: n2, point: p2 })) => n1.dot(*n2) > 1.0 - 1e-9 && n1.dot(*p2 - *p1).abs() < tol,
|
||||
(Some(Cylinder { origin: o1, axis: a1, radius: r1 }), Some(Cylinder { origin: o2, axis: a2, radius: r2 })) => {
|
||||
let off = *o2 - *o1;
|
||||
a1.dot(*a2).abs() > 1.0 - 1e-9 && (r1 - r2).abs() < tol && (off - *a1 * off.dot(*a1)).len() < tol
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The box around faces `faces` of `body` (by their boundaries), grown by `gap`.
|
||||
fn faces_box(body: &Body, faces: &[usize], gap: f64) -> Option<Aabb3> {
|
||||
let fs: Vec<&mt::Face> = body.solid.face_iter().collect();
|
||||
let mut bb = Aabb3::EMPTY;
|
||||
for f in faces {
|
||||
let b = face_box(fs.get(*f)?, gap)?;
|
||||
bb.add(b.min);
|
||||
bb.add(b.max);
|
||||
}
|
||||
(!bb.is_empty()).then(|| Aabb3 { min: bb.min - Vec3::new(gap, gap, gap), max: bb.max + Vec3::new(gap, gap, gap) })
|
||||
}
|
||||
|
||||
/// `body` (with its history `h` over its operand) with its faces in the plane (n, d) split
|
||||
/// where the faces of `tool` next to the tool's faces in the opposite plane cross them: the
|
||||
/// outline of the tool's contact imprinted on the body's. Only the faces near the contact are
|
||||
/// split, and each surface once.
|
||||
fn imprinted(body: Body, mut h: Option<History>, n: Vec3, d: f64, tool: &Body, tol: f64) -> Result<(Body, Option<History>)> {
|
||||
let contact = faces_in(tool, -n, -d, tol);
|
||||
let gap = tol * 10.0;
|
||||
let Some(region) = faces_box(tool, &contact, gap) else { return Ok((body, h)) };
|
||||
let near = |b: &Body, faces: Vec<usize>| -> Vec<usize> {
|
||||
let fs: Vec<&mt::Face> = b.solid.face_iter().collect();
|
||||
faces.into_iter().filter(|f| fs.get(*f).and_then(|x| face_box(x, gap)).is_some_and(|bx| !boxes_separate(bx, region, gap))).collect()
|
||||
};
|
||||
let bounds = crate::history::edge_faces(&tool.solid);
|
||||
let mut beside: Vec<usize> = bounds
|
||||
.iter()
|
||||
.filter(|fs| fs.len() == 2 && fs.iter().any(|f| contact.contains(f)))
|
||||
.flat_map(|fs| fs.iter().copied())
|
||||
.filter(|f| !contact.contains(f))
|
||||
.collect();
|
||||
beside.sort_unstable();
|
||||
beside.dedup();
|
||||
let mut body = body;
|
||||
// One split per surface: the halves of a round side lie on one cylinder, and splitting
|
||||
// along it again re-traces the line already there, leaving a sliver (or not) depending
|
||||
// on how the second trace falls.
|
||||
let mut used: Vec<Option<crate::FaceSurface>> = Vec::new();
|
||||
for t in beside {
|
||||
let surface = crate::face_surface(tool, t);
|
||||
if used.iter().any(|u| same_surface(u, &surface, tol)) {
|
||||
continue;
|
||||
}
|
||||
used.push(surface);
|
||||
let faces = near(&body, faces_in(&body, n, d, tol));
|
||||
if faces.is_empty() {
|
||||
break;
|
||||
}
|
||||
// Faces the tool's face does not cross stay as they are; none crossed is an error.
|
||||
let Ok((split, _)) = crate::splitface::split_any(&body, &faces, &crate::SplitTool::Face { body: tool, face: t }) else { continue };
|
||||
h = crate::boolean_record::through(&split, 0, h.as_ref());
|
||||
body = split;
|
||||
}
|
||||
Ok((body, h))
|
||||
}
|
||||
|
||||
/// Union of bodies touching back to back over part of a planar face (a pad half past the edge
|
||||
/// of the face it stands on, two blocks meeting over some of their faces, #674, #679): each
|
||||
/// body's contact faces are split along the outline of the other's, so the common area is the
|
||||
/// same face in both, and those faces are glued away. `None` when that does not give a result
|
||||
/// `accept`ed.
|
||||
pub(crate) fn imprint_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) -> Option<Body> {
|
||||
let size = a.size().max(b.size());
|
||||
let tol = (size * 1e-7).max(1e-9) * 10.0;
|
||||
let back: Vec<(Vec3, f64)> = coincident_planes(a, b).into_iter().filter(|(_, _, same)| !*same).map(|(n, d, _)| (n, d)).collect();
|
||||
if back.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let run = || -> Result<Option<Body>> {
|
||||
let (mut a2, mut ha) = (a.clone(), crate::boolean_record::kept(a, 0, 1));
|
||||
let (mut b2, mut hb) = (b.clone(), crate::boolean_record::kept(b, 0, 1));
|
||||
for (n, d) in &back {
|
||||
// a's faces face along n in the plane; b's face the other way.
|
||||
(a2, ha) = imprinted(a2, ha, *n, *d, b, tol)?;
|
||||
(b2, hb) = imprinted(b2, hb, -*n, -*d, a, tol)?;
|
||||
}
|
||||
// The common faces' boundaries with the same vertices on both sides.
|
||||
for (n, d) in &back {
|
||||
let corners = |body: &Body, n: Vec3, d: f64| -> Vec<Vec3> {
|
||||
let fs: Vec<&mt::Face> = body.solid.face_iter().collect();
|
||||
let mut out: Vec<Vec3> =
|
||||
faces_in(body, n, d, tol).iter().filter_map(|i| fs.get(*i)).flat_map(|f| f.vertex_iter().map(|v| from_p3(v.point()))).collect();
|
||||
out.dedup_by(|x, y| x.dist(*y) <= tol);
|
||||
out
|
||||
};
|
||||
let (pa, pb) = (corners(&a2, *n, *d), corners(&b2, -*n, -*d));
|
||||
let a3 = crate::splitface::split_edges_at(&a2, &pb)?;
|
||||
ha = crate::boolean_record::through(&a3, 0, ha.as_ref());
|
||||
let b3 = crate::splitface::split_edges_at(&b2, &pa)?;
|
||||
hb = crate::boolean_record::through(&b3, 0, hb.as_ref());
|
||||
(a2, b2) = (a3, b3);
|
||||
}
|
||||
let Some(r) = glue(&a2, &b2) else { return Ok(None) };
|
||||
let r = r?;
|
||||
let (ha, hb) = (crate::boolean_record::placed(ha, 0, 2), crate::boolean_record::placed(hb, 1, 2));
|
||||
let h = crate::boolean_record::of_pair(&r, &ha, &hb);
|
||||
Ok(Some(r.with_checked_history(h, &record::counts(&[a, b]))))
|
||||
};
|
||||
match guard("imprint union", run) {
|
||||
Ok(Some(r)) if accept(&r) => Some(r),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Union of bodies that touch along identical planar faces, back to back (a body joined with
|
||||
/// its mirror image, or the pieces of a body cut apart by a plane, which may meet in several
|
||||
/// faces): every such pair of faces goes and the shells are stitched along their edges. `None`
|
||||
@@ -506,10 +688,20 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
let mut ok = true;
|
||||
for e in &eb {
|
||||
let ends = (key(from_p3(e.absolute_front().point())), key(from_p3(e.absolute_back().point())));
|
||||
let m = key(mid(e));
|
||||
let m = mid(e);
|
||||
// The same curve: the same ends and e's middle on f (curves of one shape may be
|
||||
// parametrised differently, so their parameter middles needn't coincide).
|
||||
let on = |f: &mt::Edge| {
|
||||
use mt::SearchNearestParameter;
|
||||
let c = f.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
key(mid(f)) == key(m)
|
||||
|| c.search_nearest_parameter(p3(m), None, 100)
|
||||
.is_some_and(|t| (t0.min(t1) - 1e-9..=t0.max(t1) + 1e-9).contains(&t) && from_p3(c.subs(t)).dist(m) <= tol)
|
||||
};
|
||||
let hit = ea.iter().find(|f| {
|
||||
let fe = (key(from_p3(f.absolute_front().point())), key(from_p3(f.absolute_back().point())));
|
||||
(fe == ends || fe == (ends.1, ends.0)) && key(mid(f)) == m
|
||||
(fe == ends || fe == (ends.1, ends.0)) && on(f)
|
||||
});
|
||||
let Some(f) = hit else {
|
||||
ok = false;
|
||||
@@ -586,10 +778,16 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
}
|
||||
faces.push(nf);
|
||||
}
|
||||
let solid = Solid::try_new(vec![faces.into()]).map_err(|e| fail(&e.to_string()))?;
|
||||
// Bodies of several pieces stay in several pieces (each face's provenance follows it).
|
||||
let prov: HashMap<mt::FaceID, FaceProv> = faces.iter().map(|f| f.id()).zip(from).collect();
|
||||
let shell: mt::Shell = faces.into();
|
||||
let solid = Solid::try_new(crate::polybool::components_in_order(&shell)).map_err(|e| fail(&e.to_string()))?;
|
||||
let from: Vec<FaceProv> = solid.face_iter().filter_map(|f| prov.get(&f.id()).cloned()).collect();
|
||||
// Each edge's origins in the solid's edge order.
|
||||
let origins = crate::body::edge_order(solid.face_iter()).0.iter().map(|e| origins.get(&e.id()).cloned()).collect();
|
||||
crate::boolean_record::heal_build(solid, size, &[], Some(from), origins, &record::counts(&[a, b]))
|
||||
let keep: Vec<_> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
|
||||
crate::boolean_record::heal_build(solid, size, &keep, Some(from), origins, &record::counts(&[a, b]))
|
||||
.map(|body| body.with_split_lines(Some(std::sync::Arc::new(keep))))
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -869,3 +1067,7 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) ->
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "coplanar_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Booleans of bodies with faces in a common plane: a face is pushed out of the plane only where
|
||||
//! that leaves the result as it is. Every expected volume is what the two bodies define.
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use crate::*;
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
/// An l x w x h block with two bottom edges chamfered by 1 mm: no prism along any axis, so
|
||||
/// its booleans are not done on sections.
|
||||
fn block(l: f64, w: f64, h: f64) -> Body {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(l, w, h)).unwrap();
|
||||
let b = chamfer(&b, &[Vec3::new(l / 2.0, w, 0.0)], 1.0).unwrap();
|
||||
chamfer(&b, &[Vec3::new(l, w / 2.0, 0.0)], 1.0).unwrap()
|
||||
}
|
||||
|
||||
/// A 200 x 100 x 20 block with a rib across it: 20 wide over the block's 100, from the top
|
||||
/// face z = 20 up to z = 30.
|
||||
fn ribbed() -> Body {
|
||||
let rib = box_solid(Vec3::new(90.0, 0.0, 20.0), Vec3::new(110.0, 100.0, 30.0)).unwrap();
|
||||
boolean(&block(200.0, 100.0, 20.0), &rib, BoolOp::Union).unwrap().unwrap()
|
||||
}
|
||||
|
||||
/// A channel along that block, longer than its 200: y 30 to 70 and z 10 to 20, so its top
|
||||
/// lies in the plane of the face the rib stands on.
|
||||
fn channel() -> Body {
|
||||
box_solid(Vec3::new(-20.0, 30.0, 10.0), Vec3::new(220.0, 70.0, 20.0)).unwrap()
|
||||
}
|
||||
|
||||
/// What the boolean of `a` and `b` adds to a's volume, the result's lumps, and how it was made.
|
||||
fn change(a: &Body, b: &Body, op: BoolOp) -> (f64, usize, BooleanDiagnostics) {
|
||||
let (r, how) = capture_boolean_diagnostics(|| boolean(a, b, op));
|
||||
let r = r.unwrap().unwrap();
|
||||
(measure(&r).unwrap().volume - measure(a).unwrap().volume, r.lumps().unwrap().len(), how)
|
||||
}
|
||||
|
||||
fn used(how: &BooleanDiagnostics, strategy: BooleanStrategy) -> bool {
|
||||
how.events.iter().any(|e| e.strategy == strategy)
|
||||
}
|
||||
|
||||
/// A channel cut along a block, up to the face a rib stands on. The channel's top lies in
|
||||
/// that face's plane and the rib bridges it: pushed up out of the plane (2.29 % of the
|
||||
/// body's size, 5.6 mm), the top would cut a notch 20 x 40 into the rib.
|
||||
#[test]
|
||||
fn a_cut_up_to_a_face_leaves_what_stands_on_the_face() {
|
||||
let (added, lumps, how) = change(&ribbed(), &channel(), BoolOp::Cut);
|
||||
// It takes 200 x 40 x 10 of the block and nothing of the rib.
|
||||
assert!((added + 80_000.0).abs() < 1e-6 && lumps == 1, "{added}, {lumps} lumps, {how:?}");
|
||||
assert!(!used(&how, BooleanStrategy::CoplanarSeparation), "{how:?}");
|
||||
}
|
||||
|
||||
/// The same with a round hole through the block beside the channel (r 4 at 30, 15: y 11 to
|
||||
/// 19, the channel from 30 on). The body no longer has planar faces only, but the hole lies
|
||||
/// clear of the tool, and the cut stays what the channel is.
|
||||
#[test]
|
||||
fn a_round_hole_beside_the_cut_changes_nothing() {
|
||||
let hole = cylinder(Vec3::new(30.0, 15.0, -1.0), Vec3::Z, 4.0, 22.0).unwrap();
|
||||
let drilled = boolean(&ribbed(), &hole, BoolOp::Cut).unwrap().unwrap();
|
||||
let (added, lumps, how) = change(&drilled, &channel(), BoolOp::Cut);
|
||||
// 200 x 40 x 10 again (the hole is measured on its mesh, before and after).
|
||||
assert!((added + 80_000.0).abs() < 1e-3 && lumps == 1, "{added}, {lumps} lumps, {how:?}");
|
||||
assert!(!used(&how, BooleanStrategy::CoplanarSeparation), "{how:?}");
|
||||
}
|
||||
|
||||
/// With nothing in their way the faces are pushed as before: a pocket's top out of the plane
|
||||
/// of the block's top face, a pad's foot into the block. (This holds without the check too:
|
||||
/// it keeps the check from refusing what is harmless.)
|
||||
#[test]
|
||||
fn faces_with_room_are_pushed_apart() {
|
||||
let block = block(200.0, 100.0, 20.0);
|
||||
// A pocket 50 x 60 x 5 from the top face: 15 000 less.
|
||||
let pocket = box_solid(Vec3::new(75.0, 20.0, 15.0), Vec3::new(125.0, 80.0, 20.0)).unwrap();
|
||||
let (added, _, how) = change(&block, &pocket, BoolOp::Cut);
|
||||
assert!((added + 15_000.0).abs() < 1e-6 && used(&how, BooleanStrategy::CoplanarSeparation), "{added}, {how:?}");
|
||||
// A pad 50 x 60 x 10 on the top face: 30 000 more.
|
||||
let pad = box_solid(Vec3::new(75.0, 20.0, 20.0), Vec3::new(125.0, 80.0, 30.0)).unwrap();
|
||||
let (added, lumps, how) = change(&block, &pad, BoolOp::Union);
|
||||
assert!((added - 30_000.0).abs() < 1e-6 && lumps == 1 && used(&how, BooleanStrategy::CoplanarSeparation), "{added}, {how:?}");
|
||||
}
|
||||
@@ -327,3 +327,27 @@ fn rolling_ball_past_the_end_seam_is_too_large_and_reduces() {
|
||||
}
|
||||
assert!(worst < 1e-3, "blend surface is {worst} mm off the analytic round at r {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_edge_is_not_rolled_again_after_blends_far_from_it() {
|
||||
// The crossing cylinders' seam refuses a round of 3 (too large). A bar joined to them has
|
||||
// two straight edges at its far end that round on their own. Each blend there leaves the
|
||||
// seam's surroundings as they were, so the seam is tried once, not once per blend, and the
|
||||
// refusal is the same.
|
||||
let (r1, r2, d, h) = (17.0, 9.5, 12.0, 10.0);
|
||||
let body = boolean(&cylinder(Vec3::ZERO, Vec3::Z, r1, h).unwrap(), &cylinder(Vec3::new(d, 0.0, 0.0), Vec3::Z, r2, h).unwrap(), BoolOp::Union)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let bar = crate::box_solid(Vec3::new(-80.0, -5.0, 0.0), Vec3::new(-10.0, 5.0, h)).unwrap();
|
||||
let body = boolean(&body, &bar, BoolOp::Union).unwrap().unwrap();
|
||||
let along = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
|
||||
let seam = Vec3::new(along, (r1 * r1 - along * along).sqrt(), h / 2.0);
|
||||
let ends = [Vec3::new(-80.0, -5.0, h / 2.0), Vec3::new(-80.0, 5.0, h / 2.0)];
|
||||
crate::blend::ROLLS.with(|n| n.set(0));
|
||||
let e = fillet(&body, &[seam, ends[0], ends[1]], 3.0).unwrap_err();
|
||||
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
||||
let rolls = crate::blend::ROLLS.with(|n| n.get());
|
||||
assert_eq!(rolls, 1, "the seam was rolled {rolls} times");
|
||||
// The far edges alone still round.
|
||||
assert!(fillet(&body, &ends, 3.0).is_ok());
|
||||
}
|
||||
|
||||
+32
-16
@@ -120,10 +120,12 @@ fn merge_coplanar(shell: &mt::Shell, tol: f64, keep: &[Vec<Vec3>]) -> Option<(mt
|
||||
&& na.dot(*nb) > 1.0 - 1e-9
|
||||
&& (da - db).abs() < tol
|
||||
{
|
||||
// The lesser root wins, so each group's root is its first face whatever order the
|
||||
// edges (in hash order of their addresses) are met in (#707).
|
||||
let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
|
||||
if ra != rb {
|
||||
if let Some(x) = parent.get_mut(ra) {
|
||||
*x = rb;
|
||||
if let Some(x) = parent.get_mut(ra.max(rb)) {
|
||||
*x = ra.min(rb);
|
||||
}
|
||||
merged_any = true;
|
||||
}
|
||||
@@ -232,11 +234,13 @@ fn merge_collinear(shell: &mt::Shell, trace: &mut Trace) -> Option<mt::Shell> {
|
||||
let (pa, pm, pb) = (from_p3(other(a).point()), from_p3(mid.point()), from_p3(other(b).point()));
|
||||
let (Some(u), Some(w)) = ((pm - pa).normalized(), (pb - pm).normalized()) else { continue };
|
||||
if u.dot(w) > 1.0 - 1e-10 {
|
||||
// The lesser root wins: each run's root is its first edge whatever order the
|
||||
// vertices are met in (#707).
|
||||
let (ra, rb) = (find(&mut parent, ia), find(&mut parent, ib));
|
||||
if ra != rb
|
||||
&& let Some(x) = parent.get_mut(ra)
|
||||
&& let Some(x) = parent.get_mut(ra.max(rb))
|
||||
{
|
||||
*x = rb;
|
||||
*x = ra.min(rb);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
@@ -244,7 +248,9 @@ fn merge_collinear(shell: &mt::Shell, trace: &mut Trace) -> Option<mt::Shell> {
|
||||
if !any {
|
||||
return None;
|
||||
}
|
||||
let mut runs: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
// Ordered by run (and each run's ends by first use below): which end a joined edge starts
|
||||
// from must not follow hash order, or the same heal gives a different body from run to run.
|
||||
let mut runs: std::collections::BTreeMap<usize, Vec<usize>> = std::collections::BTreeMap::new();
|
||||
for i in 0..edges.len() {
|
||||
let r = find(&mut parent, i);
|
||||
runs.entry(r).or_default().push(i);
|
||||
@@ -253,14 +259,17 @@ fn merge_collinear(shell: &mt::Shell, trace: &mut Trace) -> Option<mt::Shell> {
|
||||
let mut joined: Vec<(mt::Edge, HashMap<mt::VertexID, usize>)> = Vec::new();
|
||||
let mut run_of: HashMap<mt::EdgeID, usize> = HashMap::new();
|
||||
for members in runs.values().filter(|m| m.len() > 1) {
|
||||
let mut ends: HashMap<mt::VertexID, (usize, mt::Vertex)> = HashMap::new();
|
||||
let mut ends: Vec<(mt::VertexID, usize, mt::Vertex)> = Vec::new();
|
||||
for i in members {
|
||||
let Some(e) = edges.get(*i) else { continue };
|
||||
for v in [e.front(), e.back()] {
|
||||
ends.entry(v.id()).or_insert((0, v.clone())).0 += 1;
|
||||
match ends.iter_mut().find(|(id, ..)| *id == v.id()) {
|
||||
Some(end) => end.1 += 1,
|
||||
None => ends.push((v.id(), 1, v.clone())),
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ends = ends.into_values().filter(|(n, _)| *n == 1).map(|(_, v)| v);
|
||||
let mut ends = ends.into_iter().filter(|(_, n, _)| *n == 1).map(|(.., v)| v);
|
||||
let (Some(first), Some(last), None) = (ends.next(), ends.next(), ends.next()) else { continue };
|
||||
// Walk the run from one end, collecting its vertices in order.
|
||||
let mut order = vec![first.id()];
|
||||
@@ -476,7 +485,7 @@ fn planar_surfaces(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
|
||||
/// Heal a solid; returns the input unchanged when nothing applies or healing fails.
|
||||
#[cfg(test)]
|
||||
pub fn heal(solid: Solid, size: f64) -> Solid {
|
||||
healed(solid, size, &[], false).0
|
||||
healed(solid, size, &[], false, false).0
|
||||
}
|
||||
|
||||
/// What healing made each face and edge of its result from.
|
||||
@@ -535,14 +544,21 @@ impl HealMap {
|
||||
/// along lines they were split along on purpose (`keep`), with what each face and edge of the
|
||||
/// result was made from (`None` when that could not be followed).
|
||||
pub(crate) fn heal_traced(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> (Solid, Option<HealMap>) {
|
||||
healed(solid, size, keep, true)
|
||||
healed(solid, size, keep, true, false)
|
||||
}
|
||||
|
||||
/// [`heal_traced`] that only merges coplanar neighbouring faces, leaving every edge, curve and
|
||||
/// surface as it is: for results whose geometry is already exact (a prism boolean's), where
|
||||
/// joining edges would change more than the faces split in pieces.
|
||||
pub(crate) fn merge_faces_traced(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> (Solid, Option<HealMap>) {
|
||||
healed(solid, size, keep, true, true)
|
||||
}
|
||||
|
||||
/// The healed solid, and what it was made from when `traced`.
|
||||
fn healed(solid: Solid, size: f64, keep: &[Vec<Vec3>], traced: bool) -> (Solid, Option<HealMap>) {
|
||||
fn healed(solid: Solid, size: f64, keep: &[Vec<Vec3>], traced: bool, faces_only: bool) -> (Solid, Option<HealMap>) {
|
||||
// Edges a boolean made are intersection curves, which panic where they no longer
|
||||
// evaluate; the solid then stays as it is.
|
||||
match crate::guard("heal", || Ok(heal_shells(&solid, size, keep, traced))) {
|
||||
match crate::guard("heal", || Ok(heal_shells(&solid, size, keep, traced, faces_only))) {
|
||||
Ok(Some((healed, map))) => (healed, map),
|
||||
_ => {
|
||||
let map = if traced { crate::guard("heal map", || Ok(HealMap::identity(&solid))).ok() } else { None };
|
||||
@@ -588,7 +604,7 @@ impl Trace {
|
||||
}
|
||||
}
|
||||
|
||||
fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool) -> Option<(Solid, Option<HealMap>)> {
|
||||
fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool, faces_only: bool) -> Option<(Solid, Option<HealMap>)> {
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
let mut shells = Vec::new();
|
||||
let mut changed = false;
|
||||
@@ -603,11 +619,11 @@ fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool) -> Op
|
||||
let mut from: Vec<Vec<usize>> = (first..first + count).map(|i| vec![i]).collect();
|
||||
first += count;
|
||||
// Faces come out in place, one for one, except where coplanar ones merge.
|
||||
if let Some(m) = planar_surfaces(&cur, (size * 1e-7).max(1e-9)) {
|
||||
if !faces_only && let Some(m) = planar_surfaces(&cur, (size * 1e-7).max(1e-9)) {
|
||||
cur = m;
|
||||
changed = true;
|
||||
}
|
||||
if let Some(m) = exact_edges(&cur, (size * 1e-5).max(1e-7), &mut trace) {
|
||||
if !faces_only && let Some(m) = exact_edges(&cur, (size * 1e-5).max(1e-7), &mut trace) {
|
||||
cur = m;
|
||||
changed = true;
|
||||
}
|
||||
@@ -616,7 +632,7 @@ fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool) -> Op
|
||||
from = groups.iter().map(|g| g.iter().filter_map(|i| from.get(*i)).flatten().copied().collect()).collect();
|
||||
changed = true;
|
||||
}
|
||||
if let Some(m) = merge_collinear(&cur, &mut trace) {
|
||||
if !faces_only && let Some(m) = merge_collinear(&cur, &mut trace) {
|
||||
cur = m;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Partial planar contact must sew a solid without displacing or inventing material.
|
||||
use crate::*;
|
||||
use solidcraft_geom::Vec3;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
#[test]
|
||||
fn split_contact_arc_crossing_one_edge_twice_preserves_the_body() {
|
||||
use truck_modeling::Surface;
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap();
|
||||
let b = cylinder(Vec3::new(0.0, 7.5, 0.0), -Vec3::X, 5.0, 2.5).unwrap();
|
||||
let face = a.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_some_and(|n| n.dot(-Vec3::X) > 0.99)).unwrap();
|
||||
let side = b.solid.face_iter().position(|f| !matches!(f.surface(), Surface::Plane(_))).unwrap();
|
||||
let r = split_faces(&a, &[face], &SplitTool::Face { body: &b, face: side }).unwrap();
|
||||
assert_eq!(r.face_count(), a.face_count() + 1);
|
||||
assert!(r.validity().is_empty(), "{:?}", r.validity());
|
||||
assert!(r.history().is_some());
|
||||
let (before, after) = (measure(&a).unwrap(), measure(&r).unwrap());
|
||||
assert!((before.volume - after.volume).abs() < 1e-7);
|
||||
assert!((before.area - after.area).abs() < 1e-7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_contact_does_not_join_tangent_or_separated_faces() {
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap();
|
||||
for z in [-5.0, -6.0] {
|
||||
let b = cylinder(Vec3::new(0.0, 7.5, z), -Vec3::X, 5.0, 2.5).unwrap();
|
||||
assert!(crate::coplanar::imprint_union(&a, &b, &|_| true).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_contact_retains_an_authored_split_away_from_the_contact() {
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap();
|
||||
let face = a.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_some_and(|n| n.dot(Vec3::X) > 0.99)).unwrap();
|
||||
let a = split_faces(&a, &[face], &SplitTool::Plane(solidcraft_geom::Plane { origin: Vec3::new(0.0, 0.0, 15.0), ..solidcraft_geom::Plane::XY }))
|
||||
.unwrap();
|
||||
let b = cylinder(Vec3::new(0.0, 7.5, 0.0), -Vec3::X, 5.0, 2.5).unwrap();
|
||||
let r = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
|
||||
assert_eq!(r.split_lines(), a.split_lines());
|
||||
assert!(r.regularization_protected);
|
||||
assert!(r.validity().is_empty());
|
||||
let remote_faces =
|
||||
r.faces(0.01).unwrap().iter().filter(|f| f.plane_normal.is_some_and(|n| n.dot(Vec3::X) > 0.99) && (f.centroid.x - 25.0).abs() < 1e-7).count();
|
||||
assert_eq!(remote_faces, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_circular_contact_preserves_material_and_history() {
|
||||
for z in [-3.0_f64, 0.0, 3.0] {
|
||||
for angle in [0.0, 0.37] {
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(25.0, 47.5, 37.5)).unwrap();
|
||||
let b = cylinder(Vec3::new(0.0, 7.5, z), -Vec3::X, 5.0, 2.5).unwrap();
|
||||
let place = |body: &Body| transform(body, Vec3::new(4.0, -8.0, 2.0), Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0), angle).unwrap();
|
||||
let (a, b) = (place(&a), place(&b));
|
||||
for reversed in [false, true] {
|
||||
let (a, b) = if reversed { (&b, &a) } else { (&a, &b) };
|
||||
let r = boolean(a, b, BoolOp::Union).unwrap().unwrap();
|
||||
assert!(r.validity().is_empty(), "z={z} angle={angle}: {:?}", r.validity());
|
||||
assert_eq!(r.shell_count(), 1);
|
||||
assert!(r.history().is_some(), "missing history z={z} angle={angle}");
|
||||
let m = measure(&r).unwrap();
|
||||
let volume = 25.0 * 47.5 * 37.5 + PI * 25.0 * 2.5;
|
||||
let contact = 25.0 * (-z / 5.0).acos() + z * (25.0 - z * z).sqrt();
|
||||
let area = 2.0 * (25.0 * 47.5 + 25.0 * 37.5 + 47.5 * 37.5) + 2.0 * PI * 5.0 * (5.0 + 2.5) - 2.0 * contact;
|
||||
assert!((m.volume - volume).abs() < 0.1, "z={z} angle={angle}: volume {} expected {volume}", m.volume);
|
||||
assert!((m.area - area).abs() < 0.2, "z={z} angle={angle}: area {} expected {area}", m.area);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Bodies touching back to back over part of a face fuse into one solid: two L's of a 2 × 2
|
||||
//! grid extruded up and down (#679), a pad half past the edge of the face it stands on (#674),
|
||||
//! several contact regions, contact beside a shared edge, and random rectangles.
|
||||
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn prism(pts: &[[f64; 2]], z0: f64, z1: f64) -> Body {
|
||||
let outer = Loop2::polygon(&pts.iter().map(|p| Vec2::new(p[0], p[1])).collect::<Vec<_>>());
|
||||
extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], z0, z1).unwrap().pop().unwrap()
|
||||
}
|
||||
|
||||
fn rect(x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64) -> Body {
|
||||
box_solid(Vec3::new(x0, y0, z0), Vec3::new(x1, y1, z1)).unwrap()
|
||||
}
|
||||
|
||||
fn fused(a: &Body, b: &Body) -> (Body, BooleanDiagnostics) {
|
||||
let (r, d) = capture_boolean_diagnostics(|| boolean(a, b, BoolOp::Union));
|
||||
(r.unwrap().unwrap(), d)
|
||||
}
|
||||
|
||||
fn assert_one_solid(r: &Body, volume: f64, tol: f64, what: &str) {
|
||||
let m = measure(r).unwrap();
|
||||
assert!((m.volume - volume).abs() <= tol, "{what}: volume {} vs {volume}", m.volume);
|
||||
assert_eq!(r.shell_count(), 1, "{what}: one solid");
|
||||
assert!(r.validity().is_empty(), "{what}: {:?}", r.validity());
|
||||
}
|
||||
|
||||
/// #679: XO/XX extruded up 10, XX/OX down 10, 10 mm cells. They meet back to back over two
|
||||
/// whole cells, along an edge and at the centre point: 6 cells × 10 mm × 100 mm².
|
||||
#[test]
|
||||
fn two_ls_up_and_down_fuse_into_one_solid() {
|
||||
let up = prism(&[[-10., -10.], [10., -10.], [10., 0.], [0., 0.], [0., 10.], [-10., 10.]], 0., 10.);
|
||||
let down = prism(&[[-10., 10.], [-10., 0.], [0., 0.], [0., -10.], [10., -10.], [10., 10.]], -10., 0.);
|
||||
for (a, b) in [(&up, &down), (&down, &up)] {
|
||||
let (r, d) = fused(a, b);
|
||||
assert_one_solid(&r, 6000.0, 1e-6, "#679");
|
||||
assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{d:?}");
|
||||
// Two tops, two bottoms, the two cells left open at z = 0, six sides.
|
||||
assert_eq!(r.face_count(), 12);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two blocks up from z = 0 and one plate down under both: two separate contacts fuse into one
|
||||
/// solid. A plate under a block, flush with its sides along shared edges.
|
||||
#[test]
|
||||
fn several_contact_regions_and_edge_contact() {
|
||||
let up = rect(-10., -10., -2., -2., 0., 10.);
|
||||
let up2 = rect(2., 2., 10., 10., 0., 10.);
|
||||
let ups = boolean(&up, &up2, BoolOp::Union).unwrap().unwrap();
|
||||
// Down under both up cells, and under one more.
|
||||
let down = prism(&[[-10., -10.], [0., -10.], [0., 0.], [10., 0.], [10., 10.], [-10., 10.]], -5., 0.);
|
||||
let (r, _) = fused(&ups, &down);
|
||||
assert_one_solid(&r, 2.0 * 640.0 + 3.0 * 500.0, 1e-6, "two contacts");
|
||||
// Down under one up cell and beside the other: one face contact and one edge contact.
|
||||
// A plate under one block, flush with two of its sides and running on past the third.
|
||||
let down = rect(-10., -10., -2., 5., -5., 0.);
|
||||
let (r, _) = fused(&up, &down);
|
||||
assert_one_solid(&r, 640.0 + 8.0 * 15.0 * 5.0, 1e-6, "contact and flush sides");
|
||||
}
|
||||
|
||||
/// #674 (robustness seed 16): a short cylinder whose end lies on a block's side with half of it
|
||||
/// past the block's bottom edge. The end is split along the edge, the block's side along the
|
||||
/// circle, and the half disc they share goes.
|
||||
#[test]
|
||||
fn a_pad_half_past_the_edge_of_its_face_fuses() {
|
||||
let block = rect(0., 0., 25., 47.5, 0., 37.5);
|
||||
let pad = cylinder(Vec3::new(7.5, 0., 0.), -Vec3::Y, 5.0, 2.5).unwrap();
|
||||
// Measured as the result is (the round side's volume from its mesh).
|
||||
let want = measure(&block).unwrap().volume + measure(&pad).unwrap().volume;
|
||||
assert!((want - (25.0 * 47.5 * 37.5 + std::f64::consts::PI * 25.0 * 2.5)).abs() < 0.1);
|
||||
for (a, b) in [(&block, &pad), (&pad, &block)] {
|
||||
let (r, d) = fused(a, b);
|
||||
assert_one_solid(&r, want, 1e-6 * want, "#674");
|
||||
assert!(d.events.is_empty(), "an exact join: {d:?}");
|
||||
let bounds = r.tessellate(0.01).unwrap().bounds();
|
||||
assert!((bounds.min.y + 2.5).abs() < 1e-9 && (bounds.min.z + 5.0).abs() < 0.01, "{bounds:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Random rectangles extruded up from z = 0 and down from it, overlapping anywhere (inside one
|
||||
/// another, crossing, sharing a side): one solid of exactly the two prisms' volume.
|
||||
#[test]
|
||||
fn random_rectangles_either_side_of_a_plane_fuse_exactly() {
|
||||
let mut seed = 0x679_u64;
|
||||
let mut next = |lo: f64, hi: f64| {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
lo + (hi - lo) * ((seed >> 11) as f64 / (1u64 << 53) as f64)
|
||||
};
|
||||
let q = |x: f64| (x * 2.0).round() / 2.0;
|
||||
let mut done = 0;
|
||||
for case in 0..30 {
|
||||
let (x0, y0) = (q(next(0., 20.)), q(next(0., 20.)));
|
||||
let (w0, h0) = (q(next(2., 20.)), q(next(2., 20.)));
|
||||
// The second overlaps the first over an area.
|
||||
let (x1, y1) = (q(next(x0 - 15., x0 + w0 - 1.)), q(next(y0 - 15., y0 + h0 - 1.)));
|
||||
let (w1, h1) = (q(next(1., 25.)).max(x0 + 1. - x1).max(1.), q(next(1., 25.)).max(y0 + 1. - y1).max(1.));
|
||||
let (t0, t1) = (q(next(1., 10.)), q(next(1., 10.)));
|
||||
let up = rect(x0, y0, x0 + w0, y0 + h0, 0., t0);
|
||||
let down = rect(x1, y1, x1 + w1, y1 + h1, -t1, 0.);
|
||||
let want = w0 * h0 * t0 + w1 * h1 * t1;
|
||||
let what = format!("case {case}: up {x0},{y0} {w0}x{h0}x{t0}, down {x1},{y1} {w1}x{h1}x{t1}");
|
||||
let (r, d) = fused(&up, &down);
|
||||
assert_one_solid(&r, want, 1e-6 * want, &what);
|
||||
assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{what}: {d:?}");
|
||||
done += 1;
|
||||
}
|
||||
assert_eq!(done, 30);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ mod boolean_guard;
|
||||
mod boolean_record;
|
||||
mod bore_guard;
|
||||
mod build;
|
||||
mod clearance;
|
||||
mod coplanar;
|
||||
mod curveblend;
|
||||
mod delete_face;
|
||||
@@ -39,6 +40,10 @@ mod iges_out;
|
||||
mod intersection_curve_tests;
|
||||
#[cfg(test)]
|
||||
mod issue_616_tests;
|
||||
#[cfg(test)]
|
||||
mod issue_674_tests;
|
||||
#[cfg(test)]
|
||||
mod issue_679_tests;
|
||||
mod lip;
|
||||
mod loopblend;
|
||||
mod measure;
|
||||
@@ -50,6 +55,7 @@ mod native;
|
||||
mod numbered;
|
||||
mod offset;
|
||||
mod ops;
|
||||
mod par;
|
||||
mod plane_fit;
|
||||
mod polybool;
|
||||
mod polyhedron;
|
||||
@@ -68,14 +74,17 @@ mod step_out;
|
||||
mod surface_kind;
|
||||
mod surfaces;
|
||||
mod sweep_path;
|
||||
mod tess;
|
||||
#[cfg(test)]
|
||||
mod thread_tests;
|
||||
mod topo;
|
||||
mod tspline;
|
||||
#[cfg(test)]
|
||||
mod untouched_faces_tests;
|
||||
|
||||
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_max, fillet_max_each};
|
||||
pub use blend_corner::fillet_radii;
|
||||
pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points};
|
||||
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper};
|
||||
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
|
||||
pub use build::{PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
|
||||
@@ -93,7 +102,7 @@ pub use offset::with_same_surface;
|
||||
pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix, union_apart};
|
||||
pub use polybool::{faceted_boolean, planar_boolean};
|
||||
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
|
||||
pub use prism::{extrude_planar_regions, planar_regions_boolean};
|
||||
pub use prism::{PLANAR_FIT_TOL, extrude_planar_regions, planar_regions_boolean};
|
||||
pub use prism_measure::certified_prism_equivalent;
|
||||
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
|
||||
pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map};
|
||||
@@ -101,7 +110,7 @@ pub use step::step_export;
|
||||
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_orientation_errors, step_validate};
|
||||
pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products};
|
||||
pub use surface_kind::{FaceSurface, face_surface};
|
||||
pub use surfaces::{copy_faces, extend, patch_edges, patch_region, stitch, thicken, trim};
|
||||
pub use surfaces::{copy_faces, extend, extrude_sheet, patch_edges, patch_region, stitch, thicken, trim};
|
||||
pub use sweep_path::sweep_path;
|
||||
pub use topo::{CylinderFace, cylinder_face_at};
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
|
||||
@@ -18,6 +18,14 @@ fn invalid(message: &str) -> KernelError {
|
||||
KernelError::Invalid(format!("mesh conversion: {message}"))
|
||||
}
|
||||
|
||||
/// A mesh over the triangle budget: how big it is, the limit, and that only constant-section
|
||||
/// parts convert, so a smaller freeform mesh would still be refused (#346).
|
||||
fn over_budget(triangles: usize) -> KernelError {
|
||||
invalid(&format!(
|
||||
"the mesh has {triangles} triangles, more than the {MAX_TRIANGLES} conversion can take; only closed constant-section parts (one profile extruded straight) can be converted, so a freeform mesh is refused at any size"
|
||||
))
|
||||
}
|
||||
|
||||
/// Reconstruct a constant-section mesh as analytic planes and, where certified
|
||||
/// within `tolerance` mm, circular cylinders. The source mesh is not modified.
|
||||
/// Unsupported shapes are refused instead of silently returning a faceted solid.
|
||||
@@ -30,7 +38,7 @@ pub fn convert_prismatic_mesh(body: &Body, tolerance: f64) -> Result<Body> {
|
||||
let welded = weld(input, tolerance)?;
|
||||
let mesh = welded.mesh.as_ref().ok_or_else(|| invalid("empty mesh"))?;
|
||||
if mesh.triangles.len() > MAX_TRIANGLES {
|
||||
return Err(invalid("mesh exceeds the 50000-triangle conversion budget"));
|
||||
return Err(over_budget(mesh.triangles.len()));
|
||||
}
|
||||
let mut edges: BTreeMap<(u32, u32), (usize, i32)> = BTreeMap::new();
|
||||
let mut axes: BTreeMap<(i64, i64, i64), (Vec3, f64)> = BTreeMap::new();
|
||||
@@ -69,8 +77,11 @@ pub fn convert_prismatic_mesh(body: &Body, tolerance: f64) -> Result<Body> {
|
||||
}
|
||||
|
||||
fn weld(mesh: &crate::meshbody::TriMesh, tolerance: f64) -> Result<Body> {
|
||||
if mesh.triangles.len() > MAX_TRIANGLES || mesh.positions.len() > MAX_TRIANGLES * 3 {
|
||||
return Err(invalid("mesh exceeds the conversion budget"));
|
||||
if mesh.triangles.len() > MAX_TRIANGLES {
|
||||
return Err(over_budget(mesh.triangles.len()));
|
||||
}
|
||||
if mesh.positions.len() > MAX_TRIANGLES * 3 {
|
||||
return Err(invalid(&format!("the mesh has {} vertices, more than the {} conversion can take", mesh.positions.len(), MAX_TRIANGLES * 3)));
|
||||
}
|
||||
let bounds = mesh.positions.iter().fold(solidcraft_geom::Aabb3::EMPTY, |mut b, p| {
|
||||
b.add(*p);
|
||||
@@ -464,6 +475,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mesh_over_the_budget_says_how_big_it_is_and_what_converts() {
|
||||
// #346: a freeform mesh just over the limit (a strip of 50001 triangles) is refused with
|
||||
// its size, the limit, and that only constant-section parts convert at any size.
|
||||
let n = MAX_TRIANGLES + 1;
|
||||
let positions: Vec<Vec3> = (0..n + 2).map(|k| Vec3::new((k / 2) as f64, (k % 2) as f64, 0.)).collect();
|
||||
let triangles: Vec<[u32; 3]> = (0..n as u32).map(|i| [i, i + 1, i + 2]).collect();
|
||||
let body = crate::mesh_body(&positions, &triangles).unwrap();
|
||||
let err = convert_prismatic_mesh(&body, 0.05).unwrap_err().to_string();
|
||||
assert!(err.contains("50001 triangles") && err.contains("50000"), "{err}");
|
||||
assert!(err.contains("constant-section") && err.contains("freeform"), "{err}");
|
||||
let mesh = crate::meshbody::TriMesh { positions: vec![Vec3::ZERO; MAX_TRIANGLES * 3 + 1], triangles: vec![] };
|
||||
let err = weld(&mesh, 0.05).unwrap_err().to_string();
|
||||
assert!(err.contains("150001 vertices") && err.contains("150000"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_cannot_open_a_hole_through_a_nearby_wall() {
|
||||
let outer = Loop2::polygon(&[Vec2::new(-1., -1.), Vec2::new(1., -1.), Vec2::new(1., 1.), Vec2::new(-1., 1.)]);
|
||||
|
||||
@@ -103,6 +103,8 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
|
||||
}
|
||||
Ok(Body {
|
||||
solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())),
|
||||
size: 0.0,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: Some(Arc::new(out)),
|
||||
color: None,
|
||||
@@ -197,6 +199,8 @@ impl Body {
|
||||
}
|
||||
Some(Body {
|
||||
solid: self.solid.clone(),
|
||||
size: 0.0,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: Some(Arc::new(out)),
|
||||
color: self.color,
|
||||
|
||||
@@ -84,6 +84,8 @@ pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
|
||||
let solid = adapter::decode(saved.solid)?;
|
||||
let mut body = Body {
|
||||
solid: Arc::new(solid),
|
||||
size: 0.0,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
color: None,
|
||||
|
||||
+139
-33
@@ -35,7 +35,9 @@ pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool {
|
||||
const JITTER: [[f64; 3]; 4] = [[0.0, 0.0, 0.0], [0.1373, 0.2719, 0.3911], [-0.5127, 0.0833, 0.2291], [0.6911, -0.3337, -0.6127]];
|
||||
|
||||
fn volume(b: &Body) -> f64 {
|
||||
b.tessellate(b.size() * 2e-3).map(|m| m.measure().volume).unwrap_or(f64::NAN)
|
||||
// Measured once per (solid, tolerance) and shared by clones, so an operand a later
|
||||
// boolean takes again is not meshed twice for the same number.
|
||||
b.volume_at(b.size() * 2e-3).unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// Halton low-discrepancy value (base `b`) for index `i`.
|
||||
@@ -393,6 +395,43 @@ fn meeting_size(a: &Body, b: &Body) -> f64 {
|
||||
if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
|
||||
}
|
||||
|
||||
/// Whether a result volume is plausible for `op`: within the bounds the operands' volumes
|
||||
/// leave, plus the slack the meshed volumes are measured to.
|
||||
fn plausible(v: f64, op: BoolOp, va: f64, vb: f64, slack: f64) -> bool {
|
||||
v > 0.0
|
||||
&& match op {
|
||||
BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack,
|
||||
BoolOp::Cut => v >= va - vb - slack && v <= va + slack,
|
||||
BoolOp::Intersect => v <= va.min(vb) + slack,
|
||||
}
|
||||
}
|
||||
|
||||
/// The prism route's result: `Some` when both operands are prisms along one direction (see
|
||||
/// [`crate::prism::prism_boolean`]), with a union's slab seams healed as the general B-rep
|
||||
/// boolean does.
|
||||
fn prism_result(a: &Body, b: &Body, op: BoolOp, size: f64, counts: &[(usize, usize)]) -> Option<Result<Option<Body>>> {
|
||||
// Slab-by-slab prism booleans sew a closed solid, but leave cap and wall pieces along slab
|
||||
// boundaries: a cut along Z through a box whose side wall already has a port in it splits
|
||||
// that wall into strips at the port's top and bottom, though the cut never touches it. Merge
|
||||
// those coplanar faces for every operation, just as the general B-rep boolean does, retaining
|
||||
// intentional Split Face lines. Authored boundaries (painted faces, regularization
|
||||
// protection) keep their pieces.
|
||||
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
|
||||
let authored = |x: &Body| x.regularization_protected || x.paint().is_some_and(|p| !p.faces.is_empty());
|
||||
crate::prism::prism_boolean(a, b, op).map(|result| {
|
||||
result.map(|body| {
|
||||
body.map(|body| {
|
||||
if op == BoolOp::Union {
|
||||
guard("heal prism boolean", || recorded::heal_body(&body, (*body.solid).clone(), size, &keep, counts)).unwrap_or(body)
|
||||
} else if authored(a) || authored(b) {
|
||||
body
|
||||
} else {
|
||||
guard("heal prism boolean", || recorded::heal_prism_result(&body, size, &keep, counts)).unwrap_or(body)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
a.require_brep("a boolean")?;
|
||||
b.require_brep("a boolean")?;
|
||||
@@ -405,6 +444,33 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
// floor) is otherwise spanned by triangles cutting through it, which reads as missing
|
||||
// material.
|
||||
let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5);
|
||||
// A prism pair (a plate and the tool through it) is decided exactly in 2D, with no mesh of
|
||||
// either operand. The sampled shortcuts below need a refined mesh of both, and that mesh is
|
||||
// most of what a boolean costs: a pattern or a multi-profile sketch rebuilds the whole
|
||||
// target's mesh once per tool. Only a cut or an intersection meets its tool often enough to
|
||||
// be worth asking first - parts that stay apart are settled far more cheaply by the shell
|
||||
// join the sampled shortcut does. A pair the route cannot answer still takes the path below,
|
||||
// unchanged.
|
||||
let early_prism = matches!(op, BoolOp::Cut | BoolOp::Intersect);
|
||||
let prism = early_prism.then(|| prism_result(a, b, op, size, &counts)).flatten();
|
||||
if let Some(Ok(Some(body))) = &prism {
|
||||
let (va0, vb0) = (volume(a), volume(b));
|
||||
let (v0, slack0) = (volume(body), 2e-3 * (va0 + vb0) + 1e-9);
|
||||
// A tool that misses its target, or covers it whole, leaves it as it is, and the
|
||||
// sampled shortcut below answers that by reusing the operand itself: the very same body.
|
||||
// The 2D route rebuilds it instead - the same shape on new faces - which would turn an
|
||||
// untouched body in a timeline into a different solid. Those pairs go back to the path
|
||||
// below, which decides them, and the identity they keep, exactly as it always has.
|
||||
let unchanged = 1e-4 * (va0 + vb0);
|
||||
let rebuilt_from_an_operand = match op {
|
||||
BoolOp::Cut => v0 >= va0 - unchanged,
|
||||
BoolOp::Intersect => (v0 - va0).abs() <= unchanged || (v0 - vb0).abs() <= unchanged,
|
||||
BoolOp::Union => false,
|
||||
};
|
||||
if !rebuilt_from_an_operand && plausible(v0, op, va0, vb0, slack0) {
|
||||
return Ok(Some(body.clone()));
|
||||
}
|
||||
}
|
||||
let meshes = (a.tessellate_with(tol_m, true).ok(), b.tessellate_with(tol_m, true).ok());
|
||||
let (va, vb) = match &meshes {
|
||||
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
|
||||
@@ -429,12 +495,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
let check = |r: &Body| -> (bool, f64) {
|
||||
let Ok(mr) = r.tessellate_with(tol_m, true) else { return (false, f64::NAN) };
|
||||
let v = mr.measure().volume;
|
||||
let bounds = v > 0.0
|
||||
&& match op {
|
||||
BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack,
|
||||
BoolOp::Cut => v >= va - vb - slack && v <= va + slack,
|
||||
BoolOp::Intersect => v <= va.min(vb) + slack,
|
||||
};
|
||||
let bounds = plausible(v, op, va, vb, slack);
|
||||
if !bounds {
|
||||
return (false, v);
|
||||
}
|
||||
@@ -461,36 +522,26 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
}
|
||||
let mut last = String::new();
|
||||
// Coincident faces make the intersection fail after many retries; push them apart first.
|
||||
// Prisms along one direction (plates with holes): exact, in 2D (so only the volume
|
||||
// bounds are checked).
|
||||
if let Some(Ok(r)) = crate::prism::prism_boolean(a, b, op) {
|
||||
// Slab-by-slab prism joins sew a closed solid, but leave cap and wall
|
||||
// pieces along slab boundaries. Merge those coplanar faces just as the
|
||||
// general B-rep boolean does, retaining intentional Split Face lines.
|
||||
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
|
||||
let r = if op == BoolOp::Union {
|
||||
r.map(|body| guard("heal prism boolean", || recorded::heal_body(&body, (*body.solid).clone(), size, &keep, &counts)).unwrap_or(body))
|
||||
} else {
|
||||
r
|
||||
};
|
||||
match &r {
|
||||
Some(body) => {
|
||||
// Prisms along one direction (plates with holes): exact, in 2D (so only the volume bounds
|
||||
// are checked). For a cut or an intersection this already ran above; the other operations
|
||||
// ask here, after the sampled shortcut, which is where that shortcut is cheapest.
|
||||
let prism = prism.or_else(|| (!early_prism).then(|| prism_result(a, b, op, size, &counts)).flatten());
|
||||
if let Some(result) = prism {
|
||||
match result {
|
||||
Ok(Some(body)) => {
|
||||
// Measured as the operands were: a coarser mesh of a plate with many holes
|
||||
// overstates its volume by more than the slack.
|
||||
let v = match &meshes {
|
||||
(Some(_), Some(_)) => body.tessellate_with(tol_m, true).map(|m| m.measure().volume).unwrap_or(f64::NAN),
|
||||
_ => volume(body),
|
||||
_ => volume(&body),
|
||||
};
|
||||
let bounds = match op {
|
||||
BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack,
|
||||
BoolOp::Cut => v >= va - vb - slack && v <= va + slack,
|
||||
BoolOp::Intersect => v <= va.min(vb) + slack,
|
||||
};
|
||||
if v > 0.0 && bounds {
|
||||
return Ok(r);
|
||||
if plausible(v, op, va, vb, slack) {
|
||||
return Ok(Some(body));
|
||||
}
|
||||
}
|
||||
None => return Ok(None),
|
||||
Ok(None) => return Ok(None),
|
||||
// A failed prism attempt keeps trying the other strategies, as it did before.
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
// Bodies touching along one whole face (a part joined with its mirror image).
|
||||
@@ -507,6 +558,8 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
// Coincident planar faces: push them apart (exactly). Tried once, before the general
|
||||
// boolean; why it failed is reported if everything else fails too.
|
||||
let mut apart_failed = String::new();
|
||||
// The exact polygon boolean's answer, when it was asked for early.
|
||||
let mut planar = None;
|
||||
if !APART.with(|c| c.get()) {
|
||||
APART.with(|c| c.set(true));
|
||||
let (r, diagnostics) = capture_boolean_diagnostics(|| crate::coplanar::boolean_apart(a, b, op));
|
||||
@@ -526,6 +579,22 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
Some(Err(e)) => apart_failed = format!("; {e}"),
|
||||
Some(Ok(None)) | None => {}
|
||||
}
|
||||
// A face with no room to move would take or add what it moves through. The exact
|
||||
// polygon boolean takes the bodies as they lie (bodies with only planar faces, or a
|
||||
// planar tool clear of the other body's curved faces): it is asked now, before the
|
||||
// general boolean has its tries at faces lying in each other.
|
||||
if apart_failed.contains(crate::coplanar::NO_ROOM) {
|
||||
// What its own booleans report counts only when its result is taken.
|
||||
let (exact, diagnostics) = capture_boolean_diagnostics(|| crate::polybool::planar_boolean(a, b, op));
|
||||
match exact {
|
||||
Ok(Some(body)) if check(&body).0 => {
|
||||
merge(diagnostics);
|
||||
record(BooleanFallback::new(BooleanStrategy::PlanarFallback));
|
||||
return Ok(Some(body));
|
||||
}
|
||||
other => planar = Some(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
// A face standing wholly inside a coincident face of the other body, back to back, and no
|
||||
// other contact (#396): exact, by topology alone.
|
||||
@@ -549,6 +618,14 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
return Ok(Some(body));
|
||||
}
|
||||
}
|
||||
// Faces back to back over part of their area: imprinted on each other and glued (exact; held
|
||||
// to the retry guard's checks all the same).
|
||||
if op == BoolOp::Union && crate::coplanar::faces_touch(a, b) {
|
||||
let accept = |body: &Body| check(body).0 && !unfused(body) && crate::boolean_guard::check_retry(a, b, op, Some(body)).is_ok();
|
||||
if let Some(body) = crate::coplanar::imprint_union(a, b, &accept) {
|
||||
return Ok(Some(body));
|
||||
}
|
||||
}
|
||||
let mut empty_votes = 0;
|
||||
let mut empty_diagnostics = crate::BooleanDiagnostics::default();
|
||||
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
|
||||
@@ -604,7 +681,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
last.push_str(&apart_failed);
|
||||
// Coincident faces defeat the B-rep boolean; bodies with only planar faces have an exact
|
||||
// polygon fallback.
|
||||
match crate::polybool::planar_boolean(a, b, op) {
|
||||
match planar.unwrap_or_else(|| crate::polybool::planar_boolean(a, b, op)) {
|
||||
Ok(Some(body)) => {
|
||||
let (ok, v) = check(&body);
|
||||
if ok {
|
||||
@@ -841,7 +918,20 @@ pub(crate) fn join_pieces(pieces: Vec<Body>) -> Result<Option<Body>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Affine transform by a column-major 4×4 matrix (rotations, translations, reflections).
|
||||
/// Determinant of the linear part of a column-major 4x4 matrix: negative means the placement
|
||||
/// mirrors, which turns a solid inside out.
|
||||
fn det3(m: &[[f64; 4]; 4]) -> f64 {
|
||||
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
}
|
||||
|
||||
/// Whether the linear part is a rigid motion (rotation or reflection: no scale or shear).
|
||||
fn rigid_placement(m: &[[f64; 4]; 4]) -> bool {
|
||||
let r = [Vec3::new(m[0][0], m[0][1], m[0][2]), Vec3::new(m[1][0], m[1][1], m[1][2]), Vec3::new(m[2][0], m[2][1], m[2][2])];
|
||||
(0..3).all(|i| (r[i].dot(r[i]) - 1.0).abs() < 1e-9) && r[0].dot(r[1]).abs() < 1e-9 && r[0].dot(r[2]).abs() < 1e-9 && r[1].dot(r[2]).abs() < 1e-9
|
||||
}
|
||||
|
||||
/// Affine transform by a column-major 4x4 matrix (rotations, translations, reflections).
|
||||
pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
|
||||
if m.iter().flatten().any(|x| !x.is_finite()) {
|
||||
return Err(KernelError::Invalid("non-finite transform".into()));
|
||||
@@ -865,7 +955,19 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
|
||||
for (copy, face) in solid.face_iter().zip(body.solid.face_iter()) {
|
||||
rec.whole(copy, face);
|
||||
}
|
||||
let placed = record::rebuilt(solid, &[&body.solid], &rec)?;
|
||||
let (faces, edges) = rec.history(&solid, &[&body.solid]).unzip();
|
||||
let counts = record::solid_counts(&[&body.solid]);
|
||||
// A rigid placement leaves every face's mesh, and so the missing-face count, as it was;
|
||||
// a mirrored one turns the solid inside out. Either way the orientation is read from the
|
||||
// matrix instead of meshing the whole copy again.
|
||||
let placed = record::build_placed(
|
||||
solid,
|
||||
&counts,
|
||||
faces,
|
||||
edges.as_ref(),
|
||||
det3(&m) < 0.0,
|
||||
rigid_placement(&m).then_some(body.coarse_missing).flatten(),
|
||||
)?;
|
||||
Ok(placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources).inherit_regularization_protection(body))
|
||||
})
|
||||
}
|
||||
@@ -1017,6 +1119,10 @@ pub(crate) fn half_box(body: &Body, plane: &solidcraft_geom::Plane, positive: bo
|
||||
crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "prism_fast_path_tests.rs"]
|
||||
mod prism_fast_path_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "placement_tests.rs"]
|
||||
mod placement_tests;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Ordered parallel map over independent items, on the process-wide worker pool (desktop
|
||||
//! only; the browser build runs serially). Each item runs under [`crate::guard`], so a panic in
|
||||
//! one item is that item's error and never reaches the pool.
|
||||
|
||||
use crate::{Result, guard};
|
||||
|
||||
/// `f` over `items`, results in item order: on the pool when `parallel`, else on the calling
|
||||
/// thread. Waking the pool's workers costs tens of microseconds, so callers ask for it only
|
||||
/// when the items carry more work than that (decided from the work, not the item count).
|
||||
pub(crate) fn map<T: Sync, R: Send>(what: &'static str, items: &[T], parallel: bool, f: impl Fn(&T) -> Result<R> + Sync) -> Vec<Result<R>> {
|
||||
let one = |item: &T| guard(what, || f(item));
|
||||
// The browser build has no worker pool: everything runs on the calling thread.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = parallel;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
if parallel && items.len() > 1 && rayon::current_num_threads() > 1 {
|
||||
return items.par_iter().map(one).collect();
|
||||
}
|
||||
}
|
||||
items.iter().map(one).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::KernelError;
|
||||
|
||||
#[test]
|
||||
fn keeps_order_and_errors() {
|
||||
let items: Vec<u32> = (0..1000).collect();
|
||||
let out = map("test", &items, true, |x| if *x == 500 { Err(KernelError::Invalid("five hundred".into())) } else { Ok(x * 2) });
|
||||
assert_eq!(out.len(), 1000);
|
||||
for (i, r) in out.iter().enumerate() {
|
||||
match r {
|
||||
Ok(v) => assert_eq!(*v, 2 * i as u32),
|
||||
Err(_) => assert_eq!(i, 500),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
-20
@@ -3,7 +3,7 @@
|
||||
//! as a wall flush with the side of its base. The polygons are rebuilt into a B-rep: coplanar
|
||||
//! pieces are merged into faces, T-junctions are split so neighbouring faces share edges.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use solidcraft_geom::{Mesh, Vec3};
|
||||
use truck_modeling::{self as mt, builder};
|
||||
@@ -415,7 +415,7 @@ fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option
|
||||
let tol = (a.size() * 1e-3).max(1e-3);
|
||||
let infos = a.faces(tol).ok()?;
|
||||
let mesh = a.tessellate(tol).ok()?;
|
||||
let mut curved: HashMap<u32, solidcraft_geom::Aabb3> = HashMap::new();
|
||||
let mut curved: BTreeMap<u32, solidcraft_geom::Aabb3> = BTreeMap::new();
|
||||
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if infos.get(*fi as usize)?.plane_normal.is_some() {
|
||||
continue;
|
||||
@@ -589,13 +589,13 @@ struct Sources {
|
||||
/// faces (lesser face first).
|
||||
struct OperandEdges {
|
||||
ends: Vec<(Vec3, Vec3)>,
|
||||
between: HashMap<(usize, usize), Vec<usize>>,
|
||||
between: BTreeMap<(usize, usize), Vec<usize>>,
|
||||
}
|
||||
|
||||
impl OperandEdges {
|
||||
fn of(b: &Body) -> OperandEdges {
|
||||
let (edges, bounds) = edge_order(b.solid.face_iter());
|
||||
let mut between: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
|
||||
let mut between: BTreeMap<(usize, usize), Vec<usize>> = BTreeMap::new();
|
||||
for (i, fs) in bounds.iter().enumerate() {
|
||||
if let [f, g] = fs.as_slice() {
|
||||
between.entry((*f.min(g), *f.max(g))).or_default().push(i);
|
||||
@@ -699,6 +699,25 @@ fn csg(pa: Vec<Poly>, pb: Vec<Poly>, op: BoolOp, eps: f64) -> Result<Vec<Poly>>
|
||||
Ok(polys)
|
||||
}
|
||||
|
||||
/// `shell`'s connected components, each face in the shell's order and the components by their
|
||||
/// first face. truck's own come out in the hash order of face addresses, so the same rebuild
|
||||
/// gave a body with its faces in a different order from run to run (#707).
|
||||
pub(crate) fn components_in_order(shell: &mt::Shell) -> Vec<mt::Shell> {
|
||||
let pos: HashMap<mt::FaceID, usize> = shell.face_iter().enumerate().map(|(i, f)| (f.id(), i)).collect();
|
||||
let key = |f: &mt::Face| pos.get(&f.id()).copied().unwrap_or(usize::MAX);
|
||||
let mut comps: Vec<Vec<mt::Face>> = shell
|
||||
.connected_components()
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let mut faces: Vec<mt::Face> = c.into_iter().collect();
|
||||
faces.sort_by_key(|f| key(f));
|
||||
faces
|
||||
})
|
||||
.collect();
|
||||
comps.sort_by_key(|c| c.first().map(key).unwrap_or(usize::MAX));
|
||||
comps.into_iter().map(mt::Shell::from).collect()
|
||||
}
|
||||
|
||||
/// Position key for vertex merging.
|
||||
fn key(p: Vec3, q: f64) -> (i64, i64, i64) {
|
||||
((p.x / q).round() as i64, (p.y / q).round() as i64, (p.z / q).round() as i64)
|
||||
@@ -711,7 +730,7 @@ fn key(p: Vec3, q: f64) -> (i64, i64, i64) {
|
||||
fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
|
||||
let q = size * 1e-7;
|
||||
// Global vertex table.
|
||||
let mut index: HashMap<(i64, i64, i64), usize> = HashMap::new();
|
||||
let mut index: BTreeMap<(i64, i64, i64), usize> = BTreeMap::new();
|
||||
let mut verts: Vec<Vec3> = Vec::new();
|
||||
// Points within `q` share a vertex, also across the rounding grid's cell boundaries.
|
||||
let mut id = |p: Vec3, verts: &mut Vec<Vec3>| -> usize {
|
||||
@@ -755,15 +774,15 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
|
||||
let mean = edge_len.iter().sum::<f64>() / edge_len.len().max(1) as f64;
|
||||
let cell = mean.max(size * 1e-4).max(1e-9);
|
||||
let ckey = |p: Vec3| ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64, (p.z / cell).floor() as i64);
|
||||
let mut grid: HashMap<(i64, i64, i64), Vec<usize>> = HashMap::new();
|
||||
let mut grid: BTreeMap<(i64, i64, i64), Vec<usize>> = BTreeMap::new();
|
||||
for (k, v) in verts.iter().enumerate() {
|
||||
grid.entry(ckey(*v)).or_default().push(k);
|
||||
}
|
||||
let mut directed: Vec<(usize, usize, usize)> = Vec::new(); // (plane group, from, to)
|
||||
// The polygons (by loop) each directed edge comes from.
|
||||
let mut owners: HashMap<(usize, usize, usize), Vec<usize>> = HashMap::new();
|
||||
let mut owners: BTreeMap<(usize, usize, usize), Vec<usize>> = BTreeMap::new();
|
||||
let mut groups: Vec<PlaneEq> = Vec::new();
|
||||
let mut group_of: HashMap<(i64, i64, i64, i64), usize> = HashMap::new();
|
||||
let mut group_of: BTreeMap<(i64, i64, i64, i64), usize> = BTreeMap::new();
|
||||
for (li, (pl, l)) in loops.iter().enumerate() {
|
||||
let gk = ((pl.n.x * 1e8).round() as i64, (pl.n.y * 1e8).round() as i64, (pl.n.z * 1e8).round() as i64, (pl.w / (tol * 10.0)).round() as i64);
|
||||
let g = *group_of.entry(gk).or_insert_with(|| {
|
||||
@@ -814,7 +833,7 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
|
||||
}
|
||||
}
|
||||
// Interior edges cancel against their reverse within the same plane group.
|
||||
let mut count: HashMap<(usize, usize, usize), i64> = HashMap::new();
|
||||
let mut count: BTreeMap<(usize, usize, usize), i64> = BTreeMap::new();
|
||||
for (g, a, b) in &directed {
|
||||
*count.entry((*g, *a, *b)).or_insert(0) += 1;
|
||||
}
|
||||
@@ -838,12 +857,12 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
|
||||
}
|
||||
}
|
||||
let roots: Vec<usize> = (0..loops.len()).map(|li| find(&mut parent, li)).collect();
|
||||
let mut joined: HashMap<usize, BTreeSet<FaceOrigin>> = HashMap::new();
|
||||
let mut joined: BTreeMap<usize, BTreeSet<FaceOrigin>> = BTreeMap::new();
|
||||
for (root, src) in roots.iter().zip(&srcs) {
|
||||
joined.entry(*root).or_default().insert(*src);
|
||||
}
|
||||
// What lies on each side of each boundary edge (from, to): the faces of its polygons.
|
||||
let mut side: HashMap<(usize, usize), BTreeSet<FaceOrigin>> = HashMap::new();
|
||||
let mut side: BTreeMap<(usize, usize), BTreeSet<FaceOrigin>> = BTreeMap::new();
|
||||
for (g, a, b) in &boundary {
|
||||
let set = side.entry((*a, *b)).or_default();
|
||||
for li in owners.get(&(*g, *a, *b)).into_iter().flatten() {
|
||||
@@ -857,13 +876,11 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
|
||||
joined.get(roots.get(li)?).cloned()
|
||||
};
|
||||
guard("planar boolean rebuild", || {
|
||||
let tv: Vec<mt::Vertex> = verts.iter().map(|v| builder::vertex(p3(*v))).collect();
|
||||
let mut edges: HashMap<(usize, usize), mt::Edge> = HashMap::new();
|
||||
let mut faces: Vec<mt::Face> = Vec::new();
|
||||
let mut face_from: HashMap<mt::FaceID, Option<BTreeSet<FaceOrigin>>> = HashMap::new();
|
||||
for (g, plane) in groups.iter().enumerate() {
|
||||
let mut verts = verts.clone();
|
||||
// Every plane group's boundary loops (vertex indices).
|
||||
let mut all_loops: Vec<Vec<Vec<usize>>> = Vec::new();
|
||||
for (g, _) in groups.iter().enumerate() {
|
||||
let mut segs: Vec<(usize, usize)> = boundary.iter().filter(|e| e.0 == g).map(|e| (e.1, e.2)).collect();
|
||||
let mut wires: Vec<mt::Wire> = Vec::new();
|
||||
let mut loops_g: Vec<Vec<usize>> = Vec::new();
|
||||
while let Some((s, t)) = segs.pop() {
|
||||
let mut lp = vec![s, t];
|
||||
@@ -895,7 +912,19 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for lp in &loops_g {
|
||||
all_loops.push(loops_g);
|
||||
}
|
||||
// The original vertex of each vertex index (pinched vertices get copies, below).
|
||||
let mut orig: Vec<usize> = (0..verts.len()).collect();
|
||||
split_pinched_vertices(&mut all_loops, &mut verts, &mut orig);
|
||||
let tv: Vec<mt::Vertex> = verts.iter().map(|v| builder::vertex(p3(*v))).collect();
|
||||
let mut edges: BTreeMap<(usize, usize), mt::Edge> = BTreeMap::new();
|
||||
let mut faces: Vec<mt::Face> = Vec::new();
|
||||
let mut face_from: HashMap<mt::FaceID, Option<BTreeSet<FaceOrigin>>> = HashMap::new();
|
||||
for (g, plane) in groups.iter().enumerate() {
|
||||
let Some(loops_g) = all_loops.get(g) else { continue };
|
||||
let mut wires: Vec<mt::Wire> = Vec::new();
|
||||
for lp in loops_g {
|
||||
let n = lp.len();
|
||||
let mut es = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
@@ -964,7 +993,7 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
|
||||
ws.push(wires.get(*h).cloned().ok_or_else(|| KernelError::Failed("wire".into()))?);
|
||||
}
|
||||
}
|
||||
let from = loop_faces(g, ol);
|
||||
let from = loop_faces(g, &ol.iter().map(|k| orig.get(*k).copied().unwrap_or(*k)).collect::<Vec<_>>());
|
||||
// A loop a hair off flat (fine facets of a curved face) takes the group's plane.
|
||||
let face = match builder::try_attach_plane(&ws) {
|
||||
Ok(f) => f,
|
||||
@@ -980,7 +1009,7 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
|
||||
}
|
||||
}
|
||||
let shell: mt::Shell = faces.into();
|
||||
let parts = shell.connected_components();
|
||||
let parts = components_in_order(&shell);
|
||||
let solid = Solid::try_new(parts).map_err(|e| KernelError::Failed(format!("planar boolean: {e}")))?;
|
||||
let regions = solid.face_iter().map(|f| face_from.get(&f.id()).cloned().flatten().map(|o| (o, false))).collect::<Option<Vec<_>>>();
|
||||
let tol = size * 1e-6;
|
||||
@@ -988,6 +1017,7 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
|
||||
.iter()
|
||||
.map(|((a, b), e)| {
|
||||
let ends = (*verts.get(*a)?, *verts.get(*b)?);
|
||||
let (a, b) = (orig.get(*a)?, orig.get(*b)?);
|
||||
let mut origins = BTreeSet::new();
|
||||
for p in side.get(&(*a, *b))? {
|
||||
for q in side.get(&(*b, *a))? {
|
||||
@@ -1001,6 +1031,73 @@ fn rebuild(polys: Vec<Poly>, size: f64, sources: &Sources) -> Result<Body> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A vertex where the solid's surface pinches, as where two blocks meet at one corner only
|
||||
/// (#679): the faces around it form several fans that share no edge there. Each fan past the
|
||||
/// first gets its own copy of the vertex (same point), so every vertex of the shell is a
|
||||
/// manifold one.
|
||||
fn split_pinched_vertices(loops: &mut [Vec<Vec<usize>>], verts: &mut Vec<Vec3>, orig: &mut Vec<usize>) {
|
||||
// Every corner: (group, loop, position), by vertex.
|
||||
let mut corners: BTreeMap<usize, Vec<(usize, usize, usize)>> = BTreeMap::new();
|
||||
for (g, ls) in loops.iter().enumerate() {
|
||||
for (li, l) in ls.iter().enumerate() {
|
||||
for (i, v) in l.iter().enumerate() {
|
||||
corners.entry(*v).or_default().push((g, li, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut renames: Vec<((usize, usize, usize), usize)> = Vec::new();
|
||||
let mut ids: Vec<usize> = corners.keys().copied().collect();
|
||||
ids.sort_unstable();
|
||||
for v in ids {
|
||||
let Some(cs) = corners.get(&v) else { continue };
|
||||
if cs.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
// The two neighbours of each corner; corners sharing an edge (v, x) are in one fan.
|
||||
let nbrs: Vec<[usize; 2]> = cs
|
||||
.iter()
|
||||
.map(|(g, li, i)| {
|
||||
let l = loops.get(*g).and_then(|ls| ls.get(*li)).map(Vec::as_slice).unwrap_or(&[]);
|
||||
let n = l.len().max(1);
|
||||
[l.get((i + n - 1) % n).copied().unwrap_or(v), l.get((i + 1) % n).copied().unwrap_or(v)]
|
||||
})
|
||||
.collect();
|
||||
let mut parent: Vec<usize> = (0..cs.len()).collect();
|
||||
for i in 0..cs.len() {
|
||||
for j in i + 1..cs.len() {
|
||||
let (Some(a), Some(b)) = (nbrs.get(i), nbrs.get(j)) else { continue };
|
||||
if a.iter().any(|x| b.contains(x)) {
|
||||
let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
|
||||
if let Some(slot) = parent.get_mut(ri) {
|
||||
*slot = rj;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut fan_id: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
for (k, c) in cs.iter().enumerate() {
|
||||
let root = find(&mut parent, k);
|
||||
let next = fan_id.len();
|
||||
let fan = *fan_id.entry(root).or_insert(next);
|
||||
if fan > 0 {
|
||||
// A copy per extra fan.
|
||||
let copy = verts.len() + fan - 1;
|
||||
renames.push((*c, copy));
|
||||
}
|
||||
}
|
||||
let p = verts.get(v).copied().unwrap_or_default();
|
||||
for _ in 1..fan_id.len() {
|
||||
verts.push(p);
|
||||
orig.push(v);
|
||||
}
|
||||
}
|
||||
for ((g, li, i), new) in renames {
|
||||
if let Some(slot) = loops.get_mut(g).and_then(|ls| ls.get_mut(li)).and_then(|l| l.get_mut(i)) {
|
||||
*slot = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod diagnostic_tests {
|
||||
|
||||
+246
-21
@@ -6,6 +6,8 @@
|
||||
//! Stepped extrusions (case floors and walls) use sections between every cap level, so a
|
||||
//! circular boss can meet the rim without shifting either operand off its exact plane.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
@@ -19,13 +21,23 @@ const MAX_PLANAR_SEGMENTS: usize = 8192;
|
||||
const MAX_PLANAR_EVENTS: usize = 131_072;
|
||||
const MAX_PLANAR_PAIRS: usize = 16_000_000;
|
||||
|
||||
/// Boolean of planar regions bounded by straight lines and circular arcs. Boundaries stay
|
||||
/// analytic; disjoint results and retained islands are returned as separate regions.
|
||||
/// Free-form boundary segments (conics, Béziers) take part in planar booleans as lines and
|
||||
/// arcs within this distance (mm); loops that come out whole get their exact segments back.
|
||||
pub const PLANAR_FIT_TOL: f64 = 1e-3;
|
||||
|
||||
/// Boolean of planar regions bounded by straight lines, circular arcs and free-form segments.
|
||||
/// Lines and arcs stay analytic. Free-form loops are arranged as tangent arcs within
|
||||
/// [`PLANAR_FIT_TOL`]: a loop the operation leaves whole comes back exact, one it cuts keeps
|
||||
/// the arcs. Disjoint results and retained islands are returned as separate regions.
|
||||
pub fn planar_regions_boolean(a: &[Region2], b: &[Region2], op: BoolOp, tolerance: f64) -> Result<Vec<Region2>> {
|
||||
crate::guard("planar region boolean", || {
|
||||
if !tolerance.is_finite() || !(1e-9..=1.0).contains(&tolerance) {
|
||||
return Err(KernelError::Invalid("planar boolean tolerance must be finite and between 1e-9 and 1 mm".into()));
|
||||
}
|
||||
let mut exact: Vec<(Loop2, Loop2)> = Vec::new();
|
||||
let mut pieces = 0usize;
|
||||
let (a, b) = (fit_free_form(a, &mut exact, &mut pieces)?, fit_free_form(b, &mut exact, &mut pieces)?);
|
||||
let (a, b): (&[Region2], &[Region2]) = (&a, &b);
|
||||
let loops = || a.iter().chain(b).flat_map(|r| std::iter::once(&r.outer).chain(&r.holes));
|
||||
let mut edges = 0usize;
|
||||
let mut samples = 0usize;
|
||||
@@ -59,11 +71,72 @@ pub fn planar_regions_boolean(a: &[Region2], b: &[Region2], op: BoolOp, toleranc
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean2d(a, b, op, tolerance)
|
||||
.ok_or_else(|| KernelError::Failed("planar region arrangement failed or exceeded its bounded processing budget".into()))
|
||||
let out = boolean2d(a, b, op, tolerance)
|
||||
.ok_or_else(|| KernelError::Failed("planar region arrangement failed or exceeded its bounded processing budget".into()))?;
|
||||
Ok(restore_exact_loops(out, &exact, tolerance))
|
||||
})
|
||||
}
|
||||
|
||||
/// Regions with their free-form loops fitted by lines and arcs; `exact` records each fitted
|
||||
/// loop with the loop it stands for, `pieces` counts the fitted segments. Regions of lines and
|
||||
/// arcs alone are passed on as they are.
|
||||
fn fit_free_form<'a>(regions: &'a [Region2], exact: &mut Vec<(Loop2, Loop2)>, pieces: &mut usize) -> Result<Cow<'a, [Region2]>> {
|
||||
if regions.iter().all(Region2::is_line_arc) {
|
||||
return Ok(Cow::Borrowed(regions));
|
||||
}
|
||||
let mut fit = |lp: &Loop2| -> Result<Loop2> {
|
||||
if lp.is_line_arc() {
|
||||
return Ok(lp.clone());
|
||||
}
|
||||
let fitted = lp.fit_arcs(PLANAR_FIT_TOL).filter(|f| {
|
||||
*pieces = pieces.saturating_add(f.segs.len());
|
||||
*pieces <= MAX_PLANAR_SEGMENTS
|
||||
});
|
||||
let fitted = fitted.ok_or_else(|| {
|
||||
KernelError::Invalid("planar boolean: a free-form boundary is not finite or takes more arcs than the geometry budget".into())
|
||||
})?;
|
||||
exact.push((fitted.clone(), lp.clone()));
|
||||
Ok(fitted)
|
||||
};
|
||||
let fitted: Result<Vec<Region2>> =
|
||||
regions.iter().map(|r| Ok(Region2 { outer: fit(&r.outer)?, holes: r.holes.iter().map(&mut fit).collect::<Result<_>>()? })).collect();
|
||||
fitted.map(Cow::Owned)
|
||||
}
|
||||
|
||||
/// Output loops that are a fitted free-form loop, whole (from any start, either way round), get
|
||||
/// the exact loop back, running the same way.
|
||||
fn restore_exact_loops(regions: Vec<Region2>, exact: &[(Loop2, Loop2)], tol: f64) -> Vec<Region2> {
|
||||
if exact.is_empty() {
|
||||
return regions;
|
||||
}
|
||||
let near = |p: Vec2, q: Vec2| p.dist(q) <= tol * 10.0;
|
||||
let same_run = |l: &Loop2, f: &Loop2| -> bool {
|
||||
let n = f.segs.len();
|
||||
let Some(first) = l.segs.first() else { return false };
|
||||
(0..n).any(|k| {
|
||||
f.segs.get(k).is_some_and(|s| near(s.start(), first.start()))
|
||||
&& l.segs.iter().enumerate().all(|(i, s)| {
|
||||
f.segs.get((k + i) % n).is_some_and(|g| near(g.start(), s.start()) && near(g.end(), s.end()) && near(g.mid(), s.mid()))
|
||||
})
|
||||
})
|
||||
};
|
||||
let restore = |l: Loop2| -> Loop2 {
|
||||
for (fitted, original) in exact {
|
||||
if fitted.segs.len() != l.segs.len() {
|
||||
continue;
|
||||
}
|
||||
if same_run(&l, fitted) {
|
||||
return original.clone();
|
||||
}
|
||||
if same_run(&l, &fitted.reversed()) {
|
||||
return original.reversed();
|
||||
}
|
||||
}
|
||||
l
|
||||
};
|
||||
regions.into_iter().map(|r| Region2 { outer: restore(r.outer), holes: r.holes.into_iter().map(&restore).collect() }).collect()
|
||||
}
|
||||
|
||||
/// Extrude normalized planar material as one logical compound, retaining every disjoint
|
||||
/// region and its holes. Planar arrangement certifies separation before shells are joined.
|
||||
pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Body> {
|
||||
@@ -95,8 +168,10 @@ pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, en
|
||||
if normalized.is_empty() {
|
||||
return Err(KernelError::Invalid("empty planar material extrusion".into()));
|
||||
}
|
||||
for (i, a) in normalized.iter().enumerate() {
|
||||
for b in normalized.iter().skip(i + 1) {
|
||||
// The separation test knows lines and arcs: free-form loops take part as their arcs.
|
||||
let fitted = fit_free_form(&normalized, &mut Vec::new(), &mut 0)?;
|
||||
for (i, a) in fitted.iter().enumerate() {
|
||||
for b in fitted.iter().skip(i + 1) {
|
||||
for s in std::iter::once(&a.outer).chain(&a.holes).flat_map(|l| &l.segs) {
|
||||
for t in std::iter::once(&b.outer).chain(&b.holes).flat_map(|l| &l.segs) {
|
||||
remaining_pairs = remaining_pairs
|
||||
@@ -511,7 +586,7 @@ fn top_of(f: &mt::Face, plane: &Plane) -> f64 {
|
||||
/// Certify that a curved wall is swept along `d` from its surface representation.
|
||||
/// Seam vertices alone cannot establish this: a half-cylinder's seam normals can
|
||||
/// both be perpendicular to a transverse direction, silently filling its hole.
|
||||
fn wall_along(surface: &mt::Surface, d: Vec3, tol: f64, remaining: &mut usize) -> bool {
|
||||
pub(crate) fn wall_along(surface: &mt::Surface, d: Vec3, tol: f64, remaining: &mut usize) -> bool {
|
||||
if let mt::Surface::RevolutedCurve(processor) = surface {
|
||||
let revolution = processor.entity();
|
||||
let mt::Curve::Line(line) = revolution.entity_curve() else { return false };
|
||||
@@ -887,19 +962,61 @@ fn dist_to(regions: &[Region2], p: Vec2) -> f64 {
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// Direction of the boundary of `regions` through `p` (on it), as the boundary runs.
|
||||
fn dir_at(regions: &[Region2], p: Vec2, tol: f64) -> Option<Vec2> {
|
||||
for r in regions {
|
||||
for l in std::iter::once(&r.outer).chain(&r.holes) {
|
||||
for s in &l.segs {
|
||||
let t = s.closest_param(p);
|
||||
if s.point_at(t).dist(p) < tol {
|
||||
return s.derivative(t).normalized();
|
||||
/// The bounding box of a line or an arc, grown by twice the tolerance (and by the rounding of
|
||||
/// its own corners): (min, max). Whatever lies within the tolerance of the segment lies in the
|
||||
/// box. `None` for other segments and for values that are not finite: no box, and nothing is
|
||||
/// skipped for it.
|
||||
type SegBox = (Vec2, Vec2);
|
||||
|
||||
fn seg_box(s: &Seg2, tol: f64) -> Option<SegBox> {
|
||||
let mut pts = vec![s.start(), s.end()];
|
||||
match *s {
|
||||
Seg2::Line { .. } => {}
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
// The axis extremes the arc passes.
|
||||
for k in 0..4 {
|
||||
let ang = k as f64 * std::f64::consts::FRAC_PI_2;
|
||||
let delta =
|
||||
if sweep >= 0.0 { (ang - start).rem_euclid(std::f64::consts::TAU) } else { (start - ang).rem_euclid(std::f64::consts::TAU) };
|
||||
if delta <= sweep.abs() {
|
||||
pts.push(center + Vec2::from_angle(ang) * radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
None
|
||||
let lo = pts.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |m, p| Vec2::new(m.x.min(p.x), m.y.min(p.y)));
|
||||
let hi = pts.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |m, p| Vec2::new(m.x.max(p.x), m.y.max(p.y)));
|
||||
let g = tol * 2.0 + 1e-12 * pts.iter().fold(0.0, |m: f64, p| m.max(p.x.abs()).max(p.y.abs()));
|
||||
(pts.iter().all(|p| p.is_finite()) && g.is_finite()).then_some((Vec2::new(lo.x - g, lo.y - g), Vec2::new(hi.x + g, hi.y + g)))
|
||||
}
|
||||
|
||||
fn boxes_meet(a: Option<SegBox>, b: Option<SegBox>) -> bool {
|
||||
match (a, b) {
|
||||
(Some((a0, a1)), Some((b0, b1))) => a0.x <= b1.x && b0.x <= a1.x && a0.y <= b1.y && b0.y <= a1.y,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn box_holds(b: Option<SegBox>, p: Vec2) -> bool {
|
||||
b.is_none_or(|(lo, hi)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y)
|
||||
}
|
||||
|
||||
/// Distance from `p` to the nearest of `segs` whose box holds it: infinite when none does (no
|
||||
/// segment is within the tolerance of `p` then).
|
||||
fn near_dist(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2) -> f64 {
|
||||
segs.iter().zip(boxes).filter(|(_, b)| box_holds(**b, p)).map(|(s, _)| s.point_at(s.closest_param(p)).dist(p)).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// Direction of the first of `segs` through `p` (within `tol`), as it runs.
|
||||
fn near_dir(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2, tol: f64) -> Option<Vec2> {
|
||||
let (s, t) = segs
|
||||
.iter()
|
||||
.zip(boxes)
|
||||
.filter(|(_, b)| box_holds(**b, p))
|
||||
.map(|(s, _)| (s, s.closest_param(p)))
|
||||
.find(|(s, t)| s.point_at(*t).dist(p) < tol)?;
|
||||
s.derivative(t).normalized()
|
||||
}
|
||||
|
||||
/// 2D boolean of regions bounded by lines and arcs.
|
||||
@@ -922,12 +1039,19 @@ fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Se
|
||||
if sa.iter().chain(&sb).any(|s| !matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
|
||||
return None;
|
||||
}
|
||||
// Only segments whose bounding boxes meet can cross, touch or lie on each other: the other
|
||||
// pairs skip the exact tests (a closest-point search each).
|
||||
let (box_a, box_b): (Vec<_>, Vec<_>) = (sa.iter().map(|s| seg_box(s, tol)).collect(), sb.iter().map(|s| seg_box(s, tol)).collect());
|
||||
let apart = |i: usize, j: usize| !boxes_meet(box_a.get(i).copied().flatten(), box_b.get(j).copied().flatten());
|
||||
// Split every segment where it crosses the other side's boundary.
|
||||
let mut cut_a: Vec<Vec<f64>> = vec![Vec::new(); sa.len()];
|
||||
let mut cut_b: Vec<Vec<f64>> = vec![Vec::new(); sb.len()];
|
||||
let mut events = 0usize;
|
||||
for (i, s) in sa.iter().enumerate() {
|
||||
for (j, t) in sb.iter().enumerate() {
|
||||
if apart(i, j) {
|
||||
continue;
|
||||
}
|
||||
for (u, v) in crossings(s, t, tol) {
|
||||
events += 2;
|
||||
if events > MAX_PLANAR_EVENTS {
|
||||
@@ -940,7 +1064,10 @@ fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Se
|
||||
}
|
||||
// Ends of the other side's segments lying on a segment split it too (touching corners).
|
||||
for (i, s) in sa.iter().enumerate() {
|
||||
for t in &sb {
|
||||
for (j, t) in sb.iter().enumerate() {
|
||||
if apart(i, j) {
|
||||
continue;
|
||||
}
|
||||
for p in [t.start(), t.end()] {
|
||||
let u = s.closest_param(p);
|
||||
if s.point_at(u).dist(p) < tol {
|
||||
@@ -954,7 +1081,10 @@ fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Se
|
||||
}
|
||||
}
|
||||
for (j, t) in sb.iter().enumerate() {
|
||||
for s in &sa {
|
||||
for (i, s) in sa.iter().enumerate() {
|
||||
if apart(i, j) {
|
||||
continue;
|
||||
}
|
||||
for p in [s.start(), s.end()] {
|
||||
let v = t.closest_param(p);
|
||||
if t.point_at(v).dist(p) < tol {
|
||||
@@ -997,9 +1127,9 @@ fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Se
|
||||
let (n, tag) = (split.len(), ta.get(i).copied().unwrap_or(BLANK));
|
||||
for (k, p) in split.into_iter().enumerate() {
|
||||
let m = p.point_at(0.5);
|
||||
let on = dist_to(&b, m) < tol;
|
||||
let on = near_dist(&sb, &box_b, m) < tol;
|
||||
let keep = if on {
|
||||
let same = dir_at(&b, m, tol).is_some_and(|d| d.dot(p.derivative(0.5)) > 0.0);
|
||||
let same = near_dir(&sb, &box_b, m, tol).is_some_and(|d| d.dot(p.derivative(0.5)) > 0.0);
|
||||
match op {
|
||||
BoolOp::Union | BoolOp::Intersect => same,
|
||||
BoolOp::Cut => !same,
|
||||
@@ -1028,7 +1158,7 @@ fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Se
|
||||
let (n, tag) = (split.len(), tb.get(j).copied().unwrap_or(BLANK));
|
||||
for (k, p) in split.into_iter().enumerate() {
|
||||
let m = p.point_at(0.5);
|
||||
if dist_to(&a, m) < tol {
|
||||
if near_dist(&sa, &box_a, m) < tol {
|
||||
continue;
|
||||
}
|
||||
let ins = inside(&a, m);
|
||||
@@ -2205,6 +2335,101 @@ mod planar_tests {
|
||||
assert!(extrude_planar_regions(&plane, &circles, 0.0, 2.0).is_err());
|
||||
}
|
||||
|
||||
/// An ellipse about `c` with semi-axes `a` (along x) and `b`, as four exact conics.
|
||||
fn ellipse(c: Vec2, a: f64, b: f64) -> Loop2 {
|
||||
let corners = [c + Vec2::new(a, 0.0), c + Vec2::new(0.0, b), c - Vec2::new(a, 0.0), c - Vec2::new(0.0, b)];
|
||||
let w = std::f64::consts::FRAC_1_SQRT_2;
|
||||
Loop2 {
|
||||
segs: (0..4).map(|i| Seg2::Conic { a: corners[i], apex: corners[i] + corners[(i + 1) % 4] - c, b: corners[(i + 1) % 4], w }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_form_cut_outs_stay_exact_when_whole_and_follow_arcs_when_cut() {
|
||||
let plate = rectangle(Vec2::ZERO, Vec2::new(100.0, 60.0));
|
||||
let hole = Region2 { outer: ellipse(Vec2::new(40.0, 30.0), 15.0, 8.0), holes: vec![] };
|
||||
let out = planar_regions_boolean(std::slice::from_ref(&plate), std::slice::from_ref(&hole), BoolOp::Cut, 1e-7).unwrap();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].holes.len(), 1);
|
||||
// The whole ellipse comes back as its four exact conics, running as a hole (clockwise):
|
||||
// the plate less π·15·8.
|
||||
let h = &out[0].holes[0];
|
||||
assert_eq!(h.segs.len(), 4);
|
||||
assert!(h.segs.iter().all(|s| hole.outer.segs.contains(&s.reversed())), "{h:?}");
|
||||
assert!(h.signed_area() < 0.0);
|
||||
let exact = 6000.0 - std::f64::consts::PI * 15.0 * 8.0;
|
||||
assert!((out[0].area() - exact).abs() < 1e-9, "{} vs {exact}", out[0].area());
|
||||
// What the cut takes is the ellipse, whole and exact, counter-clockwise.
|
||||
let taken = planar_regions_boolean(std::slice::from_ref(&plate), std::slice::from_ref(&hole), BoolOp::Intersect, 1e-7).unwrap();
|
||||
assert!(taken.len() == 1 && taken[0].outer.segs.iter().all(|s| hole.outer.segs.contains(s)), "{taken:?}");
|
||||
// An ellipse centred on the plate's edge makes a notch of half the ellipse: its part
|
||||
// inside follows arcs within the fit tolerance (times half the perimeter, 37 mm).
|
||||
let over = Region2 { outer: ellipse(Vec2::new(100.0, 30.0), 15.0, 8.0), holes: vec![] };
|
||||
let out = planar_regions_boolean(std::slice::from_ref(&plate), std::slice::from_ref(&over), BoolOp::Cut, 1e-7).unwrap();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!(out[0].holes.is_empty() && out[0].outer.is_line_arc());
|
||||
let notched = 6000.0 - std::f64::consts::PI * 15.0 * 8.0 / 2.0;
|
||||
assert!((out[0].area() - notched).abs() < PLANAR_FIT_TOL * 37.0, "{} vs {notched}", out[0].area());
|
||||
// Two ellipses that overlap join into one outline of arcs; apart, both stay exact.
|
||||
let beside = |dx: f64| Region2 { outer: ellipse(Vec2::new(40.0 + dx, 30.0), 15.0, 8.0), holes: vec![] };
|
||||
let joined = planar_regions_boolean(std::slice::from_ref(&hole), &[beside(20.0)], BoolOp::Union, 1e-7).unwrap();
|
||||
assert!(joined.len() == 1 && joined[0].outer.is_line_arc(), "{joined:?}");
|
||||
let apart = planar_regions_boolean(std::slice::from_ref(&hole), &[beside(40.0)], BoolOp::Union, 1e-7).unwrap();
|
||||
assert!(apart.len() == 2 && apart.iter().all(|r| r.outer.segs.len() == 4 && !r.outer.is_line_arc()), "{apart:?}");
|
||||
// The flat material extrudes with the exact elliptic wall: four conic faces, two caps.
|
||||
let body = extrude_planar_regions(&Plane::XY, &planar_regions_boolean(&[plate], &[hole], BoolOp::Cut, 1e-7).unwrap(), 0.0, 2.0).unwrap();
|
||||
assert_eq!(body.face_count(), 4 + 4 + 2);
|
||||
let v = crate::measure(&body).unwrap().volume;
|
||||
assert!((v - exact * 2.0).abs() < 1e-3 * exact, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_form_loops_that_cannot_be_followed_are_refused() {
|
||||
let plate = rectangle(Vec2::ZERO, Vec2::new(100.0, 60.0));
|
||||
let mut bad = ellipse(Vec2::new(40.0, 30.0), 15.0, 8.0);
|
||||
bad.segs[1] = Seg2::Conic { a: bad.segs[1].start(), apex: Vec2::new(f64::NAN, 0.0), b: bad.segs[1].end(), w: 1.0 };
|
||||
let e = planar_regions_boolean(std::slice::from_ref(&plate), &[Region2 { outer: bad, holes: vec![] }], BoolOp::Cut, 1e-7).unwrap_err();
|
||||
assert!(e.to_string().contains("free-form"), "{e}");
|
||||
// More fitted arcs than the arrangement's segment budget.
|
||||
let many: Vec<Region2> = (0..400).map(|i| Region2 { outer: ellipse(Vec2::new(40.0 * i as f64, 0.0), 15.0, 8.0), holes: vec![] }).collect();
|
||||
assert!(planar_regions_boolean(&[plate], &many, BoolOp::Cut, 1e-7).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_boxes_hold_what_lies_within_the_tolerance() {
|
||||
// An arc of radius 10 about the origin from 45° to 135° passes the top of its circle:
|
||||
// x within ±10/√2, y from 10/√2 to 10, grown by twice the tolerance.
|
||||
let r = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
|
||||
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: std::f64::consts::FRAC_PI_4, sweep: std::f64::consts::FRAC_PI_2 };
|
||||
for s in [arc, arc.reversed()] {
|
||||
let (lo, hi) = seg_box(&s, 1e-3).unwrap();
|
||||
assert!(lo.dist(Vec2::new(-r - 2e-3, r - 2e-3)) < 1e-9 && hi.dist(Vec2::new(r + 2e-3, 10.0 + 2e-3)) < 1e-9, "{lo:?} {hi:?}");
|
||||
}
|
||||
let (lo, hi) = seg_box(&Seg2::Line { a: Vec2::new(3.0, -1.0), b: Vec2::new(-2.0, 4.0) }, 0.5).unwrap();
|
||||
assert!(lo.dist(Vec2::new(-3.0, -2.0)) < 1e-9 && hi.dist(Vec2::new(4.0, 5.0)) < 1e-9, "{lo:?} {hi:?}");
|
||||
// A line 3 tolerances above the arc's top meets its box (each is grown by two); one
|
||||
// 5 tolerances above does not. No box (a free-form segment, a value that is not
|
||||
// finite) hides nothing.
|
||||
let level = |y: f64| seg_box(&Seg2::Line { a: Vec2::new(-1.0, y), b: Vec2::new(1.0, y) }, 1e-3);
|
||||
assert!(boxes_meet(seg_box(&arc, 1e-3), level(10.003)) && !boxes_meet(seg_box(&arc, 1e-3), level(10.005)));
|
||||
let free = ellipse(Vec2::ZERO, 2.0, 1.0).segs[0];
|
||||
assert!(seg_box(&free, 1e-3).is_none() && seg_box(&Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::X }, 1e-3).is_none());
|
||||
assert!(boxes_meet(None, level(500.0)) && box_holds(None, Vec2::new(500.0, 500.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_small_cut_outs_far_apart_cut_as_before() {
|
||||
// 400 round holes in a strip: almost every pair of segments is far apart.
|
||||
let plate = rectangle(Vec2::ZERO, Vec2::new(2000.0, 20.0));
|
||||
let holes: Vec<Region2> =
|
||||
(0..400).map(|i| Region2 { outer: Loop2::circle(Vec2::new(2.5 + 5.0 * i as f64, 10.0), 1.8), holes: vec![] }).collect();
|
||||
let out = planar_regions_boolean(&[plate], &holes, BoolOp::Cut, 1e-7).unwrap();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].holes.len(), 400);
|
||||
let exact = 40_000.0 - 400.0 * std::f64::consts::PI * 1.8 * 1.8;
|
||||
assert!((out[0].area() - exact).abs() < 1e-6, "{}", out[0].area());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytic_circle_classification_respects_signed_sweeps_and_seams() {
|
||||
for seam in [0.0, 0.13, std::f64::consts::FRAC_PI_2, -std::f64::consts::FRAC_PI_2] {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user