mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 11:35:55 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9879fa668 | |||
| 45bc22fc30 | |||
| 4aaa5457b3 | |||
| 1fd4a2f782 | |||
| 00baf1d883 | |||
| 91de39d3fe | |||
| 219816d282 | |||
| 2880525e6b | |||
| aa156f4a9f | |||
| ccc36dc1f9 | |||
| 0fe032438f | |||
| 72c5d974b8 | |||
| 201e9d5f9f | |||
| af0440aaab | |||
| 49956c2797 | |||
| b24e478bc1 | |||
| 9ab25cfd4a | |||
| 1ae6e9beb4 | |||
| fc1d70cc3b | |||
| dd67472252 | |||
| 9062c739d8 | |||
| fb4c1d74b9 | |||
| 0dee9deec1 | |||
| 722bd007c8 | |||
| 9e60080b10 | |||
| 455ca86a6c | |||
| 6fde5ff55b | |||
| 7f92093cc8 | |||
| ddd528abaf | |||
| 5c7149e57d |
@@ -0,0 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [bherbruck]
|
||||
ko_fi: bherbruck
|
||||
+43
-49
@@ -5,58 +5,53 @@ parameter to watch the timeline rebuild. [Documentation](https://bherbruck.githu
|
||||
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
|
||||
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
|
||||
**Why 0.6:** this is a minor bump because two things change the files you work with: the
|
||||
rename to SolidCraft and the new design file format. Files from earlier builds open fine, but
|
||||
builds 0.5 and older can't open designs saved by 0.6.
|
||||
**Why 0.7:** this is a minor bump because the design file format moved to version 15 (sketches
|
||||
on cylinder caps and sheet metal panels need new references), and because it adds new features.
|
||||
|
||||
**What's new**
|
||||
- **New design file format.** Saves are a zip container by default; plain JSON is still one
|
||||
click away (details below).
|
||||
- **Sheet metal:** flange corners close at any angle, and cut-outs fold across bends at any
|
||||
angle, including curved bends.
|
||||
- **Crash reports:** after a crash, Help ▸ Crash Reports… shows what happened with a ready-to-file
|
||||
issue draft. Reports stay on your computer; nothing is sent. `solidcraft-cli crashes` lists and
|
||||
replays them.
|
||||
- **Snap package** for x86_64 and aarch64 on every release, alongside the AppImage, `.deb`, `.rpm`
|
||||
and Flatpak.
|
||||
- **Trackpad navigation** (two-finger pan, pinch zoom), a fillet option that shrinks a radius too
|
||||
big for its edges, cleaner dialogs and toolbar panels, and the unfinished Generative Design
|
||||
workspace is gone.
|
||||
- Many modelling fixes: extrude/revolve in moved components, To Object picks, very flat arcs,
|
||||
touching lines and arcs in booleans, and `solidcraft-cli run` now fails when a feature fails.
|
||||
- **Sketching on stacked extrudes works again.** Creating a sketch on the top face of an extrude
|
||||
stacked on another failed with "operation failed" since 0.3
|
||||
([#489](https://github.com/bherbruck/solidcraft/issues/489)). It's fixed, and every flat face
|
||||
of the test models is now checked for this on every change.
|
||||
- **Select Other:** hold or right-click to pick something hidden under something else; click
|
||||
again to cycle through what's underneath.
|
||||
- **Tabs are named after their file** instead of "Untitled".
|
||||
- **Modelling:** a flange angle and height per edge, Patch fills non-planar loops, sketches on
|
||||
cylinder caps and on sheet metal faces, and a feature rename updates everything that refers to
|
||||
it.
|
||||
- **Fixes:** revolve with edges on the axis, shell and fillet with small rounds, Join keeps face
|
||||
attachments, and STEP export writes each lump of a body as its own solid.
|
||||
- **Navigation and UI:** orbiting from empty space turns around the visible geometry, Ctrl+drag
|
||||
orbits in every preset, toolbar drops land across labels and gaps, Combine previews before OK,
|
||||
and sketch constraints accept the sketch's own x / y axes.
|
||||
- Opening a design reads its metadata and preview through a bounded, seekable reader; the docs
|
||||
and web app have link preview cards.
|
||||
|
||||
**New file format.** Save and Save As now write `.solidcraft` designs as a zip container
|
||||
that stores imported STEP, mesh and image data as binary, once each, at full precision, so
|
||||
designs with imports save and open faster. What that means for you:
|
||||
- Your existing `.solidcraft` and `.solvecraft` files (plain JSON) still open as before; the
|
||||
format is detected from the contents.
|
||||
- Want plain JSON (for diffs or scripts)? Use **File ▸ Save As JSON…**, or pick a
|
||||
`.json` name in Save As; later saves keep JSON. **File ▸ Export Design as JSON…** writes a
|
||||
separate JSON copy. From the CLI, `solidcraft-cli run … --save design.json`; from MCP or
|
||||
scripts, pass `"format": "json"` to `save` / `file.save`.
|
||||
- SolidCraft 0.5 and older can't open designs saved by 0.6, as a container or as JSON (the
|
||||
JSON format tag is now `solidcraft/14`). Keep a copy of a file you may still open in 0.5.
|
||||
**File format.** Designs are now format 15 (`solidcraft/15`). SolidCraft 0.6 and older can't
|
||||
open designs saved by 0.7, as a container or as JSON; keep a copy of a file you may still open
|
||||
in 0.6. Files from every earlier version still open in 0.7.
|
||||
|
||||
**Renamed to SolidCraft.** Everything now uses the new name: the `solidcraft` and
|
||||
`solidcraft-cli` programs, `.solidcraft` designs, `SOLIDCRAFT_*` environment variables,
|
||||
settings folders, package names and the `solidcraft` MCP server. Upgrading from SolveCraft:
|
||||
- `.solvecraft` designs still open (double-click, Open, drag-drop, the CLI and MCP); new saves
|
||||
are `.solidcraft`.
|
||||
- Settings, preferences and recovery files are copied to the new folders on first launch; the
|
||||
old folders are left in place. `SOLVECRAFT_*` environment variables are still read.
|
||||
- `solvecraft-cli` remains as a deprecated alias for one release; it forwards to
|
||||
`solidcraft-cli` and prints a note. Agents: `claude mcp add solidcraft -- solidcraft-cli mcp`;
|
||||
an existing `claude mcp add solvecraft -- solvecraft-cli mcp` setup keeps working.
|
||||
- Windows: the `.msi` upgrades the existing installation in place (now in
|
||||
`Program Files\SolidCraft`). Linux: the `solidcraft` `.deb`/`.rpm` replaces the `solvecraft`
|
||||
package. The Flatpak app ID stays `ai.storyteller.solvecraft`, so `flatpak update` just works,
|
||||
and existing AppImages find the update.
|
||||
- macOS: quit the app, move the old `SolveCraft.app` to the Trash (your designs and settings
|
||||
stay), then drag `SolidCraft.app` from the DMG onto Applications and pin it in the Dock.
|
||||
- Details: [compatibility notes](https://github.com/bherbruck/solidcraft/blob/main/docs/compatibility.md).
|
||||
**Known limitations**
|
||||
- Some faces still need the fixed-plane fallback for sketches: side faces Join merged from two
|
||||
extrudes ([#491](https://github.com/bherbruck/solidcraft/issues/491)), faces split by a later
|
||||
feature ([#492](https://github.com/bherbruck/solidcraft/issues/492)), Cut walls and chamfer
|
||||
faces ([#493](https://github.com/bherbruck/solidcraft/issues/493)), faces after Revolve,
|
||||
Offset Face or Boss ([#494](https://github.com/bherbruck/solidcraft/issues/494)) and sheet
|
||||
metal thickness faces ([#495](https://github.com/bherbruck/solidcraft/issues/495)).
|
||||
- Cutting a coaxial cylinder from a thin disc can lose a little volume
|
||||
([#500](https://github.com/bherbruck/solidcraft/issues/500)); an edge flange with different
|
||||
per-edge angles leaves gaps at the corners
|
||||
([#501](https://github.com/bherbruck/solidcraft/issues/501)).
|
||||
- More in [ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
|
||||
|
||||
Documentation, the browser app and the Flatpak feed use the SolidCraft project Pages URLs;
|
||||
old-name Pages addresses are not permanent aliases.
|
||||
**Upgrading from 0.5 or earlier.** 0.6 renamed SolveCraft to SolidCraft and made the zip
|
||||
container the default save format. `.solvecraft` designs, old settings folders and
|
||||
`SOLVECRAFT_*` variables are still picked up, and `solvecraft-cli` still forwards to
|
||||
`solidcraft-cli`. Want plain JSON? Use **File ▸ Save As JSON…** or `--save design.json`. On
|
||||
macOS, replace `SolveCraft.app` with `SolidCraft.app`. Details:
|
||||
[compatibility notes](https://github.com/bherbruck/solidcraft/blob/main/docs/compatibility.md).
|
||||
|
||||
Documentation, the browser app and the Flatpak feed use the SolidCraft project Pages URLs.
|
||||
|
||||
**Which file?**
|
||||
- **Windows:** `SolidCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
|
||||
@@ -89,6 +84,5 @@ The builds aren't signed yet. On macOS, right-click `SolidCraft.app` in Applicat
|
||||
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolidCraft.app`).
|
||||
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
|
||||
Licences are built in (Help ▸ About ▸ Licences, `solidcraft-cli licences`); the packages also
|
||||
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
|
||||
[ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
|
||||
install `THIRD-PARTY-LICENSES.txt`.
|
||||
|
||||
|
||||
+3
-2
@@ -37,8 +37,9 @@ Generated-in-code assets are original and have no file to list:
|
||||
| assets/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/enclosure-demo-0.2.1.gif | SolidCraft contributors | original electronics enclosure screen recording of the actual SolidCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/enclosure-demo-0.2.1.mp4 | SolidCraft contributors | original electronics enclosure screen recording of the actual SolidCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.6.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.6.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/og.png | SolidCraft contributors | original 1280 × 640 social preview card (the SolidCraft mark, name and tagline), served by the docs site for link previews | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sample-plate.png | SolidCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/bracket.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/enclosure.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
|
||||
Generated
+13
-13
@@ -3290,7 +3290,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3307,7 +3307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -3316,7 +3316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3331,7 +3331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3347,7 +3347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3356,7 +3356,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"serde",
|
||||
@@ -3371,7 +3371,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3386,7 +3386,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -3394,7 +3394,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3404,7 +3404,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3415,7 +3415,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3430,7 +3430,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4853,7 +4853,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
*Start with an empty design and draw the original parametric electronics enclosure in the English desktop UI: rounded corners, a shell, a USB port sketched directly on the wall and a four-boss pattern. Change width from 80 → 100 mm and wall thickness from 2 → 3 mm in Parameters and watch it rebuild. Original animated pointer and click cues; 4× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/enclosure-demo-0.2.1.mp4), generated with the [demo script](examples/demo/record_ui.sh).*
|
||||
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots and a colour, then height 24 → 32 mm in Parameters and it all rebuilds. 6× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.6.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
|
||||
|
||||
## Download
|
||||
|
||||
@@ -81,11 +81,15 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
the boundary and keeps existing extrusions usable.
|
||||
Curve drawing and picking adapt together to zoom and display scale, including small circles
|
||||
at Fit and sketches in placed components.
|
||||
- **Constraints and dimensions** solved by our own solver (damped Gauss–Newton with
|
||||
degree-of-freedom analysis): coincident, horizontal/vertical, parallel, perpendicular, tangent,
|
||||
equal, concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
|
||||
radius, diameter and angle dimensions driven by parameters. Over-constraining edits are
|
||||
rejected; fully constrained geometry turns black.
|
||||
- **Constraints and dimensions** solved by our own solver. Points the constraints fix relative
|
||||
to known geometry are placed exactly by construction (loci intersections and rigid-cluster
|
||||
decomposition, after Owen 1991 and Fudos–Hoffmann 1997); Levenberg–Marquardt solves the rest,
|
||||
and the rank of its Jacobian gives the remaining degrees of freedom. Constraints: coincident,
|
||||
point on curve, horizontal/vertical, parallel, perpendicular, tangent, curvature (G2), equal,
|
||||
concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
|
||||
point-to-line, radius, diameter, angle, arc sweep, arc length and slot length dimensions driven
|
||||
by parameters. Over-constraining edits are rejected; fully constrained geometry turns black.
|
||||
Dragging solves the sketch every frame and rebuilds the model once, when the drag ends.
|
||||
Select a line or its two endpoints, type its dimension and press Enter to commit one undo
|
||||
step. Fit keeps sketch geometry clear of the palette and navigation controls.
|
||||
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
|
||||
|
||||
+7
-1
@@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap | in progress | 36 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
@@ -61,6 +61,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Dimensioning and error reporting
|
||||
|
||||
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
|
||||
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
|
||||
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
|
||||
arguments reject such a name when an older design's construction plane already has it. Use
|
||||
the other alias, the construction feature's numeric ID or an explicit plane; existing
|
||||
@@ -285,6 +287,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
record their coordinate frame; legacy wires migrate from known provenance without changing
|
||||
coordinates. Broken legacy links with missing provenance can require explicit refresh or relink.
|
||||
See [occurrence workflows](docs/occurrence-workflows.md).
|
||||
- Toolbar drops on panel labels, gaps and margins append commands with insertion feedback;
|
||||
button drops keep insert-before ordering.
|
||||
- Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel.
|
||||
Parameters opens the user/model variable editor; Configurations opens the configuration table.
|
||||
Configure remains available in the panel dropdown.
|
||||
@@ -317,6 +321,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
exact holes and retained islands, including the union of multiple selected profiles, and
|
||||
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
|
||||
tapered or bend-crossing machining refuses atomically instead of restoring material later.
|
||||
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
|
||||
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
|
||||
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
|
||||
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
|
||||
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
|
||||
|
||||
@@ -17,6 +17,10 @@ hostile variants of it never panic.
|
||||
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
|
||||
| `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) |
|
||||
| `split-body-pieces` | v0.3.2 (07e263d) | a slot along X cuts a block into two bodies; a fillet, a chamfer and an appearance refer to the pieces by body name (`Body1`, `Body1 (1)`) |
|
||||
| `issue-489-stacked-join` | v0.6.0 (809ecf7), desktop | a 70 × 84 extrude, then a 70 × 62 strip joined on its top face, sketched partly over the face's projected edges (issue #489; expected values from main at 2880525) |
|
||||
|
||||
The scripts that made them are in `scripts/`. Never regenerate a file with a newer build: the
|
||||
point is that it was written by the old one. Add new files for new eras instead.
|
||||
The scripts that made them are in `scripts/` (`issue-489-stacked-join` was drawn in the desktop
|
||||
app and has none). Every flat face of every final body must also take an attached sketch and an
|
||||
offset plane; the faces that can't yet are listed with their issues in `design_corpus.rs`.
|
||||
|
||||
Never regenerate a file with a newer build: the point is that it was written by the old one. Add new files for new eras instead.
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 25751.999999999945,
|
||||
"bbox": {
|
||||
"max": [
|
||||
35.0,
|
||||
42.0,
|
||||
48.00000000000001
|
||||
],
|
||||
"min": [
|
||||
-35.0,
|
||||
-42.000000000000014,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
5.524909928104648e-14,
|
||||
-1.0945382096961234e-12,
|
||||
22.366336633663238
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 24,
|
||||
"face_types": {
|
||||
"plane": 10
|
||||
},
|
||||
"faces": 10,
|
||||
"kernel": {
|
||||
"edges": 24,
|
||||
"faces": 10,
|
||||
"shells": 1,
|
||||
"vertices": 16
|
||||
},
|
||||
"mass_g": 328.33079999999734,
|
||||
"material": "Default",
|
||||
"name": "Body1",
|
||||
"vertices": 16,
|
||||
"volume_mm3": 254519.9999999979
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [
|
||||
{
|
||||
"comment": "",
|
||||
"error": null,
|
||||
"expression": "84",
|
||||
"model": true,
|
||||
"name": "d1",
|
||||
"unit": "mm",
|
||||
"value": 84.0
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"error": null,
|
||||
"expression": "70",
|
||||
"model": true,
|
||||
"name": "d2",
|
||||
"unit": "mm",
|
||||
"value": 70.0
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"error": null,
|
||||
"expression": "62",
|
||||
"model": true,
|
||||
"name": "d4",
|
||||
"unit": "mm",
|
||||
"value": 62.0
|
||||
}
|
||||
],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 6,
|
||||
"dof": 0,
|
||||
"id": 1,
|
||||
"name": "Sketch1",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"curves": 10,
|
||||
"dof": 1,
|
||||
"id": 3,
|
||||
"name": "Sketch2",
|
||||
"profiles": 3,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.131943,
|
||||
"name": "Sketch1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 1.0812709999999999,
|
||||
"name": "Extrude1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.450002,
|
||||
"name": "Sketch2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 5.191514000000001,
|
||||
"name": "Extrude2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 25751.999999999945,
|
||||
"edges": 24,
|
||||
"faces": 10,
|
||||
"mass_g": 328.33079999999734,
|
||||
"vertices": 16,
|
||||
"volume_mm3": 254519.9999999979
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Sketch1",
|
||||
"plane": {
|
||||
"name": "XY",
|
||||
"type": "origin"
|
||||
},
|
||||
"sketch": {
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"l": 0,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"l": 2,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"l": 1,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"l": 3,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"l": 4,
|
||||
"p": 5,
|
||||
"type": "midpoint"
|
||||
},
|
||||
{
|
||||
"id": "k6",
|
||||
"p": 0,
|
||||
"q": 5,
|
||||
"type": "coincident"
|
||||
},
|
||||
{
|
||||
"id": "k7",
|
||||
"l": 1,
|
||||
"param": "d1",
|
||||
"type": "length",
|
||||
"value": 84.0
|
||||
},
|
||||
{
|
||||
"id": "k8",
|
||||
"l": 0,
|
||||
"param": "d2",
|
||||
"type": "length",
|
||||
"value": 70.0
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 8,
|
||||
"l": 6,
|
||||
"p": 5
|
||||
},
|
||||
"curves": [
|
||||
{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false,
|
||||
"id": "l1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false,
|
||||
"id": "l2",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false,
|
||||
"id": "l3",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false,
|
||||
"id": "l4",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 1,
|
||||
"b": 3,
|
||||
"construction": true,
|
||||
"id": "l5",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 4,
|
||||
"construction": true,
|
||||
"id": "l6",
|
||||
"type": "line"
|
||||
}
|
||||
],
|
||||
"points": [
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
-35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
-35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p5",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"suppressed": false,
|
||||
"type": "sketch"
|
||||
},
|
||||
{
|
||||
"extent": {
|
||||
"direction": "positive",
|
||||
"distance": "30"
|
||||
},
|
||||
"id": 2,
|
||||
"name": "Extrude1",
|
||||
"operation": "new_body",
|
||||
"param_names": [
|
||||
"d3"
|
||||
],
|
||||
"profiles": {
|
||||
"loops": [
|
||||
[
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
],
|
||||
"type": "curves"
|
||||
},
|
||||
"sketch": 1,
|
||||
"suppressed": false,
|
||||
"type": "extrude"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Sketch2",
|
||||
"plane": {
|
||||
"anchor": {
|
||||
"author_path": [],
|
||||
"component": 0,
|
||||
"coordinates": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"path": [],
|
||||
"target": {
|
||||
"body": "Body1",
|
||||
"certificate": {
|
||||
"high": true,
|
||||
"holes": [],
|
||||
"kind": "extrude_cap",
|
||||
"outer": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
],
|
||||
"sketch": 1
|
||||
},
|
||||
"name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
|
||||
"origin_feature": 2,
|
||||
"point": [
|
||||
0.0,
|
||||
0.0,
|
||||
30.0
|
||||
]
|
||||
},
|
||||
"x_axis": [
|
||||
1.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"at": [
|
||||
2.012802880253332,
|
||||
7.4635742926698185,
|
||||
30.000000000000114
|
||||
],
|
||||
"name": "F2:top",
|
||||
"plane": {
|
||||
"origin": [
|
||||
0.0,
|
||||
0.0,
|
||||
30.0
|
||||
],
|
||||
"x_dir": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"y_dir": [
|
||||
0.0,
|
||||
1.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"type": "face"
|
||||
},
|
||||
"sketch": {
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"l": 4,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"l": 6,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"l": 5,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"l": 7,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"l": 8,
|
||||
"p": 9,
|
||||
"type": "midpoint"
|
||||
},
|
||||
{
|
||||
"id": "k6",
|
||||
"p": 0,
|
||||
"q": 9,
|
||||
"type": "coincident"
|
||||
},
|
||||
{
|
||||
"id": "k7",
|
||||
"p": 7,
|
||||
"param": "d4",
|
||||
"q": 6,
|
||||
"text": [
|
||||
-0.2,
|
||||
9.200000000000003
|
||||
],
|
||||
"type": "distance",
|
||||
"value": 62.0
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"j": 1,
|
||||
"k": 7,
|
||||
"l": 10,
|
||||
"p": 9
|
||||
},
|
||||
"curves": [
|
||||
{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false,
|
||||
"id": "l1",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 3,
|
||||
"b": 1,
|
||||
"construction": false,
|
||||
"id": "l2",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 4,
|
||||
"construction": false,
|
||||
"id": "l3",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 4,
|
||||
"b": 3,
|
||||
"construction": false,
|
||||
"id": "l4",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 5,
|
||||
"b": 6,
|
||||
"construction": false,
|
||||
"id": "l5",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 6,
|
||||
"b": 7,
|
||||
"construction": false,
|
||||
"id": "l6",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 7,
|
||||
"b": 8,
|
||||
"construction": false,
|
||||
"id": "l7",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 8,
|
||||
"b": 5,
|
||||
"construction": false,
|
||||
"id": "l8",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 5,
|
||||
"b": 7,
|
||||
"construction": true,
|
||||
"id": "l9",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 6,
|
||||
"b": 8,
|
||||
"construction": true,
|
||||
"id": "l10",
|
||||
"type": "line"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"auto_face": true,
|
||||
"face_name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
|
||||
"id": "j1",
|
||||
"kind": "project",
|
||||
"source": {
|
||||
"at": [
|
||||
11.666666666666666,
|
||||
-13.999999999999995,
|
||||
30.0
|
||||
],
|
||||
"body": "Body1",
|
||||
"type": "face"
|
||||
}
|
||||
}
|
||||
],
|
||||
"points": [
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p1",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
-35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p2",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
-35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p3",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p4",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p5",
|
||||
"pos": [
|
||||
-35.0,
|
||||
-31.000000000002185
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p6",
|
||||
"pos": [
|
||||
35.0,
|
||||
-31.00000000000349
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p7",
|
||||
"pos": [
|
||||
35.0,
|
||||
30.99999999999603
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p8",
|
||||
"pos": [
|
||||
-35.0,
|
||||
30.999999999995318
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p9",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"suppressed": false,
|
||||
"type": "sketch"
|
||||
},
|
||||
{
|
||||
"extent": {
|
||||
"direction": "positive",
|
||||
"distance": "18"
|
||||
},
|
||||
"id": 4,
|
||||
"name": "Extrude2",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d5"
|
||||
],
|
||||
"profiles": {
|
||||
"loops": [
|
||||
[
|
||||
"l1",
|
||||
"l5",
|
||||
"l4",
|
||||
"l7"
|
||||
]
|
||||
],
|
||||
"type": "curves"
|
||||
},
|
||||
"sketch": 3,
|
||||
"suppressed": false,
|
||||
"type": "extrude"
|
||||
}
|
||||
],
|
||||
"format": "solidcraft/14",
|
||||
"name": "Untitled",
|
||||
"next_id": 6,
|
||||
"params": [
|
||||
{
|
||||
"expr": "84",
|
||||
"model": true,
|
||||
"name": "d1",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"expr": "70",
|
||||
"model": true,
|
||||
"name": "d2",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"expr": "62",
|
||||
"model": true,
|
||||
"name": "d4",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"units": "mm"
|
||||
}
|
||||
@@ -131,6 +131,172 @@ fn body_names_saved_before_exact_history_keep_their_pieces() {
|
||||
assert!(s.doc.appearances.bodies.contains_key("Body1 (1)"));
|
||||
}
|
||||
|
||||
/// Flat faces that can't take an attached sketch yet, by cause: (design, body, the faces'
|
||||
/// planes as outward normal and distance from the origin, or every flat face of the body,
|
||||
/// issue). Face indices are not used: they can change between builds. Each issue has an
|
||||
/// ignored test below that checks its faces; a listed plane whose face attaches fails the main
|
||||
/// check until it is removed from here.
|
||||
type Planes = &'static [([f64; 3], f64)];
|
||||
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 9] = [
|
||||
("issue-489-stacked-join", "Body1", Some(&[([1., 0., 0.], 35.), ([-1., 0., 0.], 35.)]), 491),
|
||||
("issue-489-stacked-join", "Body1", Some(&[([0., 0., 1.], 30.)]), 492),
|
||||
("m0-revolve-primitives", "Body2", Some(&[([-FRAC_1_SQRT_2, 0., FRAC_1_SQRT_2], -21. * FRAC_1_SQRT_2)]), 493),
|
||||
("split-body-pieces", "Body1", Some(&[([0., FRAC_1_SQRT_2, FRAC_1_SQRT_2], 38.5 * FRAC_1_SQRT_2), ([0., -1., 0.], -18.)]), 493),
|
||||
("split-body-pieces", "Body1 (1)", Some(&[([0., 1., 0.], 12.)]), 493),
|
||||
("m0-revolve-primitives", "Body1", None, 494),
|
||||
("naming-split-pieces", "Body1", None, 494),
|
||||
("modern-plastic", "Body1", None, 494),
|
||||
("modern-sheet-metal", "Body1", None, 495),
|
||||
];
|
||||
use std::f64::consts::FRAC_1_SQRT_2;
|
||||
|
||||
/// The issue a face is listed under, and whether it is listed by its plane (not as every face).
|
||||
fn known_without_attachment(design: &str, body: &str, normal: [f64; 3], distance: f64) -> Option<(u32, bool)> {
|
||||
let same = |(n, d): &([f64; 3], f64)| (0..3).all(|i| (n[i] - normal[i]).abs() < 1e-6) && (d - distance).abs() < 1e-4;
|
||||
NO_ATTACHMENT_YET.iter().find_map(|(d, b, planes, issue)| {
|
||||
if *d != design || *b != body {
|
||||
return None;
|
||||
}
|
||||
match planes {
|
||||
None => Some((*issue, false)),
|
||||
Some(planes) => planes.iter().any(same).then_some((*issue, true)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn undo_to(s: &mut Session, features: usize) {
|
||||
for _ in 0..4 {
|
||||
if s.doc.features.len() <= features {
|
||||
return;
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc.features.len(), features, "undo did not remove the added feature");
|
||||
}
|
||||
|
||||
/// Try a sketch and an offset plane on every flat face of every final body of every corpus
|
||||
/// design, through the same face pick the desktop sends (body, face index, occurrence path, a
|
||||
/// point on it), without `fixed`. Undo leaves the design as it was for the next face. Returns
|
||||
/// (design, body, face, issue it is known under, what went wrong or None).
|
||||
fn flat_face_attachments() -> Vec<(String, String, usize, Option<(u32, bool)>, Option<String>)> {
|
||||
use solidcraft_engine::doc::{FaceAttachmentStatus, apply_point};
|
||||
let mut out = Vec::new();
|
||||
for p in corpus() {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let mut s = open(&p);
|
||||
let state = s.model.state();
|
||||
let placed = s.placed_document();
|
||||
let mut picks = Vec::new();
|
||||
for (world, definition, component, path, frame) in placed.body_frames_with_source(&state) {
|
||||
let Some(body) = state.bodies.iter().find(|b| b.definition_name() == definition && placed.model_body_component(b) == component) else {
|
||||
continue;
|
||||
};
|
||||
let mesh = body.body.tessellate(0.01).unwrap();
|
||||
for index in 0..body.body.face_count() {
|
||||
if body.body.planar_face(index).is_none() {
|
||||
continue;
|
||||
}
|
||||
// The middle of the face's largest triangle: well inside the face.
|
||||
let point = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, f)| **f as usize == index)
|
||||
.filter_map(|(t, _)| mesh.tri(t))
|
||||
.max_by(|[a, b, c], [d, e, f]| (*b - *a).cross(*c - *a).len().total_cmp(&(*e - *d).cross(*f - *d).len()))
|
||||
.map(|[a, b, c]| (a + b + c) * (1.0 / 3.0));
|
||||
let Some(point) = point else { continue };
|
||||
let point = apply_point(&frame, point);
|
||||
let normal =
|
||||
body.body.planar_face(index).map(|plane| solidcraft_engine::doc::apply_vector(&frame, plane.normal())).unwrap_or_default();
|
||||
let key = ([normal.x, normal.y, normal.z], normal.dot(point));
|
||||
picks.push((world.clone(), index, key, json!({"face": point, "body": world, "index": index, "path": path})));
|
||||
}
|
||||
}
|
||||
let before = s.doc.features.len();
|
||||
for (body, index, (normal, distance), plane) in picks {
|
||||
let mut problem = None;
|
||||
match s.execute("sketch.create", &json!({"plane": plane})) {
|
||||
Ok(created) => {
|
||||
let id = created["sketch"].as_u64().unwrap_or(0);
|
||||
match s.placed_document().sketch_face_attachment(id, &s.model) {
|
||||
FaceAttachmentStatus::Healthy { .. } | FaceAttachmentStatus::Sheet { .. } => {}
|
||||
other => problem = Some(format!("sketch attachment {other:?}")),
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
undo_to(&mut s, before);
|
||||
}
|
||||
Err(e) => problem = Some(format!("sketch.create: {e}")),
|
||||
}
|
||||
match s.execute("construct.plane.offset", &json!({"base": plane, "offset": 5})) {
|
||||
Ok(_) => undo_to(&mut s, before),
|
||||
Err(e) => problem = Some(problem.map_or(String::new(), |p| p + "; ") + &format!("construct.plane.offset: {e}")),
|
||||
}
|
||||
let known = known_without_attachment(&name, &body, normal, distance);
|
||||
out.push((name.clone(), body, index, known, problem));
|
||||
}
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{name}: left with errors after undo");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every flat face of every corpus design takes a durable attachment (issue #489: the top face
|
||||
/// of an extrude sketched over a face's projected boundary had "no exact planar source"),
|
||||
/// except the faces listed in [`NO_ATTACHMENT_YET`] under their open issues.
|
||||
#[test]
|
||||
fn every_flat_face_of_every_corpus_design_takes_an_attached_sketch() {
|
||||
let all = flat_face_attachments();
|
||||
assert!(all.len() > 50, "only {} faces checked", all.len());
|
||||
let problems: Vec<String> = all
|
||||
.iter()
|
||||
.filter_map(|(design, body, face, known, problem)| match (known, problem) {
|
||||
(None, Some(problem)) => Some(format!("{design}: {body} face {face}: {problem}")),
|
||||
(Some((issue, true)), None) => Some(format!("{design}: {body} face {face} attaches now: remove it from NO_ATTACHMENT_YET (#{issue})")),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(problems.is_empty(), "{} of {} faces:\n{}", problems.len(), all.len(), problems.join("\n"));
|
||||
}
|
||||
|
||||
fn known_faces_attach(issue: u32) {
|
||||
let problems: Vec<String> = flat_face_attachments()
|
||||
.into_iter()
|
||||
.filter(|(.., known, problem)| known.is_some_and(|(known, _)| known == issue) && problem.is_some())
|
||||
.map(|(design, body, face, _, problem)| format!("{design}: {body} face {face}: {}", problem.unwrap_or_default()))
|
||||
.collect();
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #491 is fixed"]
|
||||
fn a_side_face_join_merged_from_two_extrudes_takes_an_attached_sketch() {
|
||||
known_faces_attach(491);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #492 is fixed"]
|
||||
fn a_face_split_into_pieces_by_a_later_feature_takes_an_attached_sketch() {
|
||||
known_faces_attach(492);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #493 is fixed"]
|
||||
fn cut_walls_and_chamfer_faces_take_an_attached_sketch() {
|
||||
known_faces_attach(493);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #494 is fixed"]
|
||||
fn faces_after_revolve_offset_face_and_boss_take_an_attached_sketch() {
|
||||
known_faces_attach(494);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #495 is fixed"]
|
||||
fn sheet_metal_thickness_faces_take_an_attached_sketch() {
|
||||
known_faces_attach(495);
|
||||
}
|
||||
|
||||
/// A small deterministic generator (xorshift).
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
|
||||
@@ -4,6 +4,21 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>SolidCraft</title>
|
||||
<meta name="description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="SolidCraft">
|
||||
<meta property="og:title" content="SolidCraft: try it in your browser">
|
||||
<meta property="og:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
|
||||
<meta property="og:url" content="https://bherbruck.github.io/solidcraft/app/">
|
||||
<meta property="og:image" content="https://bherbruck.github.io/solidcraft/og.png">
|
||||
<meta property="og:image:width" content="1280">
|
||||
<meta property="og:image:height" content="640">
|
||||
<meta property="og:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="SolidCraft: try it in your browser">
|
||||
<meta name="twitter:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
|
||||
<meta name="twitter:image" content="https://bherbruck.github.io/solidcraft/og.png">
|
||||
<meta name="twitter:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
|
||||
<link data-trunk rel="rust" data-bin="solidcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="copy-file" href="fonts/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
|
||||
<link data-trunk rel="copy-file" href="fonts/OFL.txt" data-target-path="fonts" />
|
||||
|
||||
@@ -263,7 +263,8 @@ impl FeatureKind {
|
||||
dir(direction);
|
||||
}
|
||||
FeatureKind::Thread { face, .. } => pt(face),
|
||||
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SheetFlange { edges, .. } => edges.iter_mut().for_each(|e| pt(&mut e.point)),
|
||||
FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SheetConvert { face, .. } => pt(face),
|
||||
FeatureKind::SheetFold { a, b, fixed, .. } => {
|
||||
pt(a);
|
||||
|
||||
@@ -84,6 +84,10 @@ pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<St
|
||||
if let Some(old) = crate::naming::legacy_name(name) {
|
||||
return format!("{}{}", crate::naming::LEGACY_MARK, remap_topology(old, ids, bodies));
|
||||
}
|
||||
// A flat face of a sheet: `sheet:<body>:<panel>:<side>`, the panel a flange's feature token.
|
||||
if let Some((body, face)) = crate::sheet::split_sheet_face_name(name) {
|
||||
return crate::sheet::sheet_face_name(bodies.get(body).map_or(body, String::as_str), &remap_topology(face, ids, bodies));
|
||||
}
|
||||
// Imported names belong to the complete canonical body prefix, even when that prefix
|
||||
// happens to contain text resembling a feature token.
|
||||
if let Some((body, suffix)) = name.rsplit_once(":face:")
|
||||
@@ -464,6 +468,14 @@ mod clone_tests {
|
||||
assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label");
|
||||
}
|
||||
#[test]
|
||||
fn a_sheet_face_follows_its_body_and_its_flange() {
|
||||
let ids = BTreeMap::from([(1, 2), (10, 20)]);
|
||||
let bodies = BTreeMap::from([("Body1".into(), "Body1 (copy)".into())]);
|
||||
assert_eq!(remap_topology("sheet:Body1:F1.0:top", &ids, &bodies), "sheet:Body1 (copy):F2.0:top");
|
||||
assert_eq!(remap_topology("sheet:Body1:base:bottom", &ids, &bodies), "sheet:Body1 (copy):base:bottom");
|
||||
assert_eq!(remap_topology("sheet:Other:F10.1:top", &ids, &bodies), "sheet:Other:F20.1:top");
|
||||
}
|
||||
#[test]
|
||||
fn parameter_remap_is_simultaneous_and_token_scoped() {
|
||||
let map = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]);
|
||||
assert_eq!(clone_expression("d1 + d2 + d10 + 2 mm", &map), "d2 + d3 + d10 + 2 mm");
|
||||
|
||||
@@ -151,11 +151,34 @@ pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solidcraft_k
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum FaceCertificate {
|
||||
Box { axis: u8, high: bool },
|
||||
ExtrudeCap { sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>> },
|
||||
ExtrudeSide { sketch: u64, curve: String },
|
||||
Imported { payload_sha256: String, item: u64, face: u64 },
|
||||
ShellPlanar { source_producer: u64, source: Box<FaceCertificate>, inner: bool },
|
||||
Box {
|
||||
axis: u8,
|
||||
high: bool,
|
||||
},
|
||||
/// A cylinder primitive's cap: `high` is its `top` (the far end), else its `bottom`.
|
||||
CylinderCap {
|
||||
high: bool,
|
||||
},
|
||||
ExtrudeCap {
|
||||
sketch: u64,
|
||||
high: bool,
|
||||
outer: Vec<String>,
|
||||
holes: Vec<Vec<String>>,
|
||||
},
|
||||
ExtrudeSide {
|
||||
sketch: u64,
|
||||
curve: String,
|
||||
},
|
||||
Imported {
|
||||
payload_sha256: String,
|
||||
item: u64,
|
||||
face: u64,
|
||||
},
|
||||
ShellPlanar {
|
||||
source_producer: u64,
|
||||
source: Box<FaceCertificate>,
|
||||
inner: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl FaceCertificate {
|
||||
@@ -177,6 +200,7 @@ impl FaceCertificate {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let valid = match self {
|
||||
Self::Box { axis, .. } => *axis <= 2,
|
||||
Self::CylinderCap { .. } => true,
|
||||
Self::ExtrudeCap { sketch, outer, holes, .. } => *sketch != 0 && valid_signature(outer, holes),
|
||||
Self::ExtrudeSide { sketch, curve } => *sketch != 0 && !curve.is_empty() && curve.len() <= 128,
|
||||
Self::Imported { payload_sha256, item, face } => {
|
||||
@@ -206,6 +230,11 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()>
|
||||
&& fields.get("axis").and_then(serde_json::Value::as_u64).is_some_and(|axis| axis <= 2)
|
||||
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
|
||||
}
|
||||
Some("cylinder_cap") => {
|
||||
fields.len() == 2
|
||||
&& fields.keys().all(|key| ["kind", "high"].contains(&key.as_str()))
|
||||
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
|
||||
}
|
||||
Some("extrude_cap") => {
|
||||
fields.len() == 5
|
||||
&& fields.keys().all(|key| ["kind", "sketch", "high", "outer", "holes"].contains(&key.as_str()))
|
||||
@@ -252,6 +281,7 @@ pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> R
|
||||
let axis = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid shell source axis".into()))?;
|
||||
format!("box:{}{axis}", if *high { "+" } else { "-" })
|
||||
}
|
||||
FaceCertificate::CylinderCap { high } => format!("cylinder:{}", cylinder_cap_role(*high)),
|
||||
FaceCertificate::ExtrudeSide { curve, .. } => format!("side:{curve}"),
|
||||
FaceCertificate::ExtrudeCap { high, outer, holes, .. } => format!("cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes)),
|
||||
FaceCertificate::Imported { payload_sha256, item, face } => format!("import:{payload_sha256}:{item}:{face}"),
|
||||
@@ -260,10 +290,16 @@ pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> R
|
||||
Ok(format!("shell:{}:{role}", if inner { "inner" } else { "outer" }))
|
||||
}
|
||||
|
||||
/// A cylinder cap's role, as the cylinder's face names call it.
|
||||
pub(crate) fn cylinder_cap_role(high: bool) -> &'static str {
|
||||
if high { "top" } else { "bottom" }
|
||||
}
|
||||
|
||||
pub(crate) fn flat_source_certificate(source: &solidcraft_kernel::FaceSource) -> Option<(u64, FaceCertificate)> {
|
||||
use solidcraft_kernel::FaceSource;
|
||||
match source {
|
||||
FaceSource::Box { producer: Some(producer), axis, high, .. } => Some((*producer, FaceCertificate::Box { axis: *axis, high: *high })),
|
||||
FaceSource::CylinderCap { producer, high, .. } => Some((*producer, FaceCertificate::CylinderCap { high: *high })),
|
||||
FaceSource::CertifiedExtrudeSide { producer, sketch, curve, .. } => {
|
||||
Some((*producer, FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() }))
|
||||
}
|
||||
@@ -283,6 +319,9 @@ pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solidc
|
||||
(FaceCertificate::Box { axis, high }, FaceSource::Box { producer: Some(current), axis: current_axis, high: current_high, .. }) => {
|
||||
producer == *current && axis == current_axis && high == current_high
|
||||
}
|
||||
(FaceCertificate::CylinderCap { high }, FaceSource::CylinderCap { producer: current, high: current_high, .. }) => {
|
||||
producer == *current && high == current_high
|
||||
}
|
||||
(
|
||||
FaceCertificate::ExtrudeSide { sketch, curve },
|
||||
FaceSource::CertifiedExtrudeSide { producer: current, sketch: current_sketch, curve: current_curve, .. },
|
||||
@@ -309,7 +348,9 @@ pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solidc
|
||||
/// Match a generated segment to one exact analytic sketch curve. Coincident
|
||||
/// candidates are ambiguous and never resolved by distance or candidate order.
|
||||
pub(crate) struct ExactSketchCurves<'a> {
|
||||
segments: Vec<(&'a str, Vec<Seg2>)>,
|
||||
/// Each analytic curve's id, whether it is projected (linked) reference geometry, and its
|
||||
/// segments.
|
||||
segments: Vec<(&'a str, bool, Vec<Seg2>)>,
|
||||
}
|
||||
|
||||
impl<'a> ExactSketchCurves<'a> {
|
||||
@@ -340,22 +381,33 @@ impl<'a> ExactSketchCurves<'a> {
|
||||
} else {
|
||||
sketch.sketch.segs(index)
|
||||
};
|
||||
segments.push((curve.id.as_str(), originals));
|
||||
segments.push((curve.id.as_str(), curve.link.is_some(), originals));
|
||||
}
|
||||
Self { segments }
|
||||
}
|
||||
|
||||
/// The one sketch curve a generated segment lies on. A curve drawn over projected
|
||||
/// reference geometry (a profile traced over a face's live boundary) is that segment's
|
||||
/// source: the projection only marks where it was drawn. Two drawn curves, or two
|
||||
/// projections, over the same segment stay ambiguous.
|
||||
pub(crate) fn find(&self, generated: &Seg2) -> Option<String> {
|
||||
let mut found = None;
|
||||
for (id, originals) in &self.segments {
|
||||
let mut drawn = None;
|
||||
let mut projected = None;
|
||||
for (id, linked, originals) in &self.segments {
|
||||
if originals.iter().any(|original| exact_segment_contains(original, generated)) {
|
||||
if found.is_some() {
|
||||
return None;
|
||||
let slot = if *linked { &mut projected } else { &mut drawn };
|
||||
if slot.is_some() {
|
||||
*slot = Some(None);
|
||||
} else {
|
||||
*slot = Some(Some(*id));
|
||||
}
|
||||
found = Some(*id);
|
||||
}
|
||||
}
|
||||
found.map(str::to_owned)
|
||||
match (drawn, projected) {
|
||||
(Some(id), _) => id.map(str::to_owned),
|
||||
(None, Some(id)) => id.map(str::to_owned),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,7 +463,8 @@ impl Document {
|
||||
| Some(solidcraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. })
|
||||
| Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. })
|
||||
| Some(solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), .. })
|
||||
| Some(solidcraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer,
|
||||
| Some(solidcraft_kernel::FaceSource::Box { producer: Some(producer), .. })
|
||||
| Some(solidcraft_kernel::FaceSource::CylinderCap { producer, .. }) => *producer,
|
||||
_ => name
|
||||
.strip_prefix('F')
|
||||
.and_then(|name| name.split_once(':'))
|
||||
@@ -481,6 +534,11 @@ impl Document {
|
||||
target.name = format!("F{producer}:{}{axis_name}", if *high { "+" } else { "-" });
|
||||
target.certificate = Some(FaceCertificate::Box { axis: *axis, high: *high });
|
||||
}
|
||||
Some(solidcraft_kernel::FaceSource::CylinderCap { producer, high, .. }) => {
|
||||
target.origin_feature = *producer;
|
||||
target.name = format!("F{producer}:{}", cylinder_cap_role(*high));
|
||||
target.certificate = Some(FaceCertificate::CylinderCap { high: *high });
|
||||
}
|
||||
Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. }) => {
|
||||
target.origin_feature = *producer;
|
||||
target.name = format!("F{producer}:cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes));
|
||||
@@ -512,6 +570,31 @@ mod tests {
|
||||
SolvedSketch { feature: 1, name: "sources".into(), plane: solidcraft_geom::Plane::XY, sketch, report, profiles: Vec::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_cap_certificate_json_is_exact() {
|
||||
for high in [true, false] {
|
||||
let valid = json!({"kind":"cylinder_cap","high":high});
|
||||
assert!(validate_certificate_json(&valid).is_ok());
|
||||
let certificate: FaceCertificate = serde_json::from_value(valid.clone()).unwrap();
|
||||
assert_eq!(certificate, FaceCertificate::CylinderCap { high });
|
||||
assert_eq!(serde_json::to_value(&certificate).unwrap(), valid);
|
||||
}
|
||||
let shelled = json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"cylinder_cap","high":false},"inner":true});
|
||||
assert!(validate_certificate_json(&shelled).is_ok());
|
||||
let certificate: FaceCertificate = serde_json::from_value(shelled).unwrap();
|
||||
assert_eq!(shell_certificate_role(&FaceCertificate::CylinderCap { high: false }, true).unwrap(), "shell:inner:cylinder:bottom");
|
||||
assert!(certificate.validate().is_ok());
|
||||
for bad in [
|
||||
json!({"kind":"cylinder_cap"}),
|
||||
json!({"kind":"cylinder_cap","high":1}),
|
||||
json!({"kind":"cylinder_cap","high":null}),
|
||||
json!({"kind":"cylinder_cap","high":true,"axis":2}),
|
||||
json!({"kind":"cylinder_cap","high":true,"extra":[[[]]]}),
|
||||
] {
|
||||
assert!(validate_certificate_json(&bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_sources_keep_exact_identity_and_reject_ambiguity() {
|
||||
use solidcraft_geom::Vec2;
|
||||
@@ -537,6 +620,29 @@ mod tests {
|
||||
assert_eq!(ExactSketchCurves::new(&moved).find(&partial), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_drawn_over_a_projected_boundary_is_the_source() {
|
||||
use solidcraft_geom::Vec2;
|
||||
// A face sketch's live boundary, with a profile edge traced over it (issue #489).
|
||||
let mut sketch = solidcraft_sketch::Sketch::new();
|
||||
let projected = sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("edge")).unwrap();
|
||||
sketch.curves[projected].link = Some("boundary".into());
|
||||
let sketch = solved(sketch);
|
||||
let edge = Seg2::Line { a: Vec2::new(10., 0.), b: Vec2::ZERO };
|
||||
assert_eq!(ExactSketchCurves::new(&sketch).find(&edge).as_deref(), Some("edge"), "a lone projection is its own source");
|
||||
let mut traced = sketch.clone();
|
||||
traced.sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("drawn")).unwrap();
|
||||
assert_eq!(ExactSketchCurves::new(&traced).find(&edge).as_deref(), Some("drawn"));
|
||||
// Two drawn curves over it, or two projections, stay ambiguous.
|
||||
let mut twice = traced.clone();
|
||||
twice.sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("again")).unwrap();
|
||||
assert_eq!(ExactSketchCurves::new(&twice).find(&edge), None);
|
||||
let mut projections = sketch.clone();
|
||||
let other = projections.sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("other")).unwrap();
|
||||
projections.sketch.curves[other].link = Some("edge link".into());
|
||||
assert_eq!(ExactSketchCurves::new(&projections).find(&edge), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual optimized certification microbenchmark; no timing assertion"]
|
||||
fn benchmark_prepared_extrude_sources() {
|
||||
|
||||
@@ -529,7 +529,15 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
|
||||
let stem = crate::naming::strip_piece(&former);
|
||||
let current_names = crate::naming::face_names(current);
|
||||
let merged = current_names.iter().position(|name| name == stem).unwrap();
|
||||
assert!(current.body.face_was_coalesced(merged), "new exact generated-wall construction supplies the loss-only witness");
|
||||
// The Join leaves Case's -x wall on its own support, so the wall keeps its exact box
|
||||
// identity through the Join and the Cut: one face again, no longer the saved piece.
|
||||
assert!(
|
||||
matches!(
|
||||
current.body.face_sources().and_then(|sources| sources.get(merged)).cloned().flatten(),
|
||||
Some(solidcraft_kernel::FaceSource::Box { axis: 0, high: false, .. })
|
||||
),
|
||||
"the merged wall carries its exact construction identity"
|
||||
);
|
||||
assert!(!current_names.contains(&former));
|
||||
let mut sketch = Sketch::new();
|
||||
sketch.add_link(LinkKind::Project, LinkSource::Face { body: "Case".into(), at }, &resolved.geom).unwrap();
|
||||
|
||||
@@ -108,9 +108,10 @@ impl Document {
|
||||
use super::FaceCertificate;
|
||||
let Some(feature) = self.feature(producer) else { return false };
|
||||
match (certificate, &feature.kind) {
|
||||
(FaceCertificate::Box { .. }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => true,
|
||||
(FaceCertificate::ExtrudeCap { sketch, .. }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => sketch == current && extent.taper.is_none(),
|
||||
(FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => {
|
||||
(FaceCertificate::Box { .. }, FeatureKind::Box { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => true,
|
||||
(FaceCertificate::CylinderCap { .. }, FeatureKind::Cylinder { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => true,
|
||||
(FaceCertificate::ExtrudeCap { sketch, .. }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => sketch == current && extent.taper.is_none(),
|
||||
(FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
|
||||
sketch == current && extent.taper.is_none()
|
||||
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Circle { .. } | solidcraft_sketch::CurveKind::Arc { .. }))))
|
||||
}
|
||||
@@ -129,14 +130,17 @@ impl Document {
|
||||
let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?;
|
||||
if let Some(certificate) = &target.certificate {
|
||||
let valid = match (certificate, &feature.kind) {
|
||||
(super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => {
|
||||
(super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
|
||||
["x", "y", "z"].get(*axis as usize).is_some_and(|axis| role == format!("{}{axis}", if *high { "+" } else { "-" }))
|
||||
}
|
||||
(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
|
||||
(super::FaceCertificate::CylinderCap { high }, FeatureKind::Cylinder { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
|
||||
role == super::cylinder_cap_role(*high)
|
||||
}
|
||||
(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
|
||||
sketch == producer_sketch && extent.taper.is_none()
|
||||
&& role == format!("cap:{}:{}", if *high { "high" } else { "low" }, super::cap_key(outer, holes))
|
||||
}
|
||||
(super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
|
||||
(super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
|
||||
sketch == producer_sketch && extent.taper.is_none() && role == format!("side:{curve}")
|
||||
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Circle { .. } | solidcraft_sketch::CurveKind::Arc { .. }))))
|
||||
}
|
||||
@@ -192,7 +196,10 @@ impl Document {
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) {
|
||||
if matches!(
|
||||
target.certificate,
|
||||
Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::CylinderCap { .. } | super::FaceCertificate::ExtrudeCap { .. })
|
||||
) {
|
||||
let indices: Vec<_> = body
|
||||
.body
|
||||
.face_sources()
|
||||
@@ -205,6 +212,10 @@ impl Document {
|
||||
Some(super::FaceCertificate::Box { axis, high }),
|
||||
Some(solidcraft_kernel::FaceSource::Box { producer: Some(producer), axis: current_axis, high: current_high, .. }),
|
||||
) => *producer == target.origin_feature && axis == current_axis && high == current_high,
|
||||
(
|
||||
Some(super::FaceCertificate::CylinderCap { high }),
|
||||
Some(solidcraft_kernel::FaceSource::CylinderCap { producer, high: current_high, .. }),
|
||||
) => *producer == target.origin_feature && high == current_high,
|
||||
(
|
||||
Some(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }),
|
||||
Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap {
|
||||
|
||||
@@ -269,6 +269,73 @@ impl EmbossFace {
|
||||
}
|
||||
}
|
||||
|
||||
/// One edge of an edge flange feature: a point on the sheet's edge and, where given, this
|
||||
/// edge's own angle and height (expressions) in place of the feature's. In a design file an
|
||||
/// edge with neither is the point `[x, y, z]`, as every edge was before; one with its own
|
||||
/// values is `{"point": [x, y, z], "angle": "…", "height": "…"}`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FlangeEdge {
|
||||
pub point: Vec3,
|
||||
pub angle: Option<String>,
|
||||
pub height: Option<String>,
|
||||
}
|
||||
|
||||
impl FlangeEdge {
|
||||
/// An edge that takes the feature's angle and height.
|
||||
pub fn at(point: Vec3) -> Self {
|
||||
Self { point, angle: None, height: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for FlangeEdge {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeMap;
|
||||
if self.angle.is_none() && self.height.is_none() {
|
||||
return self.point.serialize(s);
|
||||
}
|
||||
let mut m = s.serialize_map(None)?;
|
||||
m.serialize_entry("point", &self.point)?;
|
||||
if let Some(a) = &self.angle {
|
||||
m.serialize_entry("angle", a)?;
|
||||
}
|
||||
if let Some(h) = &self.height {
|
||||
m.serialize_entry("height", h)?;
|
||||
}
|
||||
m.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FlangeEdge {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
use serde::de::value::{MapAccessDeserializer, SeqAccessDeserializer};
|
||||
/// The object form; a key this build doesn't know is refused, not dropped.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Own {
|
||||
point: Vec3,
|
||||
#[serde(default)]
|
||||
angle: Option<String>,
|
||||
#[serde(default)]
|
||||
height: Option<String>,
|
||||
}
|
||||
struct Edge;
|
||||
impl<'de> serde::de::Visitor<'de> for Edge {
|
||||
type Value = FlangeEdge;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a point [x, y, z] or an edge {point, angle?, height?}")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, seq: A) -> std::result::Result<FlangeEdge, A::Error> {
|
||||
Vec3::deserialize(SeqAccessDeserializer::new(seq)).map(FlangeEdge::at)
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> std::result::Result<FlangeEdge, A::Error> {
|
||||
let o = Own::deserialize(MapAccessDeserializer::new(map))?;
|
||||
Ok(FlangeEdge { point: o.point, angle: o.angle, height: o.height })
|
||||
}
|
||||
}
|
||||
d.deserialize_any(Edge)
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature definitions.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -659,9 +726,10 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
reverse: bool,
|
||||
},
|
||||
/// Edge flanges on sheet edges (points on the top or bottom edges).
|
||||
/// Edge flanges on sheet edges (points on the top or bottom edges). `height` and `angle`
|
||||
/// hold for every edge that doesn't carry its own (see [`FlangeEdge`]).
|
||||
SheetFlange {
|
||||
edges: Vec<Vec3>,
|
||||
edges: Vec<FlangeEdge>,
|
||||
height: String,
|
||||
angle: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -1521,9 +1589,12 @@ impl FeatureKind {
|
||||
v.extend([length, draft, thickness].into_iter().flatten().map(String::as_str));
|
||||
}
|
||||
FeatureKind::SheetContour { distance, .. } => v.push(distance),
|
||||
FeatureKind::SheetFlange { height, angle, radius, .. } => {
|
||||
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
|
||||
v.extend([height.as_str(), angle]);
|
||||
v.extend(radius.iter().map(String::as_str));
|
||||
for e in edges {
|
||||
v.extend([&e.angle, &e.height].into_iter().flatten().map(String::as_str));
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetFold { angle, radius, .. } => {
|
||||
v.push(angle);
|
||||
|
||||
+54
-3
@@ -2108,7 +2108,9 @@ fn feature_tools_with_constructor(
|
||||
if untrimmed {
|
||||
*constructor = Some(ExtrudeConstructor { plane: ss.plane, interval: (lo + off, hi + off) });
|
||||
}
|
||||
if *operation == Operation::NewBody && extent.taper.is_none() {
|
||||
// A Join tool's faces outlive the boolean where they stay on the result, so they are
|
||||
// certified as a new body's are (see `Body::with_preserved_union_planar_sources`).
|
||||
if matches!(operation, Operation::NewBody | Operation::Join) && extent.taper.is_none() {
|
||||
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
|
||||
let cap_bindings = crate::config::extrude_cap_bindings(&ss, &bodies, 10_000_000);
|
||||
let source_curves = cap_bindings.as_ref().map(|_| crate::config::ExactSketchCurves::new(&ss));
|
||||
@@ -2127,8 +2129,20 @@ fn feature_tools_with_constructor(
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let caps =
|
||||
let mut caps =
|
||||
cap_bindings.as_ref().and_then(|bindings| bindings.get(body_index)).cloned().unwrap_or_else(|| vec![None; curves.len()]);
|
||||
// A cap moved off its designed height (into a Join target, or past a
|
||||
// curved To face) is not the feature's start or end face.
|
||||
let nominal = |plane: &solidcraft_geom::Plane| {
|
||||
let height = (plane.origin - ss.plane.origin).dot(n);
|
||||
let tolerance = 1e-9 * (1.0 + height.abs() + (lo + off).abs() + (hi + off).abs());
|
||||
(height - (lo + off)).abs() <= tolerance || (height - (hi + off)).abs() <= tolerance
|
||||
};
|
||||
for (cap, source) in caps.iter_mut().zip(body.face_sources().into_iter().flatten()) {
|
||||
if matches!(source, Some(kernel::FaceSource::ExtrudeCap { plane, .. }) if !nominal(plane)) {
|
||||
*cap = None;
|
||||
}
|
||||
}
|
||||
// Certificates set face sources only: the faces, and their lineage, stay.
|
||||
let body = body.with_extrude_certificates(f.id, *sketch, &curves)?.with_extrude_cap_certificates(f.id, *sketch, &caps)?;
|
||||
Ok(Traced { body, lineage })
|
||||
@@ -3060,6 +3074,38 @@ fn smooth_chain(b: &ModelBody, picks: &[Vec3]) -> Vec<Vec3> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `plane` with the sheet face it names (see `sheet::plane_sheet_face`) replaced by that face's current
|
||||
/// plane in the frame of component `comp`, or an error when the face is gone: a lost
|
||||
/// attachment is reported, never moved to the nearest face.
|
||||
pub(crate) fn sheet_face_plane_in_frame(doc: &Document, st: &ModelState, comp: u64, plane: &PlaneRef) -> Result<PlaneRef> {
|
||||
Ok(match plane {
|
||||
PlaneRef::Face { name: Some(name), anchor: None, .. } if crate::sheet::plane_sheet_face(plane).is_some() => {
|
||||
let lost = || DocError::Invalid(format!("lost face attachment: the sheet face `{name}` no longer exists; reattach the sketch to a face"));
|
||||
let (body, face) = crate::sheet::sheet_face_plane(st, name).ok_or_else(lost)?;
|
||||
if doc.occurrences.is_empty() && doc.body_offsets.is_empty() {
|
||||
return Ok(PlaneRef::Custom { plane: face });
|
||||
}
|
||||
// The body's own coordinates into the component's frame, as `state_in_frame` places its bodies.
|
||||
let source = st.body(&body).ok_or_else(lost)?;
|
||||
let inverse = crate::mat_inverse(&doc.component_transform(comp)).ok_or_else(lost)?;
|
||||
let m = crate::mat_mul(&inverse, &doc.component_transform(doc.model_body_component(source)));
|
||||
let m = doc.model_body_offset(source).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m);
|
||||
let placed = Plane::new(crate::apply_point(&m, face.origin), crate::apply_vector(&m, face.x), crate::apply_vector(&m, face.y));
|
||||
PlaneRef::Custom { plane: placed.ok_or_else(lost)? }
|
||||
}
|
||||
PlaneRef::Offset { base, distance } => {
|
||||
PlaneRef::Offset { base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?), distance: distance.clone() }
|
||||
}
|
||||
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => PlaneRef::AtAngle {
|
||||
base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?),
|
||||
axis_origin: *axis_origin,
|
||||
axis_dir: *axis_dir,
|
||||
angle: angle.clone(),
|
||||
},
|
||||
other => other.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The plane of the body face a sketch sits on, found again: among planar faces with the
|
||||
/// picked plane's normal, the one nearest `at`. The picked frame moves along its normal onto it.
|
||||
fn face_plane(st: &ModelState, picked: &Plane, at: Vec3, name: Option<&str>) -> (Plane, Option<String>) {
|
||||
@@ -3191,6 +3237,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
};
|
||||
let (plane, face_warning) = match (anchor, plane) {
|
||||
(Some(_), plane) => (doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?, None),
|
||||
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
|
||||
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
|
||||
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
|
||||
}
|
||||
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref()),
|
||||
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
|
||||
};
|
||||
@@ -3624,11 +3674,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
|
||||
let corner = point(vals, corner)?;
|
||||
let b = kernel::box_solid(corner, corner + s)?;
|
||||
let b = if *operation == Operation::NewBody { b.with_box_certificate(f.id)? } else { b };
|
||||
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b };
|
||||
apply_traced(st, f, vec![crate::lineage::box_tool(b)], *operation, &[])
|
||||
}
|
||||
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
|
||||
let b = kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
|
||||
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_cylinder_certificate(f.id)? } else { b };
|
||||
apply_traced(st, f, vec![crate::lineage::cylinder_tool(b)], *operation, &[])
|
||||
}
|
||||
FeatureKind::Sphere { center, radius, operation } => {
|
||||
|
||||
@@ -415,8 +415,21 @@ pub(super) fn sheet_eval(
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body } => {
|
||||
let i = sheet_for(st, body, edges.first().copied())?;
|
||||
let i = sheet_for(st, body, edges.first().map(|e| e.point))?;
|
||||
let (h, a) = (val(vals, height, Kind::Length)?, val(vals, angle, Kind::Angle)?);
|
||||
// Each edge's own height and angle, else the feature's.
|
||||
let mut own = Vec::with_capacity(edges.len());
|
||||
for e in edges {
|
||||
let height = match &e.height {
|
||||
Some(x) => val(vals, x, Kind::Length)?,
|
||||
None => h,
|
||||
};
|
||||
let angle = match &e.angle {
|
||||
Some(x) => val(vals, x, Kind::Angle)?,
|
||||
None => a,
|
||||
};
|
||||
own.push((height, angle));
|
||||
}
|
||||
let rr = match radius {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => {
|
||||
@@ -428,9 +441,14 @@ pub(super) fn sheet_eval(
|
||||
if sh.flat {
|
||||
return Err(DocError::Invalid("refold the sheet before adding flanges".into()));
|
||||
}
|
||||
let picks = sheet_picks(sh, f, edges, warning);
|
||||
let points: Vec<Vec3> = edges.iter().map(|e| e.point).collect();
|
||||
let each: Vec<sheet::EdgeFlange> = sheet_picks(sh, f, &points, warning)
|
||||
.into_iter()
|
||||
.zip(own)
|
||||
.map(|(pick, (height, angle))| sheet::EdgeFlange { pick, height, angle })
|
||||
.collect();
|
||||
let n0 = sh.flanges.len();
|
||||
let (_, notes) = sh.add_flanges_noted(&picks, h, a, rr, position, *flip)?;
|
||||
let (_, notes) = sh.add_flanges_each(&each, rr, position, *flip)?;
|
||||
if !notes.is_empty() {
|
||||
let notes = notes.join("; ");
|
||||
*warning = Some(match warning.take() {
|
||||
|
||||
@@ -117,11 +117,13 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// All status planes are world-space. A lost plane is a repair guide only.
|
||||
/// All status planes are world-space. A lost plane is a repair guide only. `Sheet` is a sketch on
|
||||
/// a flat face of a sheet metal body, attached by the name of its panel (`sheet::sheet_face_name`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FaceAttachmentStatus {
|
||||
Fixed { plane: Option<Plane> },
|
||||
Legacy { plane: Option<Plane> },
|
||||
Sheet { plane: Option<Plane> },
|
||||
Healthy { plane: Plane, target: ConfigFaceTarget, path: Vec<u64> },
|
||||
Lost { message: String, repair_plane: Option<Plane> },
|
||||
}
|
||||
@@ -210,6 +212,13 @@ impl Document {
|
||||
self.resolve_plane(&vals, plane, 0).ok()
|
||||
});
|
||||
let current = world.and_then(|world| current.and_then(|plane| map_plane(&world, &plane).ok()));
|
||||
if crate::sheet::plane_sheet_face(plane).is_some() {
|
||||
let state = model.state_before(feature.id);
|
||||
return match crate::eval::sheet_face_plane_in_frame(self, &state, feature.component, plane) {
|
||||
Ok(_) => FaceAttachmentStatus::Sheet { plane: current },
|
||||
Err(error) => FaceAttachmentStatus::Lost { message: error.to_string(), repair_plane: current },
|
||||
};
|
||||
}
|
||||
return if has_face_reference(plane) {
|
||||
FaceAttachmentStatus::Legacy { plane: current }
|
||||
} else {
|
||||
|
||||
@@ -46,13 +46,18 @@
|
||||
//! legacy mark (`?F2:top#2`) and are found again as the build that saved them found them.
|
||||
//! Pieces a feature splits a body into are named after their faces; features saved before
|
||||
//! keep the kernel's shell order (`shell_piece_order`), which their stored body names mean.
|
||||
//! - 15: sketch face anchors and face bindings can name a cylinder primitive's cap
|
||||
//! (`cylinder_cap`), sketches can link lines to their own axes (`sketch_axis`), and sketch
|
||||
//! anchors can name a sheet metal panel side (`sheet:<body>:<panel>:<side>`). Version 14
|
||||
//! designs need no transformation; the version stamp lets older readers request an update
|
||||
//! instead of encountering an unknown certificate or link kind.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 14;
|
||||
pub const FORMAT: u32 = 15;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solidcraft/{n}")
|
||||
@@ -69,7 +74,7 @@ pub fn version_of(s: &str) -> Option<u32> {
|
||||
|
||||
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
||||
type JsonStep = fn(&mut Value) -> Result<()>;
|
||||
const JSON_STEPS: [JsonStep; 13] = [
|
||||
const JSON_STEPS: [JsonStep; 14] = [
|
||||
|_| Ok(()),
|
||||
v2_to_v3,
|
||||
v3_to_v4,
|
||||
@@ -83,6 +88,7 @@ const JSON_STEPS: [JsonStep; 13] = [
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
v13_to_v14,
|
||||
|_| Ok(()),
|
||||
];
|
||||
|
||||
/// Stored face and edge names were worked out from geometry before format 14; they get the
|
||||
|
||||
@@ -223,12 +223,21 @@ impl FeatureKind {
|
||||
v.push(("Catch length", catch_length, L));
|
||||
}
|
||||
FeatureKind::SheetContour { distance, .. } => v.push(("Distance", distance, L)),
|
||||
FeatureKind::SheetFlange { height, angle, radius, .. } => {
|
||||
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
|
||||
v.push(("Height", height, L));
|
||||
v.push(("Angle", angle, A));
|
||||
if let Some(r) = radius {
|
||||
v.push(("Bend radius", r, L));
|
||||
}
|
||||
// The edges' own values come last, so the inputs above keep their names.
|
||||
for e in edges {
|
||||
if let Some(a) = &mut e.angle {
|
||||
v.push(("Edge angle", a, A));
|
||||
}
|
||||
if let Some(h) = &mut e.height {
|
||||
v.push(("Edge height", h, L));
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetFold { angle, radius, .. } => {
|
||||
v.push(("Angle", angle, A));
|
||||
|
||||
@@ -259,6 +259,9 @@ fn resolve_uncached(
|
||||
_ => vec![LinkGeom::Point(o)],
|
||||
}
|
||||
}
|
||||
LinkSource::SketchAxis { name } => {
|
||||
vec![solidcraft_sketch::sketch_axis_geom(name).ok_or_else(|| DocError::Unknown(format!("sketch axis `{name}`")))?]
|
||||
}
|
||||
LinkSource::Text { text, at, height, angle } => solidcraft_sketch::text_geometry(text, *at, *height, *angle)?,
|
||||
LinkSource::Plane { name } => {
|
||||
let pr =
|
||||
@@ -321,6 +324,7 @@ fn certified_planar_construction(body: &crate::ModelBody, index: usize) -> bool
|
||||
Some(
|
||||
solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(_), .. }
|
||||
| solidcraft_kernel::FaceSource::Box { producer: Some(_), .. }
|
||||
| solidcraft_kernel::FaceSource::CylinderCap { .. }
|
||||
| solidcraft_kernel::FaceSource::CertifiedExtrudeSide { plane: Some(_), .. }
|
||||
| solidcraft_kernel::FaceSource::CertifiedExtrudeCap { .. }
|
||||
)
|
||||
|
||||
+496
-20
@@ -298,6 +298,15 @@ pub struct SheetFlange {
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
/// One edge flange to make: a pick on the sheet's edge, and that flange's height (to the outer
|
||||
/// face, from the virtual sharp) and angle (radians).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct EdgeFlange {
|
||||
pub pick: Vec3,
|
||||
pub height: f64,
|
||||
pub angle: f64,
|
||||
}
|
||||
|
||||
/// A sheet metal body (see the module docs).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct SheetBody {
|
||||
@@ -1368,38 +1377,54 @@ impl SheetBody {
|
||||
position: &str,
|
||||
flip: bool,
|
||||
) -> Result<(Vec<usize>, Vec<String>)> {
|
||||
if !(angle > 1e-6 && angle <= std::f64::consts::PI - 1e-6) {
|
||||
return Err(DocError::Invalid("the flange angle must be between 0 and 180°".into()));
|
||||
}
|
||||
if picks.is_empty() || picks.len() > 1000 {
|
||||
let each: Vec<EdgeFlange> = picks.iter().map(|&pick| EdgeFlange { pick, height, angle }).collect();
|
||||
self.add_flanges_each(&each, radius, position, flip)
|
||||
}
|
||||
|
||||
/// Edge flanges that each have their own height and angle, in one go: every edge is trimmed
|
||||
/// back by its own setback first, then the flanges are added and the corners between them
|
||||
/// are closed. The closing works from the two flanges it joins (each one's own fold and
|
||||
/// leg), so unequal flanges close like equal ones.
|
||||
pub fn add_flanges_each(&mut self, edges: &[EdgeFlange], radius: f64, position: &str, flip: bool) -> Result<(Vec<usize>, Vec<String>)> {
|
||||
if edges.is_empty() || edges.len() > 1000 {
|
||||
return Err(DocError::Invalid("pick 1…1000 edges".into()));
|
||||
}
|
||||
let sharp = (radius + self.t) * (angle / 2.0).tan();
|
||||
let setback = match position {
|
||||
"inside" | "" => sharp,
|
||||
"outside" => 0.0,
|
||||
"middle" | "mid" => sharp / 2.0,
|
||||
other => return Err(DocError::Invalid(format!("unknown bend position `{other}` (inside, outside or middle)"))),
|
||||
};
|
||||
let leg = height - sharp;
|
||||
if leg < -1e-9 {
|
||||
return Err(DocError::Invalid(format!("the flange height must be at least {sharp:.3} mm for this bend")));
|
||||
// With several edges a message says which one it is about.
|
||||
let on = |k: usize| if edges.len() > 1 { format!("edge {}: ", k + 1) } else { String::new() };
|
||||
// Each edge's setback and leg (the panel past the bend) follow its own angle and height.
|
||||
let mut sizes = Vec::with_capacity(edges.len());
|
||||
for (k, e) in edges.iter().enumerate() {
|
||||
if !(e.angle > 1e-6 && e.angle <= std::f64::consts::PI - 1e-6) {
|
||||
return Err(DocError::Invalid(format!("{}the flange angle must be between 0 and 180°", on(k))));
|
||||
}
|
||||
let sharp = (radius + self.t) * (e.angle / 2.0).tan();
|
||||
let setback = match position {
|
||||
"inside" | "" => sharp,
|
||||
"outside" => 0.0,
|
||||
"middle" | "mid" => sharp / 2.0,
|
||||
other => return Err(DocError::Invalid(format!("unknown bend position `{other}` (inside, outside or middle)"))),
|
||||
};
|
||||
let leg = e.height - sharp;
|
||||
if !(leg >= -1e-9) {
|
||||
return Err(DocError::Invalid(format!("{}the flange height must be at least {sharp:.3} mm for this bend", on(k))));
|
||||
}
|
||||
sizes.push((setback, leg));
|
||||
}
|
||||
// Find every edge before anything moves.
|
||||
let mut found = Vec::new();
|
||||
for p in picks {
|
||||
let (panel, a, b, top, dist) = self.edge_at(*p).ok_or_else(|| DocError::Invalid("no sheet edge there".into()))?;
|
||||
for (e, &(setback, leg)) in edges.iter().zip(&sizes) {
|
||||
let (panel, a, b, top, dist) = self.edge_at(e.pick).ok_or_else(|| DocError::Invalid("no sheet edge there".into()))?;
|
||||
if dist > self.t * 2.0 + 1e-3 {
|
||||
return Err(DocError::Invalid("pick an edge of the sheet's top or bottom face".into()));
|
||||
}
|
||||
let d = (b - a).normalized().ok_or_else(|| DocError::Invalid("edge".into()))?;
|
||||
found.push((panel, a, b, d, top));
|
||||
found.push((panel, a, b, d, top, setback, leg, e.angle));
|
||||
}
|
||||
for (panel, a, b, _, _) in &found {
|
||||
self.trim(*panel, *a, *b, setback)?;
|
||||
for (panel, a, b, _, _, setback, _, _) in &found {
|
||||
self.trim(*panel, *a, *b, *setback)?;
|
||||
}
|
||||
let mut made = Vec::new();
|
||||
for (panel, a, b, d, top) in found {
|
||||
for (panel, a, b, d, top, setback, leg, angle) in found {
|
||||
let outward = Vec2::new(d.y, -d.x);
|
||||
let line_o = a - outward * setback;
|
||||
// The trimmed edge: along d, on the moved line.
|
||||
@@ -2000,6 +2025,72 @@ impl SheetBody {
|
||||
best.map(|(_, q)| q)
|
||||
}
|
||||
|
||||
/// The persistent name of a panel: `base`, a flange's tag, or `#<index>` for an untagged
|
||||
/// flange; `flat` for the whole sheet while it is unfolded.
|
||||
fn panel_name(&self, pi: Option<usize>) -> String {
|
||||
match pi {
|
||||
_ if self.flat => "flat".into(),
|
||||
None => "base".into(),
|
||||
Some(i) => match self.flanges.get(i) {
|
||||
Some(f) if !f.tag.is_empty() => f.tag.clone(),
|
||||
_ => format!("#{i}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The plane of a flat face of a panel (`top`: the face at the thickness, else the bottom
|
||||
/// one), facing out of the sheet. Its coordinates are the flat pattern's (the bottom
|
||||
/// face's mirrored in y), so a sketch on it measures as the flat pattern does.
|
||||
fn panel_face_plane(&self, world: &Mat, top: bool) -> Option<Plane> {
|
||||
let (x, y) = (apply_vector(world, Vec3::X), apply_vector(world, Vec3::Y));
|
||||
Plane::new(apply_point(world, Vec3::new(0.0, 0.0, if top { self.t } else { 0.0 })), x, if top { y } else { y * -1.0 })
|
||||
}
|
||||
|
||||
/// The flat face of the sheet that a point lies on (sheet coordinates, within `tol`, which
|
||||
/// stays below a quarter of the thickness): its persistent name (`<panel>:top` or
|
||||
/// `<panel>:bottom`) and its plane (see [`panel_face`](Self::panel_face)).
|
||||
pub fn panel_face_at(&self, p: Vec3, tol: f64) -> Option<(String, Plane)> {
|
||||
let tol = tol.min(self.t * 0.25);
|
||||
let flat = if self.flat { self.flat_regions().ok()? } else { Vec::new() };
|
||||
let panels = if self.flat { vec![(None, self.base.clone())] } else { self.panels() };
|
||||
for (pi, region) in panels {
|
||||
let world = self.world(pi);
|
||||
let Some(inverse) = mat_inverse(&world) else { continue };
|
||||
let q = apply_point(&inverse, p);
|
||||
let top = (q.z - self.t).abs() < q.z.abs();
|
||||
if (if top { (q.z - self.t).abs() } else { q.z.abs() }) > tol {
|
||||
continue;
|
||||
}
|
||||
let q2 = Vec2::new(q.x, q.y);
|
||||
if if self.flat { flat.iter().any(|r| r.contains(q2)) } else { region.contains(q2) } {
|
||||
return Some((format!("{}:{}", self.panel_name(pi), if top { "top" } else { "bottom" }), self.panel_face_plane(&world, top)?));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The plane of the flat face with this persistent name (see [`panel_face_at`](Self::panel_face_at)).
|
||||
/// A face named while the sheet was folded follows its panel; one named unfolded (`flat`)
|
||||
/// is the flat sheet's own plane.
|
||||
pub fn panel_face(&self, name: &str) -> Option<Plane> {
|
||||
let (panel, side) = name.rsplit_once(':')?;
|
||||
let top = match side {
|
||||
"top" => true,
|
||||
"bottom" => false,
|
||||
_ => return None,
|
||||
};
|
||||
let pi = match panel {
|
||||
"flat" | "base" => None,
|
||||
tag => Some(
|
||||
self.flanges
|
||||
.iter()
|
||||
.position(|f| f.tag == tag)
|
||||
.or_else(|| tag.strip_prefix('#')?.parse::<usize>().ok().filter(|i| *i < self.flanges.len()))?,
|
||||
),
|
||||
};
|
||||
self.panel_face_plane(&self.world(pi), top)
|
||||
}
|
||||
|
||||
/// Map a region in a world plane to flat coordinates through a panel's placement.
|
||||
pub fn region_to_flat(&self, panel: Option<usize>, plane: &Plane, r: &Region2) -> Result<Region2> {
|
||||
region_complexity(std::slice::from_ref(r))?;
|
||||
@@ -2298,6 +2389,52 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
|
||||
Ok(sheet)
|
||||
}
|
||||
|
||||
/// The name a sketch keeps for a flat face of a sheet: `sheet:<body>:<panel>:<top|bottom>`.
|
||||
pub fn sheet_face_name(body: &str, face: &str) -> String {
|
||||
format!("sheet:{body}:{face}")
|
||||
}
|
||||
|
||||
/// Point the sheet face a sketch plane is attached to (also beneath an offset or a rotation)
|
||||
/// at body `new`, when it is on body `old`.
|
||||
pub fn rename_plane_sheet_body(plane: &mut crate::PlaneRef, old: &str, new: &str) {
|
||||
match plane {
|
||||
crate::PlaneRef::Face { name: Some(name), anchor: None, .. } => {
|
||||
let renamed = split_sheet_face_name(name).filter(|(body, _)| *body == old).map(|(_, face)| sheet_face_name(new, face));
|
||||
if let Some(renamed) = renamed {
|
||||
*name = renamed;
|
||||
}
|
||||
}
|
||||
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } => rename_plane_sheet_body(base, old, new),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sheet face a sketch plane is attached to (also beneath an offset or a rotation): its
|
||||
/// [`sheet_face_name`].
|
||||
pub fn plane_sheet_face(plane: &crate::PlaneRef) -> Option<&str> {
|
||||
match plane {
|
||||
crate::PlaneRef::Face { name: Some(name), anchor: None, .. } if split_sheet_face_name(name).is_some() => Some(name),
|
||||
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } => plane_sheet_face(base),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The body and the `<panel>:<side>` of a [`sheet_face_name`].
|
||||
pub fn split_sheet_face_name(name: &str) -> Option<(&str, &str)> {
|
||||
let rest = name.strip_prefix("sheet:")?;
|
||||
let (head, side) = rest.rsplit_once(':')?;
|
||||
let (body, panel) = head.rsplit_once(':')?;
|
||||
(!body.is_empty() && !panel.is_empty() && !side.is_empty()).then_some(())?;
|
||||
Some((body, rest.get(body.len() + 1..)?))
|
||||
}
|
||||
|
||||
/// The plane of the sheet face with a [`sheet_face_name`], in the body's own coordinates.
|
||||
pub fn sheet_face_plane(st: &crate::ModelState, name: &str) -> Option<(String, Plane)> {
|
||||
let (body, face) = split_sheet_face_name(name)?;
|
||||
let plane = st.sheets.iter().find(|s| s.body == body)?.panel_face(face)?;
|
||||
Some((body.to_string(), plane))
|
||||
}
|
||||
|
||||
/// Persistent names of the sheet edges nearest each world point (empty when none).
|
||||
pub fn sheet_edge_names(st: &crate::ModelState, pts: &[Vec3]) -> Vec<String> {
|
||||
pts.iter()
|
||||
@@ -2368,6 +2505,85 @@ mod tests {
|
||||
assert!((s.flat_size().unwrap().1 - 73.685298).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_faces_of_a_sheet_are_found_and_named_by_panel() {
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
// Steel, 2.5 thick, R = T: a 90° flange on the y = 0 edge stands between the planes
|
||||
// y = 0 (its outer face) and y = 2.5 (its inner face) from z = 5 up.
|
||||
let mut s = plate(100.0, 60.0);
|
||||
s.add_flange(Vec3::new(50.0, 0.0, 2.5), 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
|
||||
s.flanges[0].tag = "F7.0".into();
|
||||
let face = |s: &SheetBody, p: Vec3| s.panel_face_at(p, 1e-6);
|
||||
let (name, plane) = face(&s, Vec3::new(50.0, 30.0, 2.5)).unwrap();
|
||||
assert_eq!(name, "base:top");
|
||||
assert!(plane.normal().dist(Vec3::Z) < 1e-12 && (plane.origin.z - 2.5).abs() < 1e-12);
|
||||
// The top face's coordinates are the flat pattern's; the bottom face's mirror y.
|
||||
assert!(plane.to_local(Vec3::new(30.0, 20.0, 2.5)).dist(Vec2::new(30.0, 20.0)) < 1e-12);
|
||||
let (name, plane) = face(&s, Vec3::new(50.0, 30.0, 0.0)).unwrap();
|
||||
assert_eq!(name, "base:bottom");
|
||||
assert!(plane.normal().dist(Vec3::Z * -1.0) < 1e-12 && plane.origin.z.abs() < 1e-12);
|
||||
assert!(plane.to_local(Vec3::new(30.0, 20.0, 0.0)).dist(Vec2::new(30.0, -20.0)) < 1e-12);
|
||||
// The flange's inner face is the sheet's top side there; 7 mm up it is flat y = 5 − BA − 7.
|
||||
let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5);
|
||||
let (inner, plane) = face(&s, Vec3::new(30.0, 2.5, 12.0)).unwrap();
|
||||
assert_eq!(inner, "F7.0:top");
|
||||
assert!(plane.normal().dist(Vec3::Y) < 1e-12, "{:?}", plane.normal());
|
||||
assert!(plane.to_local(Vec3::new(30.0, 2.5, 12.0)).dist(Vec2::new(30.0, 5.0 - ba - 7.0)) < 1e-9);
|
||||
let (outer, plane) = face(&s, Vec3::new(30.0, 0.0, 12.0)).unwrap();
|
||||
assert_eq!(outer, "F7.0:bottom");
|
||||
assert!(plane.normal().dist(Vec3::Y * -1.0) < 1e-12, "{:?}", plane.normal());
|
||||
// A name gives the same plane again.
|
||||
for p in [Vec3::new(50.0, 30.0, 2.5), Vec3::new(50.0, 30.0, 0.0), Vec3::new(30.0, 2.5, 12.0), Vec3::new(30.0, 0.0, 12.0)] {
|
||||
let (name, plane) = face(&s, p).unwrap();
|
||||
assert_eq!(s.panel_face(&name), Some(plane), "{name}");
|
||||
}
|
||||
// Inside the sheet, on the bend, beside the sheet, above the flange: no flat face.
|
||||
for p in [Vec3::new(50.0, 30.0, 1.0), Vec3::new(50.0, 1.0, 1.0), Vec3::new(150.0, 30.0, 2.5), Vec3::new(30.0, 2.5, 40.0)] {
|
||||
assert!(face(&s, p).is_none(), "{p:?}");
|
||||
}
|
||||
// Names that are no face, or no flange, give no plane.
|
||||
for name in ["base", "base:side", "nobody:top", "#7:top", "F8.0:top", ""] {
|
||||
assert!(s.panel_face(name).is_none(), "{name}");
|
||||
}
|
||||
// A flange without a tag is named by its index.
|
||||
s.flanges[0].tag.clear();
|
||||
assert_eq!(face(&s, Vec3::new(30.0, 2.5, 12.0)).map(|f| f.0).as_deref(), Some("#0:top"));
|
||||
assert!(s.panel_face("#0:bottom").is_some());
|
||||
// Unfolded, the whole sheet is one panel.
|
||||
s.flat = true;
|
||||
assert_eq!(face(&s, Vec3::new(50.0, -10.0, 2.5)).map(|f| f.0).as_deref(), Some("flat:top"));
|
||||
assert!(s.panel_face("flat:bottom").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pick_beside_a_thin_sheet_takes_the_nearer_face() {
|
||||
// The tolerance of a pick on a large part is far above a thin sheet's thickness, and
|
||||
// never reaches a quarter of it here: the nearer side wins, the middle is no face.
|
||||
let rv = RuleValues { thickness: 0.5, ..steel() };
|
||||
let s = base_flange(
|
||||
"Thin",
|
||||
"Sheet",
|
||||
&rv,
|
||||
Plane::XY,
|
||||
rect(Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(100.0, 60.0), Vec2::new(0.0, 60.0)),
|
||||
);
|
||||
let at = |z: f64| s.panel_face_at(Vec3::new(50.0, 30.0, z), 5.0).map(|f| f.0);
|
||||
assert_eq!(at(0.4).as_deref(), Some("base:top"));
|
||||
assert_eq!(at(0.1).as_deref(), Some("base:bottom"));
|
||||
assert_eq!(at(0.25), None);
|
||||
assert_eq!(at(3.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_face_names_split_from_the_right() {
|
||||
assert_eq!(split_sheet_face_name(&sheet_face_name("Body1", "F7.0:top")), Some(("Body1", "F7.0:top")));
|
||||
// A body name may hold colons; the panel and the side never do.
|
||||
assert_eq!(split_sheet_face_name("sheet:Part:2:base:bottom"), Some(("Part:2", "base:bottom")));
|
||||
for name in ["", "sheet", "sheet:Body1", "sheet:Body1:top", "sheet::base:top", "sheet:Body1::top", "sheet:Body1:base:", "Body1:base:top"] {
|
||||
assert_eq!(split_sheet_face_name(name), None, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contour_flange_matches_fusion() {
|
||||
// Oracle 72: XZ chain (0,0)→(40,0)→(40,−30)→(70,−30), 50 wide.
|
||||
@@ -2895,4 +3111,264 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge flanges on `base`, each with its own height and angle, added in one call. Returns the
|
||||
/// sheet and the notes on corners left open.
|
||||
fn flanges_each(base: Region2, rv: &RuleValues, each: &[EdgeFlange]) -> (SheetBody, Vec<String>) {
|
||||
let mut s = base_flange("Body1", "AL2", rv, Plane::XY, base);
|
||||
let notes = s.add_flanges_each(each, rv.bend_radius, "inside", false).unwrap().1;
|
||||
(s, notes)
|
||||
}
|
||||
|
||||
/// The 120 x 90 tray's four flanges (bottom, right, top, left), each with its own angle
|
||||
/// (degrees) and height.
|
||||
fn tray_each(rv: &RuleValues, angles: [f64; 4], heights: [f64; 4]) -> (SheetBody, Vec<String>) {
|
||||
let base = rect(Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0));
|
||||
let t = rv.thickness;
|
||||
let picks = [Vec3::new(60.0, 0.0, t), Vec3::new(120.0, 45.0, t), Vec3::new(60.0, 90.0, t), Vec3::new(0.0, 45.0, t)];
|
||||
let each: Vec<EdgeFlange> = (0..4).map(|i| EdgeFlange { pick: picks[i], height: heights[i], angle: angles[i].to_radians() }).collect();
|
||||
flanges_each(base, rv, &each)
|
||||
}
|
||||
|
||||
/// The material of a base flange bent up, for asking whether a folded point lies in it. Worked
|
||||
/// out from the fold on its own (not through `map3`): the panel through the inverse of its fold,
|
||||
/// the bend from the radius and turn about the bend's axis.
|
||||
struct Material {
|
||||
back: Mat,
|
||||
panel: Vec<Vec2>,
|
||||
axis: (Vec3, Vec3),
|
||||
p: Vec2,
|
||||
d: Vec2,
|
||||
span: (f64, f64),
|
||||
radius: f64,
|
||||
angle: f64,
|
||||
t: f64,
|
||||
}
|
||||
|
||||
impl Material {
|
||||
fn of(s: &SheetBody, i: usize) -> Material {
|
||||
let f = &s.flanges[i];
|
||||
assert!(f.parent.is_none() && f.angle > 0.0);
|
||||
let (o, k, _) = s.bend_axis(f);
|
||||
Material {
|
||||
back: mat_inverse(&s.fold(i)).unwrap(),
|
||||
panel: corners_of(&s.panel(f)),
|
||||
axis: (o, k),
|
||||
p: f.p,
|
||||
d: f.d,
|
||||
span: s.zone_span(f),
|
||||
radius: f.radius,
|
||||
angle: f.angle,
|
||||
t: s.t,
|
||||
}
|
||||
}
|
||||
|
||||
/// How deep (mm) `q` lies inside the flange's panel or bend; None when it is outside both.
|
||||
fn depth(&self, q: Vec3) -> Option<f64> {
|
||||
let flat = apply_point(&self.back, q);
|
||||
let m = self.panel.len();
|
||||
let sides = (0..m).map(|j| {
|
||||
let (a, b) = (self.panel[j], self.panel[(j + 1) % m]);
|
||||
(b - a).cross(Vec2::new(flat.x, flat.y) - a) / (b - a).len()
|
||||
});
|
||||
let in_panel = sides.fold(flat.z.min(self.t - flat.z), f64::min);
|
||||
// The bend turns the sheet under its axis toward the flange: a point's distance from
|
||||
// the axis is its radius, its turn from straight below the axis its place across the bend.
|
||||
let (o, k) = self.axis;
|
||||
let rel = q - o;
|
||||
let perp = rel - k * rel.dot(k);
|
||||
let down = Vec3::Z * -1.0;
|
||||
let (rho, turn) = (perp.len(), perp.dot(k.cross(down)).atan2(perp.dot(down)));
|
||||
let u = (Vec2::new(q.x, q.y) - self.p).dot(self.d);
|
||||
let in_bend = [rho - self.radius, self.radius + self.t - rho, turn * rho, (self.angle - turn) * rho, u - self.span.0, self.span.1 - u]
|
||||
.into_iter()
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
let depth = in_panel.max(in_bend);
|
||||
(depth > 0.0).then_some(depth)
|
||||
}
|
||||
}
|
||||
|
||||
/// Folded, the two flanges at corner `k` keep out of each other: no point of one's bend or
|
||||
/// panel near the corner (sampled every 0.5 mm or finer, at three heights through the sheet)
|
||||
/// lies in the other's. Each sample is also found inside its own flange, which checks
|
||||
/// [`Material`] against the fold that places the solid's faces.
|
||||
fn flanges_keep_clear(s: &SheetBody, k: &Corner, what: &str) {
|
||||
let (ma, mb) = (Material::of(s, k.a), Material::of(s, k.b));
|
||||
for (i, at_end, own, other) in [(k.a, k.a_end, &ma, &mb), (k.b, k.b_end, &mb, &ma)] {
|
||||
let f = &s.flanges[i];
|
||||
let ba = s.allowance(f);
|
||||
let mut samples = Vec::new();
|
||||
// The bend's 12 mm nearest the corner.
|
||||
let (u0, u1) = s.zone_span(f);
|
||||
for iu in 0..48 {
|
||||
let du = 0.02 + 0.25 * iu as f64;
|
||||
let u = if at_end { u1 - du } else { u0 + du };
|
||||
for is in 0..24 {
|
||||
samples.push((Piece::Zone(i), f.p + f.d * u + f.n * (ba * (is as f64 + 0.5) / 24.0)));
|
||||
}
|
||||
}
|
||||
// The panel's 25 mm nearest its end, over its whole height.
|
||||
let (q0, q1, inward) = if at_end {
|
||||
let b = f.p + f.n * ba + f.d * (f.len + f.ext.1);
|
||||
(b, b + f.n * f.leg + f.d * (f.slope.1 * f.leg), f.d * -1.0)
|
||||
} else {
|
||||
let a = f.p + f.n * ba - f.d * f.ext.0;
|
||||
(a, a + f.n * f.leg - f.d * (f.slope.0 * f.leg), f.d)
|
||||
};
|
||||
let rows = (f.leg / 0.5).ceil().max(1.0) as usize;
|
||||
for ir in 0..rows {
|
||||
let on_end = q0 + (q1 - q0) * ((ir as f64 + 0.5) / rows as f64);
|
||||
for iu in 0..50 {
|
||||
samples.push((Piece::Panel(Some(i)), on_end + inward * (0.02 + 0.5 * iu as f64)));
|
||||
}
|
||||
}
|
||||
for (piece, flat) in samples {
|
||||
for z in [0.1 * s.t, 0.5 * s.t, 0.9 * s.t] {
|
||||
let q = s.map3(piece, flat, z);
|
||||
assert!(own.depth(q).is_some(), "{what}: flange {i}: its own point {flat:?} at {z} ({piece:?}) is not found in it");
|
||||
let d = other.depth(q);
|
||||
assert!(
|
||||
d.is_none_or(|d| d < 1e-6),
|
||||
"{what}: flange {i}'s {piece:?} at {flat:?}, height {z}, lies {d:?} mm inside the other flange"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel of flange `i` of the tray by hand, in the flat pattern. Take a flange at θ on the
|
||||
/// edge y = 0 and its neighbour at φ on x = 0, T the thickness. A point of the first one's inner
|
||||
/// face, w up its outer face from the virtual sharp, is (x, −w·cosθ + T·sinθ, w·sinθ + T·cosθ);
|
||||
/// the inner faces are the planes y·sinθ + z·cosθ = T and x·sinφ + z·cosφ = T, and the plane
|
||||
/// halving them passes through the corner with normal (sinφ, −sinθ, cosφ − cosθ), of length
|
||||
/// √(2 − 2·cosθ·cosφ). Keeping the inner face gap/2 from it gives the panel's end
|
||||
/// x(w) = ((g/2)·√(2 − 2·cosθ·cosφ) + T·(1 − cosθ·cosφ) − w·sinθ·cosφ) / sinφ for w = s0 … h, at
|
||||
/// flat y = s0 − BA − (w − s0). Each flange is taken in its own frame: the origin at the start
|
||||
/// corner of its edge, u along the edge, v into the base; flange i − 1 is at its start and
|
||||
/// flange i + 1 at its end.
|
||||
fn hand_panel(rv: &RuleValues, angles: [f64; 4], heights: [f64; 4], i: usize) -> Vec<Vec2> {
|
||||
let (t, r, g) = (rv.thickness, rv.bend_radius, rv.gap);
|
||||
let (o, d, v, len) = [
|
||||
(Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0), 120.0),
|
||||
(Vec2::new(120.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(-1.0, 0.0), 90.0),
|
||||
(Vec2::new(120.0, 90.0), Vec2::new(-1.0, 0.0), Vec2::new(0.0, -1.0), 120.0),
|
||||
(Vec2::new(0.0, 90.0), Vec2::new(0.0, -1.0), Vec2::new(1.0, 0.0), 90.0),
|
||||
][i];
|
||||
let th = angles[i].to_radians();
|
||||
let (s0, ba) = ((r + t) * (th / 2.0).tan(), th * (r + rv.k_factor * t));
|
||||
let x_end = |phi: f64, w: f64| {
|
||||
let c = th.cos() * phi.cos();
|
||||
((g / 2.0) * (2.0 - 2.0 * c).sqrt() + t * (1.0 - c) - w * th.sin() * phi.cos()) / phi.sin()
|
||||
};
|
||||
let (prev, next) = (angles[(i + 3) % 4].to_radians(), angles[(i + 1) % 4].to_radians());
|
||||
let flat = |u: f64, w: f64| o + d * u + v * (s0 - ba - (w - s0));
|
||||
let h = heights[i];
|
||||
vec![flat(x_end(prev, s0), s0), flat(x_end(prev, h), h), flat(len - x_end(next, h), h), flat(len - x_end(next, s0), s0)]
|
||||
}
|
||||
|
||||
/// The tray's flat area by hand: the base inside the four bend start lines, every bend zone
|
||||
/// (the base's trimmed edge by the bend allowance) and every panel of [`hand_panel`]. With
|
||||
/// s0 = (R + T)·tan(θ/2) a flange's setback and BA = θ·(R + K·T) its bend allowance, the base
|
||||
/// is (120 − s0_left − s0_right) by (90 − s0_bottom − s0_top).
|
||||
fn hand_flat_area(rv: &RuleValues, angles: [f64; 4], heights: [f64; 4]) -> f64 {
|
||||
let s0 = angles.map(|a| (rv.bend_radius + rv.thickness) * (a.to_radians() / 2.0).tan());
|
||||
let ba = angles.map(|a| a.to_radians() * (rv.bend_radius + rv.k_factor * rv.thickness));
|
||||
let (w, h) = (120.0 - s0[3] - s0[1], 90.0 - s0[0] - s0[2]);
|
||||
let panels: f64 = (0..4).map(|i| signed_area(&hand_panel(rv, angles, heights, i)).abs()).sum();
|
||||
w * h + (ba[0] + ba[2]) * w + (ba[1] + ba[3]) * h + panels
|
||||
}
|
||||
|
||||
/// 60°, 90°, 105° and 120° on the tray's bottom, right, top and left edges.
|
||||
const MIXED: [f64; 4] = [60.0, 90.0, 105.0, 120.0];
|
||||
|
||||
#[test]
|
||||
fn flanges_with_their_own_angle_and_height_close_at_their_corners() {
|
||||
// Four angles on a 120 x 90 tray, then four heights as well, in one call: every panel ends
|
||||
// where the hand derivation says, whatever its neighbour's angle and height.
|
||||
let rv = alu();
|
||||
for (heights, what) in [([38.6; 4], "one height"), ([38.6, 30.0, 45.0, 25.0], "four heights")] {
|
||||
let (s, notes) = tray_each(&rv, MIXED, heights);
|
||||
assert!(notes.is_empty(), "{what}: {notes:?}");
|
||||
for i in 0..4 {
|
||||
let (f, th) = (&s.flanges[i], MIXED[i].to_radians());
|
||||
assert!((f.angle - th).abs() < 1e-12, "{what}: flange {i}");
|
||||
assert!((f.leg - (heights[i] - (rv.bend_radius + rv.thickness) * (th / 2.0).tan())).abs() < 1e-9, "{what}: flange {i}");
|
||||
let got = corners_of(&s.panel(f));
|
||||
assert_eq!(got.len(), 4, "{what}: flange {i}");
|
||||
for q in hand_panel(&rv, MIXED, heights, i) {
|
||||
assert!(got.iter().any(|p| p.dist(q) < 1e-9), "{what}: flange {i}: {q:?} not in {got:?}");
|
||||
}
|
||||
}
|
||||
// The flat pattern is the base, the bend zones and those panels, nothing more or less;
|
||||
// folded it holds the same material (`holds_the_flat_material`), so the volume is this
|
||||
// area times the thickness.
|
||||
let (area, want) = (flat_area(&s), hand_flat_area(&rv, MIXED, heights));
|
||||
assert!((area - want).abs() < 1e-6, "{what}: flat area {area} vs {want}");
|
||||
let corners = s.corners(&[0, 1, 2, 3]);
|
||||
assert_eq!(corners.len(), 4, "{what}");
|
||||
ends_are_gap_apart(&s, &corners, rv.gap, what);
|
||||
for k in &corners {
|
||||
flanges_keep_clear(&s, k, what);
|
||||
}
|
||||
holds_the_flat_material(&s, what);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trapezoid_with_unequal_angles_and_heights_closes_at_its_acute_and_obtuse_corners() {
|
||||
// 77.5° and 102.5° base corners; flanges at 60°, 105°, 75° and 120°, each its own height.
|
||||
let pts = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(100.0, 90.0), Vec2::new(20.0, 90.0)];
|
||||
let picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(110.0, 45.0, 2.0), Vec3::new(60.0, 90.0, 2.0), Vec3::new(10.0, 45.0, 2.0)];
|
||||
let (angles, heights) = ([60.0f64, 105.0, 75.0, 120.0], [38.6, 30.0, 45.0, 25.0]);
|
||||
let each: Vec<EdgeFlange> = (0..4).map(|i| EdgeFlange { pick: picks[i], height: heights[i], angle: angles[i].to_radians() }).collect();
|
||||
let (s, notes) = flanges_each(Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] }, &alu(), &each);
|
||||
assert!(notes.is_empty(), "{notes:?}");
|
||||
for i in 0..4 {
|
||||
let (f, th) = (&s.flanges[i], angles[i].to_radians());
|
||||
assert!((f.angle - th).abs() < 1e-12 && (f.leg - (heights[i] - 3.0 * (th / 2.0).tan())).abs() < 1e-9, "flange {i}: {f:?}");
|
||||
}
|
||||
let corners = s.corners(&[0, 1, 2, 3]);
|
||||
assert_eq!(corners.len(), 4);
|
||||
ends_are_gap_apart(&s, &corners, 0.5, "trapezoid");
|
||||
for k in &corners {
|
||||
flanges_keep_clear(&s, k, "trapezoid");
|
||||
}
|
||||
holds_the_flat_material(&s, "trapezoid");
|
||||
}
|
||||
|
||||
/// The angles of the sweep below.
|
||||
const SWEEP: [f64; 7] = [45.0, 60.0, 75.0, 90.0, 105.0, 120.0, 135.0];
|
||||
|
||||
#[test]
|
||||
fn every_pair_of_angles_closes_at_any_base_corner() {
|
||||
// A parallelogram base (120 and 90 long) with a corner of 60°, 90° or 120° at the origin;
|
||||
// flanges 38.6 high on its two edges there, in one call, at every pair of the angles: 147
|
||||
// corners, each closed with the gap, the flanges clear of each other, the part one valid
|
||||
// shell holding the flat pattern's material.
|
||||
let rv = alu();
|
||||
let mut cases = 0;
|
||||
for alpha in [60.0f64, 90.0, 120.0] {
|
||||
let u = Vec2::new(alpha.to_radians().cos(), alpha.to_radians().sin());
|
||||
let pts = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 0.0) + u * 90.0, u * 90.0];
|
||||
for a in SWEEP {
|
||||
for b in SWEEP {
|
||||
let what = format!("corner {alpha}°, flanges {a}° and {b}°");
|
||||
let each = [
|
||||
EdgeFlange { pick: Vec3::new(60.0, 0.0, 2.0), height: 38.6, angle: a.to_radians() },
|
||||
EdgeFlange { pick: Vec3::new(u.x * 45.0, u.y * 45.0, 2.0), height: 38.6, angle: b.to_radians() },
|
||||
];
|
||||
let (s, notes) = flanges_each(Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] }, &rv, &each);
|
||||
assert!(notes.is_empty(), "{what}: {notes:?}");
|
||||
assert!((s.flanges[0].angle - a.to_radians()).abs() < 1e-12 && (s.flanges[1].angle - b.to_radians()).abs() < 1e-12, "{what}");
|
||||
let corners = s.corners(&[0, 1]);
|
||||
assert_eq!(corners.len(), 1, "{what}");
|
||||
ends_are_gap_apart(&s, &corners, rv.gap, &what);
|
||||
flanges_keep_clear(&s, &corners[0], &what);
|
||||
holds_the_flat_material(&s, &what);
|
||||
cases += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(cases, 147);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +464,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Later features refer to bodies by name.
|
||||
let mut failed = None;
|
||||
for f in d.features.iter_mut().filter(|f| f.id != fid) {
|
||||
if let FeatureKind::Sketch { plane, .. } = &mut f.kind {
|
||||
solidcraft_doc::sheet::rename_plane_sheet_body(plane, &old, name);
|
||||
}
|
||||
if let FeatureKind::Sketch { plane, sketch } = &mut f.kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
|
||||
&& anchor.target.body == old
|
||||
@@ -614,7 +617,13 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.pending_moves = trial.pending_moves;
|
||||
|
||||
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() { "exact_face" } else { "fixed" }),
|
||||
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() {
|
||||
"exact_face"
|
||||
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
|
||||
"sheet_face"
|
||||
} else {
|
||||
"fixed"
|
||||
}),
|
||||
_ => None,
|
||||
});
|
||||
Ok(json!({ "sketch": id,"attachment":attachment }))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Undo/redo, the timeline, deletion and selection.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{Feature, FeatureKind};
|
||||
|
||||
@@ -22,7 +24,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("delete")
|
||||
.key("Delete")
|
||||
.params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"),
|
||||
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name"),
|
||||
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name (references to it follow)"),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
|
||||
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
|
||||
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
|
||||
@@ -163,15 +165,25 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let name =
|
||||
str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
|
||||
let old = s.doc.feature(id).map(|f| f.name.clone()).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
if s.doc.features.iter().any(|f| f.name == name && f.id != id) {
|
||||
return Err(bad(cmd, format!("another feature is called `{name}`")));
|
||||
}
|
||||
if s.doc.feature(id).is_some_and(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })) {
|
||||
super::features::check_plane_name(name, cmd)?;
|
||||
}
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
if name == old {
|
||||
return Ok(json!({"feature": id, "name": name}));
|
||||
}
|
||||
let names = BTreeMap::from([(old.clone(), name.to_string())]);
|
||||
let sketches = BTreeMap::new();
|
||||
let doc = s.doc_mut();
|
||||
if let Some(f) = doc.feature_mut(id) {
|
||||
f.name = name.to_string();
|
||||
}
|
||||
for f in doc.features.iter_mut() {
|
||||
f.kind.remap_refs(&sketches, &names);
|
||||
}
|
||||
Ok(json!({"feature": id, "name": name}))
|
||||
}
|
||||
|
||||
@@ -322,6 +334,14 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
nf.edge_refs = solidcraft_doc::edge_refs(&before, body, a);
|
||||
nf.edge_names = solidcraft_doc::edge_names_for(&before, body, a);
|
||||
}
|
||||
// New or reordered flange edges: their names now, so each edge's own angle and height stay
|
||||
// with the edge they were given for.
|
||||
if let (FeatureKind::SheetFlange { edges: a, .. }, FeatureKind::SheetFlange { edges: b, .. }) = (&nf.kind, &f.kind) {
|
||||
let points = |e: &[solidcraft_doc::FlangeEdge]| e.iter().map(|x| x.point).collect::<Vec<_>>();
|
||||
if points(a) != points(b) {
|
||||
nf.edge_names = solidcraft_doc::sheet::sheet_edge_names(&s.model.state_before(id), &points(a));
|
||||
}
|
||||
}
|
||||
// Profiles picked by index or point are kept by their curves, as when the feature was made.
|
||||
if nf.kind.clone().profile_sels_mut() != f.kind.clone().profile_sels_mut() {
|
||||
super::features::stable_profiles(s, &mut nf.kind)?;
|
||||
|
||||
@@ -142,7 +142,8 @@ fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
|
||||
let component = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let occurrence = component["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Stock"}));
|
||||
run(&mut s, "solid.press_pull", json!({"face":[5,5,20],"distance":5}));
|
||||
// A top merged from two joined boxes has no single certified source: the legacy path.
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"corner":[40,0,0],"operation":"join"}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":false}));
|
||||
let state = s.model.state();
|
||||
@@ -153,12 +154,12 @@ fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
|
||||
.faces(1e-4)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 25.0).abs() < 1e-6)
|
||||
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 20.0).abs() < 1e-6)
|
||||
.unwrap()
|
||||
.index;
|
||||
let before = s.doc.to_json();
|
||||
let error =
|
||||
super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,25],"body":"Stock","index":index,"path":[occurrence]}), "internal")
|
||||
super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,20],"body":"Stock","index":index,"path":[occurrence]}), "internal")
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("capture the source occurrence position"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
@@ -229,3 +230,153 @@ fn blind_cut_inside_a_cylinder_keeps_only_planes_and_cylinders() {
|
||||
assert_eq!(body["face_types"], json!({"cylinder": 2, "plane": 12}), "{m}");
|
||||
assert_eq!(body["faces"], 14, "{m}");
|
||||
}
|
||||
|
||||
fn face_anchor(s: &Session, sketch: u64) -> solidcraft_doc::FaceAnchor {
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch).unwrap().kind else {
|
||||
panic!("sketch {sketch} has no face anchor")
|
||||
};
|
||||
(**anchor).clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_keeps_exact_face_attachments_from_both_operands() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-10,-10],"p1":[10,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let base = run(&mut s, "solid.extrude", json!({"distance":40,"name":"Base"}))["feature"].as_u64().unwrap();
|
||||
// A cross bar joined through the base (the shape of #420) and a boss joined on its top.
|
||||
run(&mut s, "sketch.create", json!({"plane":"YZ"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,20],"radius":5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":20,"symmetric":true,"operation":"join"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,40]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let boss = run(&mut s, "solid.extrude", json!({"distance":10,"operation":"join","name":"Boss"}))["feature"].as_u64().unwrap();
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
|
||||
// The base's top around the boss, and the boss's own top (the Union's second operand).
|
||||
let on_base = run(&mut s, "sketch.create", json!({"plane":{"face":[8,8,40]}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let on_boss = run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,50]}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(face_anchor(&s, on_base).target.origin_feature, base);
|
||||
assert_eq!(face_anchor(&s, on_boss).target.origin_feature, boss);
|
||||
|
||||
// The attachments follow their faces through an upstream edit and a reopen.
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Base","set":{"extent":{"distance":"50"}}}));
|
||||
s.refresh();
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none()), "{:?}", s.model.results);
|
||||
let state = s.model.state();
|
||||
assert!((state.sketch(on_base).unwrap().plane.origin.z - 50.0).abs() < 1e-6);
|
||||
assert!((state.sketch(on_boss).unwrap().plane.origin.z - 60.0).abs() < 1e-6);
|
||||
let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
reopened.refresh();
|
||||
assert!(reopened.model.results.iter().all(|result| result.error.is_none()), "{:?}", reopened.model.results);
|
||||
assert!((reopened.model.state().sketch(on_boss).unwrap().plane.origin.z - 60.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_leaves_a_face_merged_from_both_operands_without_an_attachment() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10}));
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"corner":[20,0,0],"operation":"join"}));
|
||||
// Both boxes' tops share one support: no single construction identity owns it.
|
||||
let error = s.execute("sketch.create", &json!({"plane":{"face":[10,10,10]}})).unwrap_err().to_string();
|
||||
assert!(error.contains("fixed:true"), "{error}");
|
||||
run(&mut s, "sketch.create", json!({"plane":{"face":[10,10,10],"fixed":true}}));
|
||||
// An unshared support keeps its box role.
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let side = run(&mut s, "sketch.create", json!({"plane":{"face":[0,10,5]}}))["sketch"].as_u64().unwrap();
|
||||
assert!(matches!(face_anchor(&s, side).target.certificate, Some(solidcraft_doc::config::FaceCertificate::Box { axis: 0, high: false })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_covering_a_whole_attached_face_loses_the_attachment_without_moving_it() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10,"name":"Block"}));
|
||||
let top = run(&mut s, "sketch.create", json!({"plane":{"face":[30,15,10]},"name":"Top"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[30,15],"radius":3}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Top","distance":5,"operation":"join","name":"Peg"}));
|
||||
// An earlier join whose roof replaces Block's whole top.
|
||||
run(&mut s, "timeline.roll_to", json!({"feature":"Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"B"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-5,-5],"p1":[45,35]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"B","distance":20,"operation":"join","name":"Boss"}));
|
||||
run(&mut s, "timeline.roll_to", json!({}));
|
||||
let failed = |s: &Session, id: u64| s.model.results.iter().find(|r| r.id == id).unwrap().error.is_some();
|
||||
assert!(failed(&s, top), "the attachment never follows the substitute roof");
|
||||
assert!(s.model.state().sketch(top).is_none());
|
||||
run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true}));
|
||||
assert!(!failed(&s, top));
|
||||
assert!((s.model.state().sketch(top).unwrap().plane.origin.z - 10.).abs() < 1e-9);
|
||||
}
|
||||
|
||||
fn certificate_of(s: &Session, sketch: u64) -> Option<solidcraft_doc::config::FaceCertificate> {
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch).unwrap().kind else {
|
||||
return None;
|
||||
};
|
||||
anchor.target.certificate.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_primitive_caps_take_attachments_that_follow_edits_and_reopen() {
|
||||
use solidcraft_doc::config::FaceCertificate;
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":40,"name":"Post"}));
|
||||
let top = run(&mut s, "sketch.create", json!({"plane":{"face":[2,1,40]}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let bottom = run(&mut s, "sketch.create", json!({"plane":{"face":[2,1,0]}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(certificate_of(&s, top), Some(FaceCertificate::CylinderCap { high: true }));
|
||||
assert_eq!(certificate_of(&s, bottom), Some(FaceCertificate::CylinderCap { high: false }));
|
||||
let heights = |s: &Session| {
|
||||
let state = s.model.state();
|
||||
(state.sketch(top).unwrap().plane.origin.z, state.sketch(bottom).unwrap().plane.origin.z)
|
||||
};
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Post","set":{"height":"55","base":[0,0,5]}}));
|
||||
s.refresh();
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none()), "{:?}", s.model.results);
|
||||
let (high, low) = heights(&s);
|
||||
assert!((high - 60.).abs() < 1e-9 && (low - 5.).abs() < 1e-9, "{high} {low}");
|
||||
let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
reopened.refresh();
|
||||
assert!(reopened.model.results.iter().all(|result| result.error.is_none()), "{:?}", reopened.model.results);
|
||||
assert_eq!(heights(&reopened), (high, low));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossed_cylinder_primitives_keep_their_caps_attachable() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":40}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius":5,"height":40,"base":[-20,0,20],"axis":"X","operation":"join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
for face in [[0., 0., 40.], [20., 0., 20.]] {
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane":{"face":face}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(certificate_of(&s, sketch).is_some(), "{face:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shelled_cylinder_floor_keeps_its_cap_identity() {
|
||||
use solidcraft_doc::config::FaceCertificate;
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":40}));
|
||||
run(&mut s, "solid.shell", json!({"faces":[[2,1,40]],"thickness":2}));
|
||||
let floor = run(&mut s, "sketch.create", json!({"plane":{"face":[2,1,2]}}))["sketch"].as_u64().unwrap();
|
||||
assert!(
|
||||
matches!(
|
||||
certificate_of(&s, floor),
|
||||
Some(FaceCertificate::ShellPlanar { source, inner: true, .. }) if *source == FaceCertificate::CylinderCap { high: false }
|
||||
),
|
||||
"{:?}",
|
||||
certificate_of(&s, floor)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -387,9 +387,11 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
(solidcraft_doc::edge_refs(&st, body, edges), solidcraft_doc::edge_names_for(&st, body, edges))
|
||||
}
|
||||
// Sheet metal edges are named by the sheet model.
|
||||
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => {
|
||||
(Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), edges))
|
||||
FeatureKind::SheetFlange { edges, .. } => {
|
||||
let points: Vec<_> = edges.iter().map(|e| e.point).collect();
|
||||
(Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), &points))
|
||||
}
|
||||
FeatureKind::SheetHem { edges, .. } => (Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), edges)),
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let face_names = {
|
||||
@@ -1530,3 +1532,7 @@ mod offset_edit_guard_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "offset_plane_face_tests.rs"]
|
||||
mod offset_plane_face_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "stacked_face_tests.rs"]
|
||||
mod stacked_face_tests;
|
||||
|
||||
@@ -132,7 +132,10 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
|
||||
}
|
||||
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let doc = solidcraft_io::read_design(&bytes)?;
|
||||
let mut doc = solidcraft_io::read_design(&bytes)?;
|
||||
if let Some(stem) = default_name_for(&doc.name, path) {
|
||||
doc.name = stem;
|
||||
}
|
||||
if let Some(notify) = s.open_preview.take()
|
||||
&& let Some(preview) = solidcraft_io::read_design_preview(&bytes)
|
||||
{
|
||||
@@ -141,10 +144,50 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*s = Session::new(doc);
|
||||
s.path = Some(path.to_string());
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors}))
|
||||
Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "name": s.doc.name}))
|
||||
}
|
||||
|
||||
/// The name a design never given one by its user is shown under.
|
||||
const DEFAULT_NAME: &str = "Untitled";
|
||||
|
||||
/// A design still under the default name takes its file's name (`bracket.solidcraft` →
|
||||
/// "bracket"); a name the user chose is kept. `None` when nothing changes.
|
||||
fn default_name_for(name: &str, path: &str) -> Option<String> {
|
||||
let name = name.trim();
|
||||
if !name.is_empty() && name != DEFAULT_NAME {
|
||||
return None;
|
||||
}
|
||||
let stem = std::path::Path::new(path).file_stem()?.to_string_lossy().trim().to_string();
|
||||
(!stem.is_empty()).then_some(stem)
|
||||
}
|
||||
|
||||
/// Rename the design, and the undo and redo states that still carry the old name, so undo
|
||||
/// does not bring the default name back and leave the design looking changed.
|
||||
fn rename_design(s: &mut Session, from: &str, to: &str) {
|
||||
s.doc_mut().name = to.to_string();
|
||||
for snap in s.undo.iter_mut().chain(s.redo.iter_mut()) {
|
||||
if snap.doc.name == from {
|
||||
std::sync::Arc::make_mut(&mut snap.doc).name = to.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> {
|
||||
// Saving an unnamed design names it after its file (not an undoable edit).
|
||||
let renamed = default_name_for(&s.doc.name, path).map(|stem| (s.doc.name.clone(), stem));
|
||||
if let Some((from, to)) = &renamed {
|
||||
rename_design(s, from, to);
|
||||
}
|
||||
let r = write_file(s, path, format, compression, preview);
|
||||
if r.is_err()
|
||||
&& let Some((from, to)) = &renamed
|
||||
{
|
||||
rename_design(s, to, from);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> {
|
||||
let compression = match compression {
|
||||
"fast" => solidcraft_io::DesignCompression::Fast,
|
||||
"compact" => solidcraft_io::DesignCompression::Compact,
|
||||
@@ -159,7 +202,7 @@ fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview:
|
||||
solidcraft_io::write_atomic(std::path::Path::new(path), &bytes, true).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
s.path = Some(path.to_string());
|
||||
s.mark_saved();
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "name": s.doc.name}))
|
||||
}
|
||||
|
||||
/// Cache only warm display geometry. A save never waits for an unevaluated mesh.
|
||||
@@ -267,7 +310,10 @@ fn recover(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "doc.recover";
|
||||
let dir = recovery_dir(p, cmd)?;
|
||||
let id = str_(p, "id").ok_or_else(|| bad(cmd, "`id` is required (see doc.recovery_list)"))?;
|
||||
let (doc, entry) = crate::recovery::load(&dir, id)?;
|
||||
let (mut doc, entry) = crate::recovery::load(&dir, id)?;
|
||||
if let Some(stem) = entry.path.as_deref().and_then(|path| default_name_for(&doc.name, path)) {
|
||||
doc.name = stem;
|
||||
}
|
||||
*s = Session::new(doc);
|
||||
s.path = entry.path.clone();
|
||||
s.mark_unsaved();
|
||||
@@ -420,6 +466,67 @@ mod tests {
|
||||
assert!(saved_preview(&editing).is_none());
|
||||
}
|
||||
|
||||
fn name_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-name-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_names_an_untitled_design_after_its_file() {
|
||||
let dir = name_dir("untitled");
|
||||
let path = dir.join("bracket.solidcraft");
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
assert_eq!(s.doc.name, "Untitled");
|
||||
let r = s.execute("file.save_as", &json!({"path":path})).unwrap();
|
||||
assert_eq!(r["name"], "bracket");
|
||||
assert_eq!(s.doc.name, "bracket");
|
||||
assert!(!s.is_dirty(), "taking the file's name is part of the save");
|
||||
assert_eq!(solidcraft_io::read_design(&std::fs::read(&path).unwrap()).unwrap().name, "bracket");
|
||||
// Undoing the box does not bring the old name back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.name, "bracket");
|
||||
// Saving under another file keeps the name it now has.
|
||||
s.execute("file.save_as", &json!({"path":dir.join("copy.solidcraft")})).unwrap();
|
||||
assert_eq!(s.doc.name, "bracket");
|
||||
// A failed save leaves the name alone.
|
||||
let mut failed = Session::default();
|
||||
assert!(failed.execute("file.save_as", &json!({"path":dir.join("missing-dir").join("plate.solidcraft")})).is_err());
|
||||
assert_eq!(failed.doc.name, "Untitled");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_keeps_a_name_the_user_chose() {
|
||||
let dir = name_dir("named");
|
||||
let mut s = Session::default();
|
||||
s.execute("file.new", &json!({"name":"Gearbox lid"})).unwrap();
|
||||
s.execute("file.save_as", &json!({"path":dir.join("lid-v2.solidcraft")})).unwrap();
|
||||
assert_eq!(s.doc.name, "Gearbox lid");
|
||||
let mut reopened = Session::default();
|
||||
reopened.execute("doc.open", &json!({"path":dir.join("lid-v2.solidcraft")})).unwrap();
|
||||
assert_eq!(reopened.doc.name, "Gearbox lid");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_an_untitled_file_shows_its_file_name() {
|
||||
let dir = name_dir("open");
|
||||
for (stored, file) in [("Untitled", "hinge.solidcraft"), ("", "spacer.json")] {
|
||||
let path = dir.join(file);
|
||||
let bytes = solidcraft_io::write_design(&Document::new(stored));
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("doc.open", &json!({"path":path})).unwrap();
|
||||
let stem = file.split('.').next().unwrap();
|
||||
assert_eq!(s.doc.name, stem);
|
||||
assert_eq!(r["name"], stem);
|
||||
assert!(!s.is_dirty(), "showing the file's name is not a change to save");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_save_formats_reopen_and_preserve_atomic_backups() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-container-{}", std::process::id()));
|
||||
|
||||
@@ -208,6 +208,25 @@ fn patterns_of_a_blind_hole_drill_every_copy() {
|
||||
assert!((c - 2.0 * one).abs() < 1e-3 * one, "{c} vs 2 × {one}");
|
||||
}
|
||||
|
||||
/// Feature patterns and mirrors reference features by name; a rename follows (#401).
|
||||
#[test]
|
||||
fn renaming_a_source_feature_follows_pattern_and_mirror_references() {
|
||||
let mut s = plate_with_hole();
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "YZ", "offset": 0, "name": "Mirror plane"}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Hole1"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 20, "name": "Copies"}));
|
||||
run(&mut s, "solid.mirror", json!({"features": ["Hole1"], "plane": "Mirror plane", "name": "Mirrored"}));
|
||||
let renamed = run(&mut s, "timeline.rename", json!({"feature": "Hole1", "name": "Single hole"}));
|
||||
assert_eq!(renamed["name"], "Single hole", "{renamed}");
|
||||
run(&mut s, "timeline.rename", json!({"feature": "Mirror plane", "name": "Renamed plane"}));
|
||||
for feature in ["Copies", "Mirrored"] {
|
||||
let inspected = run(&mut s, "timeline.inspect", json!({"feature": feature}));
|
||||
assert!(inspected["error"].is_null(), "{feature}: {inspected}");
|
||||
assert_eq!(inspected["definition"]["features"], json!(["Single hole"]), "{inspected}");
|
||||
}
|
||||
let inspected = run(&mut s, "timeline.inspect", json!({"feature": "Mirrored"}));
|
||||
assert_eq!(inspected["definition"]["plane"]["name"], "Renamed plane", "{inspected}");
|
||||
}
|
||||
|
||||
/// A plate with a `count1` × `count2` feature pattern of one through-hole cut: its volume and how
|
||||
/// long the pattern took. It runs on its own thread so a hang fails the test instead of CI.
|
||||
fn hole_pattern(count1: usize, count2: usize) -> (f64, std::time::Duration) {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! the flat pattern (outline, bend lines, bend table) and its DXF export.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::FeatureKind;
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::sheet::{SheetBody, SheetRule};
|
||||
use solidcraft_doc::{FeatureKind, FlangeEdge};
|
||||
use solidcraft_geom::{Vec2, Vec3};
|
||||
|
||||
use super::CommandSpec;
|
||||
@@ -15,7 +15,8 @@ use crate::{EngineError, Result, Session};
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sheet.flange", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params(
|
||||
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \
|
||||
edge: edges: [[x,y,z] on top/bottom edges] + height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
|
||||
edge: edges: [[x,y,z] on top/bottom edges, or {point: [x,y,z], angle?, height?} for an edge with its own angle and height] + \
|
||||
height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
|
||||
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs); name?, body_name? (the new body)",
|
||||
),
|
||||
CommandSpec::new("sheet.hem", "Hem", hem)
|
||||
@@ -61,6 +62,39 @@ fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `edges` of an edge flange: points `[x, y, z]` on sheet edges, or `{point, angle?, height?}`
|
||||
/// for an edge with its own angle and height (expressions, checked like the feature's).
|
||||
fn flange_edges(s: &Session, p: &Value, cmd: &str) -> Result<Vec<FlangeEdge>> {
|
||||
const KEYS: [&str; 3] = ["point", "angle", "height"];
|
||||
let list = "`edges` must list points [x, y, z] on sheet edges, or {point, angle?, height?}";
|
||||
let a = p.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, list))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
return Err(bad(cmd, "`edges` must list 1…1000 points"));
|
||||
}
|
||||
let mut edges = Vec::with_capacity(a.len());
|
||||
for (i, v) in a.iter().enumerate() {
|
||||
// A bare point, as before: [x, y, z] or {x, y, z}.
|
||||
let Some(o) = v.as_object().filter(|o| KEYS.iter().any(|k| o.contains_key(*k))) else {
|
||||
edges.push(FlangeEdge::at(vec3(v).ok_or_else(|| bad(cmd, format!("edges[{i}]: {list}")))?));
|
||||
continue;
|
||||
};
|
||||
if let Some(k) = o.keys().find(|k| !KEYS.contains(&k.as_str())) {
|
||||
return Err(bad(cmd, format!("edges[{i}]: unknown key `{k}` (an edge takes point, angle and height)")));
|
||||
}
|
||||
let point = o.get("point").and_then(vec3).ok_or_else(|| bad(cmd, format!("edges[{i}]: `point` must be [x, y, z]")))?;
|
||||
let own = |k: &str, kind: Kind| -> Result<Option<String>> {
|
||||
if o.get(k).is_none_or(Value::is_null) {
|
||||
return Ok(None);
|
||||
}
|
||||
let e = expr(v, k).ok_or_else(|| bad(cmd, format!("edges[{i}]: `{k}` must be a number or an expression")))?;
|
||||
check_expr(s, &e, kind, cmd, &format!("edges[{i}].{k}"))?;
|
||||
Ok(Some(e))
|
||||
};
|
||||
edges.push(FlangeEdge { point, angle: own("angle", Kind::Angle)?, height: own("height", Kind::Length)? });
|
||||
}
|
||||
Ok(edges)
|
||||
}
|
||||
|
||||
fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
|
||||
match str_(p, "rule") {
|
||||
Some(r) if s.doc.sheet.rules.iter().any(|x| x.name == r) || r == SheetRule::steel().name => Ok(Some(r.to_string())),
|
||||
@@ -106,7 +140,7 @@ fn flange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
)
|
||||
}
|
||||
"edge" => {
|
||||
let edges = points(p, "edges", cmd)?;
|
||||
let edges = flange_edges(s, p, cmd)?;
|
||||
let height = req_expr(cmd, p, "height")?;
|
||||
check_expr(s, &height, Kind::Length, cmd, "height")?;
|
||||
let angle = expr(p, "angle").unwrap_or_else(|| "90 deg".into());
|
||||
|
||||
@@ -709,6 +709,212 @@ fn fold_keeps_the_larger_side_either_way() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 100 x 60 plate with a 90° flange up from its y = 0 edge (steel: 2.5 thick, R = T, K 0.44).
|
||||
/// The flange's inner face is the plane y = 2.5 and its outer face y = 0; it stands from z = 5.
|
||||
fn plate_with_flange(s: &mut Session) {
|
||||
base(s, 100.0, 60.0);
|
||||
run(s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
|
||||
}
|
||||
|
||||
/// A circle sketch on `plane`, finished.
|
||||
fn circle_sketch(s: &mut Session, name: &str, plane: Value, center: [f64; 2], diameter: f64) -> Value {
|
||||
let made = run(s, "sketch.create", json!({"plane": plane, "name": name}));
|
||||
run(s, "sketch.circle.center", json!({"center": center, "diameter": diameter}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
made
|
||||
}
|
||||
|
||||
fn cut_through(s: &mut Session, sketch: &str) {
|
||||
run(s, "solid.extrude", json!({"sketch": sketch, "through_all": true, "direction": "negative", "operation": "cut"}));
|
||||
}
|
||||
|
||||
fn contains(s: &Session, p: [f64; 3]) -> bool {
|
||||
s.model.state().bodies.first().is_some_and(|b| b.body.contains(solidcraft_geom::Vec3::new(p[0], p[1], p[2])))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sketch_on_a_flange_face_attaches_to_its_panel() {
|
||||
// Sketch coordinates on a sheet face are the flat pattern's: the flange panel starts at
|
||||
// y = 5 − BA and runs on toward −y, so a hole 7 mm up the face is at y = 5 − BA − 7.
|
||||
let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5);
|
||||
let hole = PI * 1.8 * 1.8 * 2.5;
|
||||
let mut s = Session::default();
|
||||
plate_with_flange(&mut s);
|
||||
let v0 = volume(&mut s);
|
||||
let made = circle_sketch(&mut s, "Holes", json!({"face": [50, 2.5, 12]}), [30.0, 5.0 - ba - 7.0], 3.6);
|
||||
assert_eq!(made["attachment"], "sheet_face");
|
||||
// The sketch keeps the face's name: the body, the panel (its flange) and the side of the sheet.
|
||||
let sketch = s.doc.find_feature("Holes").cloned().unwrap();
|
||||
let solidcraft_doc::FeatureKind::Sketch { plane, .. } = &sketch.kind else { panic!("{:?}", sketch.kind) };
|
||||
let name = solidcraft_doc::sheet::plane_sheet_face(plane).unwrap_or_default();
|
||||
assert!(name.starts_with("sheet:Body1:F") && name.ends_with(":top"), "{name}");
|
||||
// A cut through the flange from that sketch is a round hole 7 mm up the face, at x = 30.
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Holes", "distance": 2.5, "direction": "negative", "operation": "cut"}));
|
||||
assert!((v0 - volume(&mut s) - hole).abs() < 0.1, "{}", v0 - volume(&mut s));
|
||||
assert!(!contains(&s, [30.0, 1.25, 12.0]) && contains(&s, [30.0, 1.25, 14.5]) && contains(&s, [33.0, 1.25, 12.0]));
|
||||
let f = flat(&mut s);
|
||||
let cut = f["cutouts"][0].as_array().cloned().unwrap_or_default();
|
||||
assert!(!cut.is_empty(), "{f}");
|
||||
for p in &cut {
|
||||
let (x, y) = (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN));
|
||||
assert!(((x - 30.0).hypot(y - (5.0 - ba - 7.0)) - 1.8).abs() < 1e-3, "({x}, {y})");
|
||||
}
|
||||
// `fixed: true` still freezes the picked plane instead of attaching to the panel.
|
||||
let frozen = circle_sketch(&mut s, "Frozen", json!({"face": [50, 2.5, 12], "fixed": true}), [0.0, 0.0], 1.0);
|
||||
assert_eq!(frozen["attachment"], "fixed");
|
||||
}
|
||||
|
||||
/// The thickness of a sheet comes from a parameter: the flange's face moves with it, and a
|
||||
/// sketch frozen on the old plane cuts a blind pocket in the thicker sheet. The attached
|
||||
/// sketch cuts the same hole as one drawn on the thicker sheet in the first place.
|
||||
#[test]
|
||||
fn a_sketch_on_a_sheet_face_follows_the_sheet_when_its_thickness_changes() {
|
||||
let build = |t: f64, then: Option<f64>| {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "T", "expression": format!("{t} mm")}));
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "AL", "thickness": "T", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, t]], "height": 20}));
|
||||
// The hole is drawn 10 mm up the 2 mm flange, in the flat pattern: its bend ends at
|
||||
// y = T + R = 3 and the bend allowance is (R + T/2)·π/2 = π. The coordinates stay as they are.
|
||||
let flat_y = 3.0 - PI - 10.0;
|
||||
circle_sketch(&mut s, "Holes", json!({"face": [50, t, 12]}), [30.0, flat_y], 6.0);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [10, 30, t]}, "offset": "5 mm", "name": "Above"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let v0 = volume(&mut s);
|
||||
cut_through(&mut s, "Holes");
|
||||
if let Some(t2) = then {
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "T", "expression": format!("{t2} mm")}));
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
}
|
||||
(s, v0)
|
||||
};
|
||||
let (mut grown, _) = build(2.0, Some(3.0));
|
||||
let (mut direct, v_direct) = build(3.0, None);
|
||||
let (vg, vd) = (volume(&mut grown), volume(&mut direct));
|
||||
// The hole is the cylinder Ø6 × 3 through the thicker sheet, as if drawn there at first.
|
||||
assert!((v_direct - vd - PI * 9.0 * 3.0).abs() < 0.1, "{}", v_direct - vd);
|
||||
assert!(rel(vg, vd) < 1e-9, "{vg} {vd}");
|
||||
let (fg, fd) = (flat(&mut grown), flat(&mut direct));
|
||||
assert_eq!(fg["pockets"], json!([]), "a blind pocket instead of the hole: {fg}");
|
||||
assert_eq!(fg["cutouts"], fd["cutouts"]);
|
||||
// The thicker flange's panel starts at flat y = 4 − 3.927 and z = T + R = 4, so the hole is
|
||||
// 10.215 mm up it: through the whole 3 mm.
|
||||
assert!(!contains(&grown, [30.0, 1.5, 14.2]) && !contains(&grown, [30.0, 0.2, 14.2]) && contains(&grown, [30.0, 1.5, 19.0]));
|
||||
// The sketch offset from the base's top face moved up with it: 3 + 5.
|
||||
let plane = run(&mut grown, "sketch.inspect", json!({"sketch": "Above"}))["plane"]["origin"][2].as_f64().unwrap_or(f64::NAN);
|
||||
assert!((plane - 8.0).abs() < 1e-9, "{plane}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketches_on_the_top_and_bottom_faces_of_a_sheet_survive_unfold_refold_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
// The top face's coordinates are the base sketch's; the bottom face's mirror y, as seen from below.
|
||||
circle_sketch(&mut s, "Top", json!({"face": [30, 20, 2.5]}), [30.0, 20.0], 4.0);
|
||||
circle_sketch(&mut s, "Bottom", json!({"face": [60, 20, 0]}), [60.0, -20.0], 4.0);
|
||||
cut_through(&mut s, "Top");
|
||||
cut_through(&mut s, "Bottom");
|
||||
let want = 15000.0 - 2.0 * PI * 4.0 * 2.5;
|
||||
assert!((volume(&mut s) - want).abs() < 0.1, "{}", volume(&mut s));
|
||||
let holes = |f: &Value| {
|
||||
let mut centres: Vec<(f64, f64)> = f["cutouts"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let pts: Vec<(f64, f64)> = c
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|p| (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN)))
|
||||
.collect();
|
||||
let n = pts.len().max(1) as f64;
|
||||
(pts.iter().map(|p| p.0).sum::<f64>() / n, pts.iter().map(|p| p.1).sum::<f64>() / n)
|
||||
})
|
||||
.collect();
|
||||
centres.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
centres
|
||||
};
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
let centres = holes(&flat(&mut s));
|
||||
assert_eq!(centres.len(), 2, "{centres:?}");
|
||||
assert!((centres[0].0 - 30.0).abs() < 0.01 && (centres[0].1 - 20.0).abs() < 0.01, "{centres:?}");
|
||||
assert!((centres[1].0 - 60.0).abs() < 0.01 && (centres[1].1 - 20.0).abs() < 0.01, "{centres:?}");
|
||||
assert!((volume(&mut s) - want).abs() < 0.1);
|
||||
}
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((volume(&mut reopened) - want).abs() < 0.1);
|
||||
assert_eq!(holes(&flat(&mut reopened)), holes(&flat(&mut s)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sketch_made_on_the_unfolded_sheet_lands_on_the_flange_after_refolding() {
|
||||
let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5);
|
||||
let mut s = Session::default();
|
||||
plate_with_flange(&mut s);
|
||||
let v0 = volume(&mut s);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
// Unfolded, the flange lies in the sheet's plane: the one flat face covers it too.
|
||||
let y = 5.0 - ba - 7.0;
|
||||
circle_sketch(&mut s, "Holes", json!({"face": [30, y, 2.5]}), [30.0, y], 3.6);
|
||||
cut_through(&mut s, "Holes");
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((v0 - volume(&mut s) - PI * 1.8 * 1.8 * 2.5).abs() < 0.1, "{}", v0 - volume(&mut s));
|
||||
assert!(!contains(&s, [30.0, 1.25, 12.0]) && contains(&s, [30.0, 1.25, 14.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sketch_on_a_sheet_face_reports_a_lost_attachment_instead_of_moving_to_another_face() {
|
||||
let mut s = Session::default();
|
||||
plate_with_flange(&mut s);
|
||||
circle_sketch(&mut s, "Holes", json!({"face": [50, 2.5, 12]}), [30.0, -8.0], 3.6);
|
||||
let id = s.doc.find_feature("Holes").map(|f| f.id).unwrap_or(0);
|
||||
let status = |s: &Session| s.placed_document().sketch_face_attachment(id, &s.model);
|
||||
assert!(matches!(status(&s), solidcraft_doc::FaceAttachmentStatus::Sheet { plane: Some(_) }));
|
||||
// With the flange suppressed there is no such face; the base's top face is not a substitute.
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "EdgeFlange1", "suppressed": true}));
|
||||
let error = run(&mut s, "timeline.inspect", json!({"feature": "Holes"}))["error"].as_str().unwrap_or_default().to_string();
|
||||
assert!(error.contains("lost face attachment") && error.contains("reattach"), "{error}");
|
||||
let solidcraft_doc::FaceAttachmentStatus::Lost { message, repair_plane } = status(&s) else { panic!("{:?}", status(&s)) };
|
||||
assert!(message.contains("sheet face") && repair_plane.is_some(), "{message}");
|
||||
// Redefine Sketch Plane repairs it on another face, here the base's top (its sketch keeps its geometry).
|
||||
let repaired = run(&mut s, "sketch.redefine", json!({"sketch": "Holes", "plane": {"face": [10, 30, 2.5]}}));
|
||||
assert_eq!(repaired["attachment"], "sheet_face");
|
||||
assert!(run(&mut s, "timeline.inspect", json!({"feature": "Holes"}))["error"].is_null());
|
||||
assert!(matches!(status(&s), solidcraft_doc::FaceAttachmentStatus::Sheet { .. }));
|
||||
// The flange is back, and a sketch attached to it again is healthy.
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "EdgeFlange1", "suppressed": false}));
|
||||
let again = run(&mut s, "sketch.redefine", json!({"sketch": "Holes", "plane": {"face": [50, 2.5, 12]}}));
|
||||
assert_eq!(again["attachment"], "sheet_face");
|
||||
assert!(run(&mut s, "timeline.inspect", json!({"feature": "Holes"}))["error"].is_null());
|
||||
assert!(matches!(status(&s), solidcraft_doc::FaceAttachmentStatus::Sheet { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_sheet_body_keeps_its_sketches_on_their_faces() {
|
||||
let mut s = Session::default();
|
||||
plate_with_flange(&mut s);
|
||||
let v0 = volume(&mut s);
|
||||
circle_sketch(&mut s, "Holes", json!({"face": [50, 2.5, 12]}), [30.0, -8.0], 3.6);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [10, 30, 2.5]}, "offset": "5 mm", "name": "Above"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "body.rename", json!({"body": "Body1", "name": "Bracket"}));
|
||||
for (sketch, face) in [("Holes", "sheet:Bracket:F"), ("Above", "sheet:Bracket:base:top")] {
|
||||
let feature = s.doc.find_feature(sketch).cloned().unwrap();
|
||||
let solidcraft_doc::FeatureKind::Sketch { plane, .. } = &feature.kind else { panic!("{:?}", feature.kind) };
|
||||
assert!(solidcraft_doc::sheet::plane_sheet_face(plane).is_some_and(|name| name.starts_with(face)), "{sketch}: {:?}", feature.kind);
|
||||
assert!(run(&mut s, "timeline.inspect", json!({"feature": sketch}))["error"].is_null(), "{sketch}");
|
||||
}
|
||||
cut_through(&mut s, "Holes");
|
||||
assert!((v0 - volume(&mut s) - PI * 1.8 * 1.8 * 2.5).abs() < 0.1, "{}", v0 - volume(&mut s));
|
||||
}
|
||||
|
||||
/// A tray shrunk by a parameter change unfolds like the same tray built at that size. The
|
||||
/// sketch solved again leaves its corners ~1e-13 mm off, and the flat pattern's planar
|
||||
/// arrangement used to drop a bend zone through a gap that small: "planar region arrangement
|
||||
@@ -1865,3 +2071,302 @@ fn corners_left_open_are_reported_on_the_flange() {
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!(volume(&mut s) > 0.0);
|
||||
}
|
||||
|
||||
/// 2 mm aluminium (R 1, K 0.5, gap 0.5) and a 120 x 90 base flange.
|
||||
fn alu_base(s: &mut Session) {
|
||||
run(
|
||||
s,
|
||||
"sheet.manage_rules",
|
||||
json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": "0.5 mm", "active": true}),
|
||||
);
|
||||
base(s, 120.0, 90.0);
|
||||
}
|
||||
|
||||
/// The base's four edges (bottom, right, top, left) in one feature: 75° and 38.6 high from the
|
||||
/// feature, then 90°, 105° at 30 high and 120° at 25 high from the edges themselves.
|
||||
fn four_angles_in_one_feature() -> Session {
|
||||
let mut s = Session::default();
|
||||
alu_base(&mut s);
|
||||
let edges = json!([
|
||||
[60, 0, 2],
|
||||
{"point": [120, 45, 2], "angle": "90 deg"},
|
||||
{"point": [60, 90, 2], "angle": 105, "height": "30 mm"},
|
||||
{"point": {"x": 0, "y": 45, "z": 2}, "angle": "120 deg", "height": 25}
|
||||
]);
|
||||
run(&mut s, "sheet.flange", json!({"edges": edges, "height": 38.6, "angle": "75 deg"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The flat outline's points.
|
||||
fn outline_points(f: &Value) -> Vec<(f64, f64)> {
|
||||
let loops = f["outline"].as_array().cloned().unwrap_or_default();
|
||||
loops
|
||||
.iter()
|
||||
.flat_map(|l| l.as_array().cloned().unwrap_or_default())
|
||||
.map(|q| (q[0].as_f64().unwrap_or(f64::NAN), q[1].as_f64().unwrap_or(f64::NAN)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Two point lists are the same set of points, to 1e-6.
|
||||
fn same_points(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
|
||||
a.len() == b.len() && a.iter().all(|p| b.iter().any(|q| (p.0 - q.0).abs() < 1e-6 && (p.1 - q.1).abs() < 1e-6))
|
||||
}
|
||||
|
||||
fn bend_angles(s: &mut Session) -> Vec<f64> {
|
||||
flat(s)["bends"].as_array().cloned().unwrap_or_default().iter().map(|b| b["angle_deg"].as_f64().unwrap_or(f64::NAN).round()).collect()
|
||||
}
|
||||
|
||||
fn no_errors_or_warnings(s: &Session) -> bool {
|
||||
s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none())
|
||||
}
|
||||
|
||||
/// The flanges of [`four_angles_in_one_feature`] (bottom, right, top, left): angles in degrees
|
||||
/// and heights.
|
||||
const FOUR_ANGLES: [f64; 4] = [75.0, 90.0, 105.0, 120.0];
|
||||
const FOUR_HEIGHTS: [f64; 4] = [38.6, 38.6, 30.0, 25.0];
|
||||
|
||||
/// That tray's panels in the flat pattern by hand (T 2, R 1, K 0.5, gap g 0.5; the derivation
|
||||
/// is with the doc tests). Folded, a flange at θ whose neighbour at a corner is at φ ends
|
||||
/// x(w) = ((g/2)·√(2 − 2·cosθ·cosφ) + T·(1 − cosθ·cosφ) − w·sinθ·cosφ) / sinφ along its edge from
|
||||
/// that corner, w up its outer face from the virtual sharp. In the flat that point lies
|
||||
/// s0 − BA − (w − s0) into the base from the edge, with s0 = (R + T)·tan(θ/2) the setback and
|
||||
/// BA = θ·(R + K·T) the bend allowance; the panel spans w = s0 … the flange's height.
|
||||
fn hand_panels() -> Vec<Vec<(f64, f64)>> {
|
||||
// Each edge's start corner, its direction, the direction into the base, and its length.
|
||||
let frames: [((f64, f64), (f64, f64), (f64, f64), f64); 4] = [
|
||||
((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), 120.0),
|
||||
((120.0, 0.0), (0.0, 1.0), (-1.0, 0.0), 90.0),
|
||||
((120.0, 90.0), (-1.0, 0.0), (0.0, -1.0), 120.0),
|
||||
((0.0, 90.0), (0.0, -1.0), (1.0, 0.0), 90.0),
|
||||
];
|
||||
(0..4)
|
||||
.map(|i| {
|
||||
let (o, d, v, len) = frames[i];
|
||||
let (th, h) = (FOUR_ANGLES[i].to_radians(), FOUR_HEIGHTS[i]);
|
||||
let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0);
|
||||
let x_end = |phi: f64, w: f64| {
|
||||
let c = th.cos() * phi.cos();
|
||||
(0.25 * (2.0 - 2.0 * c).sqrt() + 2.0 * (1.0 - c) - w * th.sin() * phi.cos()) / phi.sin()
|
||||
};
|
||||
let (prev, next) = (FOUR_ANGLES[(i + 3) % 4].to_radians(), FOUR_ANGLES[(i + 1) % 4].to_radians());
|
||||
let at = |u: f64, w: f64| {
|
||||
let into = s0 - ba - (w - s0);
|
||||
(o.0 + d.0 * u + v.0 * into, o.1 + d.1 * u + v.1 * into)
|
||||
};
|
||||
vec![at(x_end(prev, s0), s0), at(x_end(prev, h), h), at(len - x_end(next, h), h), at(len - x_end(next, s0), s0)]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A polygon's area.
|
||||
fn polygon_area(p: &[(f64, f64)]) -> f64 {
|
||||
let n = p.len();
|
||||
(0..n).map(|i| p[i].0 * p[(i + 1) % n].1 - p[(i + 1) % n].0 * p[i].1).sum::<f64>().abs() / 2.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_flanges_take_their_own_angle_and_height_per_edge() {
|
||||
let mut one = four_angles_in_one_feature();
|
||||
assert!(no_errors_or_warnings(&one), "{:?}", one.model.results);
|
||||
let f1 = flat(&mut one);
|
||||
// The bends: each edge's own angle, as long as the base's edge between its neighbours' bend
|
||||
// start lines (each s0 in from the original edge).
|
||||
assert_eq!(bend_angles(&mut one), FOUR_ANGLES);
|
||||
let s0 = FOUR_ANGLES.map(|a| 3.0 * (a.to_radians() / 2.0).tan());
|
||||
let ba = FOUR_ANGLES.map(|a| a.to_radians() * 2.0);
|
||||
let (w, h) = (120.0 - s0[3] - s0[1], 90.0 - s0[0] - s0[2]);
|
||||
let lengths: Vec<f64> = f1["bends"].as_array().cloned().unwrap_or_default().iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect();
|
||||
assert_eq!(lengths.len(), 4, "{f1}");
|
||||
for (got, want) in lengths.iter().zip([w, h, w, h]) {
|
||||
assert!((got - want).abs() < 1e-6, "{lengths:?} vs {w} and {h}");
|
||||
}
|
||||
// The flat pattern is one outline through every hand-derived panel corner.
|
||||
let (outline, panels) = (outline_points(&f1), hand_panels());
|
||||
assert_eq!(f1["outline"].as_array().map(Vec::len), Some(1), "{f1}");
|
||||
for (i, panel) in panels.iter().enumerate() {
|
||||
for &(x, y) in panel {
|
||||
assert!(outline.iter().any(|p| (p.0 - x).abs() < 1e-6 && (p.1 - y).abs() < 1e-6), "flange {i}: ({x}, {y}) not in {outline:?}");
|
||||
}
|
||||
}
|
||||
// It holds the base, the four bend zones and the four panels, nothing more or less, and its
|
||||
// size is the panels' extent.
|
||||
let area = w * h + (ba[0] + ba[2]) * w + (ba[1] + ba[3]) * h + panels.iter().map(|p| polygon_area(p)).sum::<f64>();
|
||||
assert!(rel(polygon_area(&outline), area) < 1e-9, "{} vs {area}", polygon_area(&outline));
|
||||
let all = panels.concat();
|
||||
let span = |k: fn(&(f64, f64)) -> f64| all.iter().map(k).fold(f64::MIN, f64::max) - all.iter().map(k).fold(f64::MAX, f64::min);
|
||||
for (k, want) in [span(|p| p.0), span(|p| p.1), 2.0].into_iter().enumerate() {
|
||||
assert!((f1["flat_size_mm"][k].as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{}: {want}", f1["flat_size_mm"]);
|
||||
}
|
||||
// Folded, the part holds that material (K = 0.5 keeps the volume).
|
||||
let v1 = volume(&mut one);
|
||||
assert!(rel(v1, area * 2.0) < 1e-3, "{v1} vs {}", area * 2.0);
|
||||
// The DXF: the outline in straight lines, and four bend lines.
|
||||
let dxf = run(&mut one, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
|
||||
assert_eq!(dxf.matches("0\nLINE\n").count(), outline.len() + 4, "{} outline points", outline.len());
|
||||
// Unfold and refold keep it.
|
||||
run(&mut one, "sheet.unfold", json!({}));
|
||||
assert!(same_points(&outline_points(&flat(&mut one)), &outline_points(&f1)));
|
||||
run(&mut one, "sheet.refold", json!({}));
|
||||
assert!(rel(volume(&mut one), v1) < 1e-9);
|
||||
// Saved, an edge with its own values is an object and one without is the point it always was.
|
||||
let text = one.doc.to_json();
|
||||
let saved: Value = serde_json::from_str(&text).unwrap();
|
||||
let flange = saved["features"]
|
||||
.as_array()
|
||||
.and_then(|f| f.iter().find(|f| f["type"] == "sheet_flange" && f["edges"].as_array().is_some_and(|e| e.len() == 4)));
|
||||
assert_eq!(
|
||||
flange.map(|f| f["edges"].clone()),
|
||||
Some(json!([
|
||||
[60.0, 0.0, 2.0],
|
||||
{"point": [120.0, 45.0, 2.0], "angle": "90 deg"},
|
||||
{"point": [60.0, 90.0, 2.0], "angle": "105", "height": "30 mm"},
|
||||
{"point": [0.0, 45.0, 2.0], "angle": "120 deg", "height": "25"}
|
||||
])),
|
||||
"{saved}"
|
||||
);
|
||||
// Reopened, it is the same part.
|
||||
let mut again = Session::new(solidcraft_doc::Document::from_json(&text).unwrap());
|
||||
assert!(no_errors_or_warnings(&again), "{:?}", again.model.results);
|
||||
assert!(rel(volume(&mut again), v1) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_edge_values_are_expressions_and_feature_inputs() {
|
||||
let build = |angle: &str, height: &str| {
|
||||
let mut s = Session::default();
|
||||
alu_base(&mut s);
|
||||
run(&mut s, "parameters.add", json!({"name": "wall_angle", "expression": "105 deg"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "wall_height", "expression": "30 mm"}));
|
||||
let edges = json!([[60, 0, 2], {"point": [120, 45, 2], "angle": "90 deg"}, {"point": [60, 90, 2], "angle": angle, "height": height}]);
|
||||
run(&mut s, "sheet.flange", json!({"edges": edges, "height": 38.6, "angle": "75 deg"}));
|
||||
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
|
||||
s
|
||||
};
|
||||
let part = |s: &mut Session| (volume(s), outline_points(&flat(s)));
|
||||
let mut s = build("wall_angle", "wall_height");
|
||||
// The feature's inputs: its own, then each edge's, in the order of the edges.
|
||||
let labels: Vec<String> =
|
||||
s.doc.features.last().map(|f| f.kind.inputs().into_iter().map(|(l, e, _)| format!("{l}={e}")).collect()).unwrap_or_default();
|
||||
assert_eq!(labels, ["Height=38.6", "Angle=75 deg", "Edge angle=90 deg", "Edge angle=wall_angle", "Edge height=wall_height"]);
|
||||
// Changing the user parameters moves that flange.
|
||||
run(&mut s, "parameters.change", json!({"name": "wall_angle", "expression": "110 deg"}));
|
||||
run(&mut s, "parameters.change", json!({"name": "wall_height", "expression": "34 mm"}));
|
||||
let (v, outline) = part(&mut build("110 deg", "34 mm"));
|
||||
assert!(rel(volume(&mut s), v) < 1e-9, "{} vs {v}", volume(&mut s));
|
||||
assert!(same_points(&outline_points(&flat(&mut s)), &outline));
|
||||
// So does the feature's own input for an edge, by its parameter name (the last two).
|
||||
let names = s.doc.features.last().map(|f| f.param_names.clone()).unwrap_or_default();
|
||||
assert_eq!(names.len(), 5, "{names:?}");
|
||||
run(&mut s, "parameters.change", json!({"name": names[3], "expression": "100 deg"}));
|
||||
run(&mut s, "parameters.change", json!({"name": names[4], "expression": "36 mm"}));
|
||||
let (v, outline) = part(&mut build("100 deg", "36 mm"));
|
||||
assert!(rel(volume(&mut s), v) < 1e-9, "{} vs {v}", volume(&mut s));
|
||||
assert!(same_points(&outline_points(&flat(&mut s)), &outline));
|
||||
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_per_edge_input_is_refused_and_plain_edges_stay_plain() {
|
||||
let mut s = Session::default();
|
||||
alu_base(&mut s);
|
||||
let err = |s: &mut Session, edges: Value| {
|
||||
s.execute("sheet.flange", &json!({"edges": edges, "height": 38.6, "angle": "90 deg"})).err().map(|e| e.to_string()).unwrap_or_default()
|
||||
};
|
||||
// The wrong kind of value, or no expression at all: the edge and the key are named.
|
||||
let e = err(&mut s, json!([[60, 0, 2], {"point": [120, 45, 2], "angle": "10 mm"}]));
|
||||
assert!(e.contains("edges[1].angle"), "{e}");
|
||||
let e = err(&mut s, json!([{"point": [60, 0, 2], "height": "30 deg"}]));
|
||||
assert!(e.contains("edges[0].height"), "{e}");
|
||||
let e = err(&mut s, json!([{"point": [60, 0, 2], "angle": "nope"}]));
|
||||
assert!(e.contains("edges[0].angle"), "{e}");
|
||||
let e = err(&mut s, json!([{"point": [60, 0, 2], "angle": [1, 2]}]));
|
||||
assert!(e.contains("edges[0]") && e.contains("`angle` must be a number or an expression"), "{e}");
|
||||
// An unknown key, a point that is none, an angle without `point`, something that is no edge.
|
||||
let e = err(&mut s, json!([{"point": [60, 0, 2], "twist": 3}]));
|
||||
assert!(e.contains("edges[0]") && e.contains("unknown key `twist`"), "{e}");
|
||||
let e = err(&mut s, json!([{"point": [60, 0], "angle": "60 deg"}]));
|
||||
assert!(e.contains("edges[0]") && e.contains("`point` must be [x, y, z]"), "{e}");
|
||||
let e = err(&mut s, json!([{"x": 60, "y": 0, "z": 2, "angle": "60 deg"}]));
|
||||
assert!(e.contains("edges[0]") && e.contains("unknown key"), "{e}");
|
||||
let e = err(&mut s, json!([{"angle": "60 deg"}]));
|
||||
assert!(e.contains("edges[0]") && e.contains("`point` must be [x, y, z]"), "{e}");
|
||||
let e = err(&mut s, json!(["edge"]));
|
||||
assert!(e.contains("edges[0]"), "{e}");
|
||||
let e = err(&mut s, json!([]));
|
||||
assert!(e.contains("1…1000"), "{e}");
|
||||
// Evaluated: an angle outside 0…180° and a height under the bend's, each naming its edge.
|
||||
let e = err(&mut s, json!([[60, 0, 2], {"point": [120, 45, 2], "angle": "200 deg"}]));
|
||||
assert!(e.contains("edge 2") && e.contains("angle must be between 0 and 180"), "{e}");
|
||||
let e = err(&mut s, json!([{"point": [60, 0, 2], "height": "2 mm"}, [120, 45, 2]]));
|
||||
assert!(e.contains("edge 1") && e.contains("height must be at least"), "{e}");
|
||||
// None of it added a feature.
|
||||
assert_eq!(s.doc.features.len(), 2, "{:?}", s.doc.features.iter().map(|f| f.name.clone()).collect::<Vec<_>>());
|
||||
// Plain points, `{x, y, z}` points and `{point}` without values are saved as the points they were.
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[60, 0, 2], {"x": 120, "y": 45, "z": 2}, {"point": [60, 90, 2]}], "height": 20, "angle": "90 deg"}));
|
||||
let edges = serde_json::to_value(s.doc.features.last().map(|f| f.kind.clone())).unwrap()["edges"].clone();
|
||||
assert_eq!(edges, json!([[60.0, 0.0, 2.0], [120.0, 45.0, 2.0], [60.0, 90.0, 2.0]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_flange_keeps_each_edges_own_values_with_its_edge() {
|
||||
let mut s = four_angles_in_one_feature();
|
||||
let id = s.doc.features.last().map(|f| f.id).unwrap_or(0);
|
||||
// Give the first edge 80° and take the 90° off the second.
|
||||
let edges = json!([
|
||||
{"point": [60, 0, 2], "angle": "80 deg"},
|
||||
[120, 45, 2],
|
||||
{"point": [60, 90, 2], "angle": "105 deg", "height": "30 mm"},
|
||||
{"point": [0, 45, 2], "angle": "120 deg", "height": "25 mm"}
|
||||
]);
|
||||
run(&mut s, "timeline.edit", json!({"feature": id, "set": {"edges": edges}}));
|
||||
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
|
||||
assert_eq!(bend_angles(&mut s), [80.0, 75.0, 105.0, 120.0]);
|
||||
let kind = |s: &Session| serde_json::to_value(s.doc.features.last().map(|f| f.kind.clone())).unwrap();
|
||||
assert_eq!(kind(&s)["edges"][1], json!([120.0, 45.0, 2.0]), "no value of its own: a plain point again");
|
||||
let (v, outline) = (volume(&mut s), outline_points(&flat(&mut s)));
|
||||
// The same edges in another order are the same part: each value follows its edge.
|
||||
let edges = json!([
|
||||
{"point": [0, 45, 2], "angle": "120 deg", "height": "25 mm"},
|
||||
{"point": [60, 90, 2], "angle": "105 deg", "height": "30 mm"},
|
||||
{"point": [60, 0, 2], "angle": "80 deg"},
|
||||
[120, 45, 2]
|
||||
]);
|
||||
run(&mut s, "timeline.edit", json!({"feature": id, "set": {"edges": edges}}));
|
||||
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
|
||||
assert_eq!(bend_angles(&mut s), [120.0, 105.0, 80.0, 75.0]);
|
||||
assert!(rel(volume(&mut s), v) < 1e-9, "{} vs {v}", volume(&mut s));
|
||||
assert!(same_points(&outline_points(&flat(&mut s)), &outline));
|
||||
// A bad edit is refused and leaves the feature as it was.
|
||||
let before = kind(&s);
|
||||
assert!(s.execute("timeline.edit", &json!({"feature": id, "set": {"edges": [{"point": [60, 0, 2], "angle": "80 deg", "colour": 1}]}})).is_err());
|
||||
assert!(s.execute("timeline.edit", &json!({"feature": id, "set": {"edges": [{"point": [60, 0, 2], "angle": "250 deg"}]}})).is_err());
|
||||
assert_eq!(kind(&s), before);
|
||||
assert_eq!(bend_angles(&mut s), [120.0, 105.0, 80.0, 75.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn designs_without_per_edge_values_are_read_and_written_as_before() {
|
||||
let mut s = Session::default();
|
||||
alu_base(&mut s);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[60, 0, 2], [120, 45, 2], [60, 90, 2], [0, 45, 2]], "height": 38.6, "angle": "72 deg"}));
|
||||
let v = volume(&mut s);
|
||||
let text = s.doc.to_json();
|
||||
let mut design: Value = serde_json::from_str(&text).unwrap();
|
||||
let flange = design["features"].as_array().and_then(|f| f.iter().position(|f| f["type"] == "sheet_flange")).unwrap();
|
||||
assert_eq!(design["features"][flange]["edges"], json!([[60.0, 0.0, 2.0], [120.0, 45.0, 2.0], [60.0, 90.0, 2.0], [0.0, 45.0, 2.0]]));
|
||||
let mut same = Session::new(solidcraft_doc::Document::from_json(&text).unwrap());
|
||||
assert!(rel(volume(&mut same), v) < 1e-12);
|
||||
// A design file whose last edge carries its own angle is the part the command makes.
|
||||
design["features"][flange]["edges"][3] = json!({"point": [0.0, 45.0, 2.0], "angle": "100 deg"});
|
||||
let mut opened = Session::new(solidcraft_doc::Document::from_json(&design.to_string()).unwrap());
|
||||
assert!(no_errors_or_warnings(&opened), "{:?}", opened.model.results);
|
||||
let mut made = Session::default();
|
||||
alu_base(&mut made);
|
||||
let edges = json!([[60, 0, 2], [120, 45, 2], [60, 90, 2], {"point": [0, 45, 2], "angle": "100 deg"}]);
|
||||
run(&mut made, "sheet.flange", json!({"edges": edges, "height": 38.6, "angle": "72 deg"}));
|
||||
let (vo, vm) = (volume(&mut opened), volume(&mut made));
|
||||
assert!(rel(vo, vm) < 1e-9 && rel(vo, v) > 1e-4, "{vo} vs {vm} vs {v}");
|
||||
// A key this build doesn't know is refused, not dropped.
|
||||
design["features"][flange]["edges"][3] = json!({"point": [0.0, 45.0, 2.0], "twist": "3 deg"});
|
||||
assert!(solidcraft_doc::Document::from_json(&design.to_string()).is_err());
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sketch")
|
||||
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source, fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"),
|
||||
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source (a flat face of a sheet metal body attaches to its panel instead, in the flat pattern's coordinates), fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"),
|
||||
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"),
|
||||
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
|
||||
CommandSpec::new("sketch.line", "Line", draw_line)
|
||||
@@ -117,12 +117,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_coincident")
|
||||
.enabled(in_sketch)
|
||||
.params("a, b: points, a point and a curve, or two distinct lines (joins their closest endpoints)"),
|
||||
.params("a, b: points, a point and a curve or sketch axis (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created), or two distinct lines (joins their closest endpoints)"),
|
||||
CommandSpec::new("sketch.constraint.tangent", "Tangent", c_tangent)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_tangent")
|
||||
.enabled(in_sketch)
|
||||
.params("a, b: curves"),
|
||||
.params("a, b: curves or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
|
||||
CommandSpec::new("sketch.constraint.equal", "Equal", c_equal)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_equal")
|
||||
@@ -132,12 +132,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_parallel")
|
||||
.enabled(in_sketch)
|
||||
.params("a, b: lines"),
|
||||
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
|
||||
CommandSpec::new("sketch.constraint.perpendicular", "Perpendicular", c_perpendicular)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_perpendicular")
|
||||
.enabled(in_sketch)
|
||||
.params("a, b: lines"),
|
||||
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
|
||||
CommandSpec::new("sketch.constraint.fix", "Fix/UnFix", c_fix)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_fix")
|
||||
@@ -157,12 +157,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_collinear")
|
||||
.enabled(in_sketch)
|
||||
.params("a, b: lines"),
|
||||
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
|
||||
CommandSpec::new("sketch.constraint.symmetry", "Symmetry", c_symmetry)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_symmetry")
|
||||
.enabled(in_sketch)
|
||||
.params("a, b: points, line: symmetry line"),
|
||||
.params("a, b: two points or two curves (lines mirror their ends, circles/arcs their centres); line: symmetry line id, or x / y for the sketch's own axes (a curve with that id wins; an axis becomes a linked construction line on it, reported in created)"),
|
||||
CommandSpec::new("sketch.construction", "Normal/Construction", construction)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("construction")
|
||||
@@ -454,6 +454,22 @@ pub(super) fn curve_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) -
|
||||
sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown sketch curve `{id}`")))
|
||||
}
|
||||
|
||||
/// A line a constraint refers to: a curve id, or `x` / `y` for the sketch's own axes (a curve
|
||||
/// with that id wins). An axis becomes the sketch's linked construction line on it
|
||||
/// ([`Sketch::sketch_axis_line`]), added on first use and reported in `created`.
|
||||
pub(super) fn line_or_axis_ref(sk: &mut Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result<usize> {
|
||||
let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, format!("`{what}` must be a curve id, or x / y for a sketch axis")))?;
|
||||
curve_or_axis(sk, id)?.ok_or_else(|| bad(cmd, format!("unknown sketch curve `{id}` (x and y name the sketch's own axes)")))
|
||||
}
|
||||
|
||||
/// Curve `id`, or the line of sketch axis `id` (see [`line_or_axis_ref`]); `None` when neither.
|
||||
fn curve_or_axis(sk: &mut Sketch, id: &str) -> Result<Option<usize>> {
|
||||
match sk.curve_index(id) {
|
||||
Some(c) => Ok(Some(c)),
|
||||
None => Ok(sk.sketch_axis_line(id)?),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn point_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result<usize> {
|
||||
let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, format!("`{what}` must be a point reference")))?;
|
||||
sk.resolve_point(id).ok_or_else(|| bad(cmd, format!("unknown sketch point `{id}`")))
|
||||
@@ -646,6 +662,8 @@ fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneRef, interna
|
||||
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => Some(if face::anchor(plane).is_some() {
|
||||
"exact_face"
|
||||
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
|
||||
"sheet_face"
|
||||
} else if internal_body.is_some() {
|
||||
"internal_legacy_face"
|
||||
} else {
|
||||
@@ -1154,7 +1172,7 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Constraints
|
||||
|
||||
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&Sketch) -> Result<ConstraintKind>) -> Result<Value> {
|
||||
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch) -> Result<ConstraintKind>) -> Result<Value> {
|
||||
let mut before = None;
|
||||
let (id, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let k = f(sk)?;
|
||||
@@ -1179,7 +1197,7 @@ pub(super) enum Ent {
|
||||
}
|
||||
|
||||
fn ent(sk: &Sketch, r: &str) -> Option<Ent> {
|
||||
sk.resolve_point(r).map(Ent::Point).or_else(|| sk.curve_index(r).map(Ent::Curve))
|
||||
sk.resolve_point(r).map(Ent::Point).or_else(|| sk.curve_index(r).or_else(|| sk.sketch_axis_curve(r)).map(Ent::Curve))
|
||||
}
|
||||
|
||||
/// The entities of a two-entity constraint in the order they were picked (`a` then `b`, or
|
||||
@@ -1372,15 +1390,18 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let aref = p.get("a").and_then(Value::as_str).unwrap_or_default();
|
||||
let bref = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?;
|
||||
// A curve picked first and a point second: the point goes onto the curve.
|
||||
if let (None, Some(c), Some(q)) = (sk.resolve_point(aref), sk.curve_index(aref), sk.resolve_point(bref)) {
|
||||
// A curve (or sketch axis) picked first and a point second: the point goes onto it.
|
||||
if sk.resolve_point(aref).is_none()
|
||||
&& let Some(q) = sk.resolve_point(bref)
|
||||
&& let Some(c) = curve_or_axis(sk, aref)?
|
||||
{
|
||||
return Ok(ConstraintKind::PointOnCurve { p: q, c });
|
||||
}
|
||||
let a = point_ref(sk, p.get("a"), cmd, "a")?;
|
||||
if let Some(b) = sk.resolve_point(bref) {
|
||||
return Ok(ConstraintKind::Coincident { p: a, q: b });
|
||||
}
|
||||
let c = sk.curve_index(bref).ok_or_else(|| bad(cmd, format!("unknown entity `{bref}`")))?;
|
||||
let c = curve_or_axis(sk, bref)?.ok_or_else(|| bad(cmd, format!("unknown entity `{bref}`")))?;
|
||||
Ok(ConstraintKind::PointOnCurve { p: a, c })
|
||||
})
|
||||
}
|
||||
@@ -1420,20 +1441,27 @@ fn two_curves(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> {
|
||||
Ok((curve_ref(sk, p.get("a"), cmd, "a")?, curve_ref(sk, p.get("b"), cmd, "b")?))
|
||||
}
|
||||
|
||||
/// Two curves where either may be a sketch axis (`x` / `y`, see [`line_or_axis_ref`]).
|
||||
fn two_curves_or_axes(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> {
|
||||
Ok((line_or_axis_ref(sk, p.get("a"), cmd, "a")?, line_or_axis_ref(sk, p.get("b"), cmd, "b")?))
|
||||
}
|
||||
|
||||
fn c_tangent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
constrain(s, p, "sketch.constraint.tangent", |sk| two_curves(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b }))
|
||||
constrain(s, p, "sketch.constraint.tangent", |sk| {
|
||||
two_curves_or_axes(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b })
|
||||
})
|
||||
}
|
||||
fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
constrain(s, p, "sketch.constraint.equal", |sk| two_curves(sk, p, "sketch.constraint.equal").map(|(a, b)| ConstraintKind::Equal { a, b }))
|
||||
}
|
||||
fn c_parallel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
constrain(s, p, "sketch.constraint.parallel", |sk| {
|
||||
two_curves(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b })
|
||||
two_curves_or_axes(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b })
|
||||
})
|
||||
}
|
||||
fn c_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
constrain(s, p, "sketch.constraint.perpendicular", |sk| {
|
||||
two_curves(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })
|
||||
two_curves_or_axes(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })
|
||||
})
|
||||
}
|
||||
fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1443,7 +1471,7 @@ fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
fn c_collinear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
constrain(s, p, "sketch.constraint.collinear", |sk| {
|
||||
two_curves(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b })
|
||||
two_curves_or_axes(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b })
|
||||
})
|
||||
}
|
||||
fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1455,7 +1483,7 @@ fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.constraint.symmetry";
|
||||
let (ids, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let l = curve_ref(sk, p.get("line"), cmd, "line")?;
|
||||
let l = line_or_axis_ref(sk, p.get("line"), cmd, "line")?;
|
||||
let ra = p.get("a").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`a` must be a point or curve"))?;
|
||||
let rb = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?;
|
||||
let rep = solve(&mut sk.clone());
|
||||
@@ -2119,6 +2147,9 @@ mod rect_tests;
|
||||
#[path = "sketch_ux_tests.rs"]
|
||||
mod ux_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_axis_ref_tests.rs"]
|
||||
mod axis_ref_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_drag_live_tests.rs"]
|
||||
mod drag_live_tests;
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Constraints that take a line accept `x` / `y` for the sketch's own axes (issue #394): the
|
||||
//! axis becomes one linked construction line on it, shared by every constraint on that axis,
|
||||
//! reported in `created`, and left out of profiles. A real curve with that id wins.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::{Vec2, Vec3};
|
||||
use solidcraft_sketch::{CurveKind, LinkSource, Sketch};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn sketch(s: &Session) -> Sketch {
|
||||
s.doc.sketch(s.active_sketch.unwrap()).unwrap().clone()
|
||||
}
|
||||
|
||||
fn on(plane: Value) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({ "plane": plane }));
|
||||
s
|
||||
}
|
||||
|
||||
fn pt(sk: &Sketch, r: &str) -> Vec2 {
|
||||
sk.point(sk.resolve_point(r).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn strs(v: &Value) -> Vec<String> {
|
||||
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The sketch's axis lines: (axis name, curve id).
|
||||
fn axis_lines(sk: &Sketch) -> Vec<(String, String)> {
|
||||
sk.links
|
||||
.iter()
|
||||
.filter_map(|l| match &l.source {
|
||||
LinkSource::SketchAxis { name } => Some((name.clone(), l.id.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.flat_map(|(name, link)| sk.link_curves(&link).into_iter().map(move |c| (name.clone(), sk.curves[c].id.clone())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_axis_line(sk: &Sketch, id: &str, dir: Vec2) {
|
||||
let c = sk.curve_index(id).unwrap();
|
||||
let curve = &sk.curves[c];
|
||||
assert!(curve.construction, "axis line {id} is construction");
|
||||
assert!(sk.is_linked_curve(c), "axis line {id} is linked");
|
||||
let CurveKind::Line { a, b } = curve.kind else { panic!("axis line {id} is a line") };
|
||||
for p in [sk.point(a).unwrap(), sk.point(b).unwrap()] {
|
||||
assert!(p.cross(dir).abs() < 1e-9, "{id} end {p:?} lies on the axis {dir:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_394_symmetry_about_the_sketch_y_axis() {
|
||||
let mut s = on(json!("XY"));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]}));
|
||||
let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"}));
|
||||
assert!(r["sketch"]["solved"].as_bool().unwrap(), "{r}");
|
||||
let sk = sketch(&s);
|
||||
let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end"));
|
||||
assert!((start.x + end.x).abs() < 1e-7 && (start.y - end.y).abs() < 1e-7, "mirrored about y: {start:?} {end:?}");
|
||||
assert!((start.x - -4.0).abs() < 1e-7, "the first entity stays: {start:?}");
|
||||
let axes = axis_lines(&sk);
|
||||
assert_eq!(axes.len(), 1, "{axes:?}");
|
||||
let (name, line) = &axes[0];
|
||||
assert_eq!(name, "y");
|
||||
assert_axis_line(&sk, line, Vec2::Y);
|
||||
assert_eq!(strs(&r["created"]["curves"]), vec![line.clone()], "the axis line is reported in created: {r}");
|
||||
assert_eq!(strs(&r["created"]["constraints"]).len(), 1, "{r}");
|
||||
|
||||
// A second constraint on the same axis reuses its line.
|
||||
run(&mut s, "sketch.line", json!({"points": [[-3, 5], [8, 5]], "ids": ["b"]}));
|
||||
let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "b.start", "b": "b.end", "line": "y"}));
|
||||
assert!(strs(&r["created"]["curves"]).is_empty(), "{r}");
|
||||
assert_eq!(axis_lines(&sketch(&s)).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_curve_named_like_an_axis_wins() {
|
||||
let mut s = on(json!("XY"));
|
||||
run(&mut s, "sketch.line", json!({"points": [[5, -20], [5, 20]], "ids": ["y"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entity": "y"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [12, 0]], "ids": ["a"]}));
|
||||
run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"}));
|
||||
let sk = sketch(&s);
|
||||
assert!(axis_lines(&sk).is_empty(), "no axis line when a curve is called y");
|
||||
let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end"));
|
||||
assert!((start.x + end.x - 10.0).abs() < 1e-7, "mirrored about x = 5: {start:?} {end:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_centred_on_the_origin_keeps_one_profile() {
|
||||
let mut s = on(json!("XY"));
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [2, 1], "p1": [12, 7]}));
|
||||
let sk = sketch(&s);
|
||||
let lines = strs(&r["curves"]);
|
||||
let dir = |id: &String| {
|
||||
let CurveKind::Line { a, b } = sk.curves[sk.curve_index(id).unwrap()].kind else { panic!() };
|
||||
sk.point(b).unwrap() - sk.point(a).unwrap()
|
||||
};
|
||||
let horizontal = lines.iter().find(|l| dir(l).y.abs() < 1e-9).unwrap().clone();
|
||||
let vertical = lines.iter().find(|l| dir(l).x.abs() < 1e-9).unwrap().clone();
|
||||
run(&mut s, "sketch.constraint.symmetry", json!({"a": format!("{horizontal}.start"), "b": format!("{horizontal}.end"), "line": "y"}));
|
||||
run(&mut s, "sketch.constraint.symmetry", json!({"a": format!("{vertical}.start"), "b": format!("{vertical}.end"), "line": "x"}));
|
||||
let sk = sketch(&s);
|
||||
let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY);
|
||||
let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for l in &lines {
|
||||
for end in ["start", "end"] {
|
||||
let p = pt(&sk, &format!("{l}.{end}"));
|
||||
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
|
||||
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
|
||||
}
|
||||
}
|
||||
assert!((lo.x + hi.x).abs() < 1e-7 && (lo.y + hi.y).abs() < 1e-7, "centred on the origin: {lo:?} {hi:?}");
|
||||
assert_eq!(axis_lines(&sk).len(), 2);
|
||||
// The axis lines cross the rectangle but are construction: still one profile.
|
||||
assert_eq!(run(&mut s, "sketch.solve", json!({}))["profiles"], json!(1));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_axes_on_a_plane_off_the_world_axes() {
|
||||
// A plane whose own axes are none of the world axes: x along world Y, y along world Z,
|
||||
// origin away from the world origin.
|
||||
let origin = Vec3::new(5.0, 7.0, 9.0);
|
||||
let mut s = on(json!({"origin": [5, 7, 9], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[3, -6], [3, 2]], "ids": ["a"]}));
|
||||
let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "x"}));
|
||||
assert!(r["sketch"]["solved"].as_bool().unwrap(), "{r}");
|
||||
let sk = sketch(&s);
|
||||
let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end"));
|
||||
assert!((start.y + end.y).abs() < 1e-7 && (start.x - end.x).abs() < 1e-7, "mirrored about the sketch x axis: {start:?} {end:?}");
|
||||
let axes = axis_lines(&sk);
|
||||
assert_eq!(axes.len(), 1);
|
||||
assert_axis_line(&sk, &axes[0].1, Vec2::X);
|
||||
|
||||
// After a recompute the axis line is still the sketch's x axis, in world space too.
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let st = s.model.state();
|
||||
let id = s.doc.find_feature("Sketch1").map(|f| f.id).unwrap();
|
||||
let ss = st.sketch(id).unwrap();
|
||||
let c = ss.sketch.curve_index(&axes[0].1).unwrap();
|
||||
let CurveKind::Line { a, b } = ss.sketch.curves[c].kind else { panic!() };
|
||||
for p in [a, b] {
|
||||
let w = ss.plane.to_world(ss.sketch.point(p).unwrap()) - origin;
|
||||
assert!(w.x.abs() < 1e-9 && w.z.abs() < 1e-9, "on the plane's x axis (world Y through its origin): {w:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_line_constraints_take_sketch_axes() {
|
||||
let mut s = on(json!("XZ"));
|
||||
run(&mut s, "sketch.line", json!({"points": [[1, 1], [6, 3]], "ids": ["p"]}));
|
||||
run(&mut s, "sketch.constraint.parallel", json!({"a": "p", "b": "x"}));
|
||||
let sk = sketch(&s);
|
||||
assert!((pt(&sk, "p.start").y - pt(&sk, "p.end").y).abs() < 1e-7, "parallel to x");
|
||||
|
||||
run(&mut s, "sketch.line", json!({"points": [[-2, 1], [-1, 6]], "ids": ["q"]}));
|
||||
run(&mut s, "sketch.constraint.perpendicular", json!({"a": "x", "b": "q"}));
|
||||
let sk = sketch(&s);
|
||||
assert!((pt(&sk, "q.start").x - pt(&sk, "q.end").x).abs() < 1e-7, "perpendicular to x");
|
||||
|
||||
run(&mut s, "sketch.line", json!({"points": [[2, -5], [4, -8]], "ids": ["r"]}));
|
||||
run(&mut s, "sketch.constraint.collinear", json!({"a": "r", "b": "y"}));
|
||||
let sk = sketch(&s);
|
||||
assert!(pt(&sk, "r.start").x.abs() < 1e-7 && pt(&sk, "r.end").x.abs() < 1e-7, "on the y axis");
|
||||
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [10, 4], "radius": 2, "id": "c"}));
|
||||
run(&mut s, "sketch.constraint.tangent", json!({"a": "c", "b": "x"}));
|
||||
let sk = sketch(&s);
|
||||
let c = sk.curve_index("c").unwrap();
|
||||
let CurveKind::Circle { c: centre, r } = sk.curves[c].kind else { panic!() };
|
||||
assert!((sk.point(centre).unwrap().y.abs() - r).abs() < 1e-7, "tangent to x");
|
||||
|
||||
// A point onto an axis, either pick order.
|
||||
run(&mut s, "sketch.point", json!({"point": [7, 9], "id": "u"}));
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": "u", "b": "x"}));
|
||||
assert!(pt(&sketch(&s), "u").y.abs() < 1e-7);
|
||||
run(&mut s, "sketch.point", json!({"point": [7, 9], "id": "v"}));
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": "y", "b": "v"}));
|
||||
assert!(pt(&sketch(&s), "v").x.abs() < 1e-7);
|
||||
|
||||
// Still one line per axis.
|
||||
let mut names: Vec<String> = axis_lines(&sketch(&s)).into_iter().map(|(n, _)| n).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["x", "y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_axis_survives_save_and_load() {
|
||||
let mut s = on(json!("YZ"));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]}));
|
||||
run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let id = doc.find_feature("Sketch1").map(|f| f.id).unwrap();
|
||||
let sk = doc.sketch(id).unwrap();
|
||||
assert_eq!(axis_lines(sk).len(), 1);
|
||||
assert_eq!(sk.links.iter().map(|l| &l.source).collect::<Vec<_>>(), [&LinkSource::SketchAxis { name: "y".into() }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_line_references_are_errors_and_change_nothing() {
|
||||
let mut s = on(json!("XY"));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 3], [9, 4]], "ids": ["b"]}));
|
||||
let before = s.doc.to_json();
|
||||
let hostile =
|
||||
[json!("Y"), json!("X"), json!("z"), json!(""), json!("x.start"), json!(" y"), json!(5), json!(null), json!(["y"]), json!({"axis": "y"})];
|
||||
for line in &hostile {
|
||||
assert!(s.execute("sketch.constraint.symmetry", &json!({"a": "a.start", "b": "a.end", "line": line})).is_err(), "symmetry line {line}");
|
||||
for cmd in ["sketch.constraint.parallel", "sketch.constraint.perpendicular", "sketch.constraint.collinear", "sketch.constraint.tangent"] {
|
||||
assert!(s.execute(cmd, &json!({"a": "b", "b": line})).is_err(), "{cmd} b {line}");
|
||||
}
|
||||
assert!(s.execute("sketch.constraint.coincident", &json!({"a": "b.start", "b": line})).is_err(), "coincident b {line}");
|
||||
}
|
||||
// Constraints that do not take an axis still refuse x / y, and leave no axis line behind.
|
||||
for cmd in ["sketch.constraint.equal", "sketch.constraint.concentric"] {
|
||||
assert!(s.execute(cmd, &json!({"a": "b", "b": "x"})).is_err(), "{cmd}");
|
||||
}
|
||||
assert!(s.execute("sketch.constraint.midpoint", &json!({"point": "b.start", "line": "y"})).is_err());
|
||||
assert!(s.execute("sketch.constraint.horizontal_vertical", &json!({"line": "x"})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before, "failed commands change nothing");
|
||||
// A constraint the sketch refuses rolls the axis line back with it.
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "b", "mode": "horizontal"}));
|
||||
let before_redundant = s.doc.to_json();
|
||||
assert!(s.execute("sketch.constraint.parallel", &json!({"a": "b", "b": "x"})).is_err(), "already horizontal");
|
||||
assert_eq!(s.doc.to_json(), before_redundant);
|
||||
assert!(axis_lines(&sketch(&s)).is_empty());
|
||||
}
|
||||
@@ -79,6 +79,7 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
|
||||
by_component.entry(doc.model_body_component(body)).or_default().push(body);
|
||||
}
|
||||
let mut chosen = None;
|
||||
let mut sheet_face: Option<(String, Plane)> = None;
|
||||
let mut work = state.bodies.len().saturating_add(doc.occurrences.len());
|
||||
for (component, path, placement) in placements {
|
||||
let Some(bodies) = by_component.get(&component) else {
|
||||
@@ -104,6 +105,22 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
|
||||
let inverse = mat_inverse(&frame).ok_or_else(|| bad(cmd, "invalid selected face frame"))?;
|
||||
let local_point = apply_point(&inverse, point);
|
||||
let tolerance = (body.body.size() * 1e-4).max(1e-5);
|
||||
// A flat face of a sheet metal body attaches to its panel: the exact plane comes from
|
||||
// the panel's flat region and fold, and the sketch keeps the face's name to follow it.
|
||||
if !fixed
|
||||
&& !internal
|
||||
&& let Some(sheet) = state.sheets.iter().find(|sheet| sheet.body == body.name)
|
||||
&& let Some((face, plane)) = sheet.panel_face_at(local_point, tolerance * 10.0)
|
||||
{
|
||||
if chosen.is_some() || sheet_face.is_some() {
|
||||
return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index"));
|
||||
}
|
||||
let relative = mat_mul(&author_inverse, &frame);
|
||||
let plane = Plane::new(apply_point(&relative, plane.origin), apply_vector(&relative, plane.x), apply_vector(&relative, plane.y))
|
||||
.ok_or_else(|| bad(cmd, "invalid sheet face plane"))?;
|
||||
sheet_face = Some((solidcraft_doc::sheet::sheet_face_name(&body.name, &face), plane));
|
||||
continue;
|
||||
}
|
||||
let faces = body.body.faces(tolerance)?;
|
||||
let mesh = body.body.tessellate(tolerance)?;
|
||||
work = work.saturating_add(mesh.triangles.len().saturating_mul(if requested_index.is_some() { 1 } else { faces.len() }));
|
||||
@@ -138,6 +155,12 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((name, plane)) = sheet_face {
|
||||
if chosen.is_some() {
|
||||
return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index"));
|
||||
}
|
||||
return Ok(PlaneRef::Face { plane, at: apply_point(&author_inverse, point), name: Some(name), anchor: None });
|
||||
}
|
||||
let (body, index, path, plane) =
|
||||
chosen.ok_or_else(|| bad(cmd, "the selected planar face no longer exists; choose another face or an explicit fixed plane"))?;
|
||||
if fixed {
|
||||
@@ -160,6 +183,19 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
|
||||
}
|
||||
None
|
||||
}
|
||||
Err(solidcraft_doc::DocError::Invalid(message))
|
||||
if matches!(
|
||||
message.as_str(),
|
||||
"this face has no exact planar source; choose a fixed plane instead"
|
||||
| "this face has no supported construction certificate; choose a fixed plane instead"
|
||||
) =>
|
||||
{
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"this face can't keep an attachment: it was merged from more than one feature's faces or reshaped by an edit. \
|
||||
Pick another face, or use a fixed plane here (fixed:true), which won't follow later changes to the face",
|
||||
));
|
||||
}
|
||||
Err(error) => return Err(bad(cmd, format!("{error}; choose a supported exact face or explicitly use fixed:true"))),
|
||||
};
|
||||
Ok(PlaneRef::Face { plane, at, name, anchor })
|
||||
|
||||
@@ -76,7 +76,8 @@ fn explicit_rebind_preserves_geometry_dimensions_context_and_one_undo() {
|
||||
fn unsupported_face_has_explicit_fixed_alternative_and_no_live_projection() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[4,4,10],"operation":"join"}));
|
||||
// The tops of two joined boxes merge into one face that no single construction owns.
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[10,0,0],"operation":"join"}));
|
||||
let mut pick = top_pick(&s, "Base");
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("sketch.create", &json!({"plane":pick})).unwrap_err();
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Sketches on construction planes built from the model (perpendicular to a plane through a
|
||||
//! sketch line, through three points, a midplane…): Project, Intersect, Include 3D Geometry and
|
||||
//! Text resolve the sketch's plane against the model before the sketch, as evaluation does, and
|
||||
//! the links follow when a parameter moves the plane.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sketch `A` on YZ with the line `s` from the origin to (run 100, rise `rise`), and the plane
|
||||
/// `P` through that line, square to YZ. The plane's origin is the line's start and its x axis
|
||||
/// runs along the line.
|
||||
fn sloped_plane(s: &mut Session) {
|
||||
run(s, "parameters.add", json!({"name": "rise", "expression": "60 mm"}));
|
||||
run(s, "sketch.create", json!({"plane": "YZ", "name": "A"}));
|
||||
run(s, "sketch.line", json!({"points": [[0, 0], [100, 60]], "ids": ["s"]}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["s.start", "s.end"], "type": "horizontal", "value": "100 mm"}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["s.start", "s.end"], "type": "vertical", "value": "rise"}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "construct.plane.perpendicular", json!({"plane": "YZ", "line": {"sketch": "A", "curve": "s"}, "name": "P"}));
|
||||
}
|
||||
|
||||
/// World position of point `point` of sketch `sketch`.
|
||||
fn world_point(s: &Session, sketch: &str, point: &str) -> Vec3 {
|
||||
let id = s.doc.find_feature(sketch).unwrap().id;
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).unwrap();
|
||||
let i = ss.sketch.resolve_point(point).unwrap_or_else(|| panic!("no point {point} in {sketch}"));
|
||||
ss.plane.to_world(ss.sketch.point(i).unwrap())
|
||||
}
|
||||
|
||||
fn no_trouble(s: &Session) {
|
||||
for r in &s.model.results {
|
||||
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_onto_a_plane_built_from_a_sketch_line_follows_the_parameter() {
|
||||
let mut s = Session::default();
|
||||
sloped_plane(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": "P", "name": "B"}));
|
||||
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "A", "point": "s.end"}, {"sketch": "A", "curve": "s"}]}));
|
||||
let point = r["links"][0]["points"][0].as_str().unwrap().to_string();
|
||||
let curve = r["links"][1]["curves"][0].as_str().unwrap().to_string();
|
||||
// Geometry drawn against the projection keeps working: a circle on the projected point.
|
||||
run(&mut s, "sketch.circle.center", json!({"center": point, "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
no_trouble(&s);
|
||||
let len = (100.0f64 * 100.0 + 60.0 * 60.0).sqrt();
|
||||
let end = world_point(&s, "A", "s.end");
|
||||
assert!(world_point(&s, "B", &point).dist(end) < 1e-9, "{:?} {end:?}", world_point(&s, "B", &point));
|
||||
let ends = [world_point(&s, "B", &format!("{curve}.start")), world_point(&s, "B", &format!("{curve}.end"))];
|
||||
assert!(ends.iter().any(|q| q.dist(Vec3::ZERO) < 1e-9) && ends.iter().any(|q| q.dist(end) < 1e-9), "{ends:?}");
|
||||
{
|
||||
let st = s.model.state();
|
||||
let b = st.sketch(s.doc.find_feature("B").unwrap().id).unwrap();
|
||||
let q = b.sketch.point(b.sketch.resolve_point(&point).unwrap()).unwrap();
|
||||
assert!((q.x - len).abs() < 1e-9 && q.y.abs() < 1e-9, "the line's end lies on the plane's x axis: {q:?}");
|
||||
}
|
||||
// The parameter tilts the plane; the projection and the circle on it follow, in the file too.
|
||||
run(&mut s, "parameters.change", json!({"name": "rise", "expression": "25 mm"}));
|
||||
let check = |s: &Session| {
|
||||
no_trouble(s);
|
||||
let end = world_point(s, "A", "s.end");
|
||||
assert!((end.z - 25.0).abs() < 1e-9 && (end.y - 100.0).abs() < 1e-9, "{end:?}");
|
||||
assert!(world_point(s, "B", &point).dist(end) < 1e-9);
|
||||
assert!(world_point(s, "B", "c1.center").dist(end) < 1e-9);
|
||||
};
|
||||
check(&s);
|
||||
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
|
||||
// Editing the sketch again after the change refreshes its links on the moved plane.
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "B"}));
|
||||
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
check(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_a_plane_built_from_a_sketch_line() {
|
||||
let mut s = Session::default();
|
||||
sloped_plane(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": "P", "name": "B"}));
|
||||
let r = run(&mut s, "sketch.text", json!({"text": "AB", "at": [10, 10], "height": 5}));
|
||||
assert!(!r["curves"].as_array().unwrap().is_empty(), "{r}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
no_trouble(&s);
|
||||
let st = s.model.state();
|
||||
let b = st.sketch(s.doc.find_feature("B").unwrap().id).unwrap();
|
||||
assert!(!b.profiles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_and_include_on_planes_built_from_the_model() {
|
||||
let mut s = Session::default();
|
||||
sloped_plane(&mut s);
|
||||
run(&mut s, "solid.box", json!({"corner": [-10, 20, -5], "length": 20, "width": 30, "height": 80, "body_name": "Post"}));
|
||||
// The plane through three sketch points and the midplane of two faces are built from the model too.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "T"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 30]], "ids": ["u", "v"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"construct.plane.three_points",
|
||||
json!({"a": {"sketch": "T", "point": "u.start"}, "b": {"sketch": "T", "point": "u.end"}, "c": {"sketch": "T", "point": "v.end"}, "name": "Three"}),
|
||||
);
|
||||
run(&mut s, "construct.plane.midplane", json!({"a": {"face": [0, 20, 30]}, "b": {"face": [0, 50, 30]}, "name": "Mid"}));
|
||||
|
||||
run(&mut s, "sketch.create", json!({"plane": "P", "name": "B"}));
|
||||
// The slope cuts the post: a closed section of four lines.
|
||||
let r = run(&mut s, "sketch.intersect", json!({"refs": [{"body": "Post"}]}));
|
||||
assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}");
|
||||
// An edge of the post, brought onto the slope.
|
||||
let r = run(&mut s, "sketch.include_3d", json!({"refs": [{"edge": [-10, 20, 30], "body": "Post"}]}));
|
||||
assert!(r["links"][0]["link"].is_string(), "{r}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
|
||||
run(&mut s, "sketch.create", json!({"plane": "Three", "name": "C"}));
|
||||
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "T", "point": "v.end"}]}));
|
||||
let p = r["links"][0]["points"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(world_point(&s, "C", &p).dist(Vec3::new(40.0, 30.0, 0.0)) < 1e-9);
|
||||
|
||||
run(&mut s, "sketch.create", json!({"plane": "Mid", "name": "D"}));
|
||||
let r = run(&mut s, "sketch.intersect", json!({"refs": [{"body": "Post"}]}));
|
||||
assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
no_trouble(&s);
|
||||
// The midplane sits half way between the post's two faces (y = 35).
|
||||
let st = s.model.state();
|
||||
let d = st.sketch(s.doc.find_feature("D").unwrap().id).unwrap();
|
||||
assert!(d.sketch.points.iter().any(|q| q.link.is_some()));
|
||||
for q in d.sketch.points.iter().filter(|q| q.link.is_some()) {
|
||||
assert!((d.plane.to_world(q.pos).y - 35.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -118,15 +118,36 @@ fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
|
||||
Err(bad(cmd, format!("cannot project {v}")))
|
||||
}
|
||||
|
||||
/// The plane of a sketch feature.
|
||||
fn sketch_plane(doc: &Document, id: u64) -> Result<Plane> {
|
||||
/// The plane of a sketch feature, resolved against `st`: the model before the sketch as its
|
||||
/// component sees it, which is what evaluation resolves it against. A construction plane built
|
||||
/// from the model (through a sketch line, three points, between two faces…) exists only there.
|
||||
fn sketch_plane(doc: &Document, id: u64, st: &solidcraft_doc::ModelState) -> Result<Plane> {
|
||||
let (vals, _) = doc.param_values();
|
||||
match doc.feature(id).map(|f| &f.kind) {
|
||||
Some(FeatureKind::Sketch { plane, .. }) => Ok(doc.resolve_plane(&vals, plane, 0)?),
|
||||
Some(FeatureKind::Sketch { plane, .. }) => Ok(doc.resolve_plane_in(&vals, plane, 0, Some(st))?),
|
||||
_ => Err(EngineError::Other(format!("feature {id} is not a sketch"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The model before sketch `id` in the frame its links and its plane are resolved in: the
|
||||
/// attached face's frame for a sketch on a face, else its component's.
|
||||
fn state_for_sketch<'a>(
|
||||
s: &Session,
|
||||
doc: &Document,
|
||||
id: u64,
|
||||
st: &'a solidcraft_doc::ModelState,
|
||||
) -> std::result::Result<std::borrow::Cow<'a, solidcraft_doc::ModelState>, solidcraft_doc::DocError> {
|
||||
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
});
|
||||
match attachment {
|
||||
Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(doc, st, anchor, comp),
|
||||
None => Ok(solidcraft_doc::state_in_frame_at(doc, st, comp, owner_frame(s, doc, comp))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep candidate feature edits while overlaying uncommitted occurrence placements.
|
||||
fn placed_candidate<'a>(s: &Session, doc: &'a Document) -> std::borrow::Cow<'a, Document> {
|
||||
if s.pending_moves.is_empty() {
|
||||
@@ -152,21 +173,16 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) ->
|
||||
return Vec::new();
|
||||
}
|
||||
let doc = placed_candidate(s, doc);
|
||||
let Ok(plane) = sketch_plane(&doc, id) else { return Vec::new() };
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
});
|
||||
let st = match attachment {
|
||||
Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp),
|
||||
None => Ok(solidcraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))),
|
||||
};
|
||||
let Ok(st) = st else {
|
||||
let Ok(st) = state_for_sketch(s, &doc, id, &st) else {
|
||||
return vec!["lost face attachment; reattach explicitly".into()];
|
||||
};
|
||||
let Ok(plane) = sketch_plane(&doc, id, &st) else { return Vec::new() };
|
||||
match attachment {
|
||||
Some(anchor) => solidcraft_doc::project::refresh_face_anchor_links(&doc, &vals, &st, &plane, sk, anchor),
|
||||
None => solidcraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk),
|
||||
@@ -176,20 +192,11 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) ->
|
||||
/// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id.
|
||||
pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, kind: LinkKind, src: LinkSource) -> Result<String> {
|
||||
let doc = placed_candidate(s, doc);
|
||||
let plane = sketch_plane(&doc, id)?;
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
// The model as the sketch's component sees it.
|
||||
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
});
|
||||
let st = match attachment {
|
||||
Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp),
|
||||
None => Ok(solidcraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))),
|
||||
};
|
||||
let st = st?;
|
||||
let st = state_for_sketch(s, &doc, id, &st)?;
|
||||
let plane = sketch_plane(&doc, id, &st)?;
|
||||
let r = resolve(&doc, &vals, &st, &plane, kind, &src)?;
|
||||
let name = if let LinkSource::Face { body, at } = &r.source {
|
||||
st.body(body).and_then(|body| {
|
||||
@@ -454,7 +461,8 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
|
||||
sk.points.get(pi).map(|q| q.id.clone()).unwrap_or_default()
|
||||
} else if let Some(at) = edge {
|
||||
let l = add_link(s, d, id, sk, LinkKind::Project, LinkSource::Edge { body, at }).map_err(|e| bad(cmd, format!("edge: {e}")))?;
|
||||
let plane = sketch_plane(d, id)?;
|
||||
let before = s.model.state_before(id);
|
||||
let plane = sketch_plane(d, id, &*state_for_sketch(s, d, id, &before)?)?;
|
||||
let q = plane.to_local(at);
|
||||
let ci = sk.link_curves(&l).first().copied().ok_or_else(|| bad(cmd, "the edge projects to a point"))?;
|
||||
let pi = sk.add_point(q, None)?;
|
||||
@@ -490,10 +498,10 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
|
||||
/// edits, but cannot provide the construction certificate required by public attachments.
|
||||
pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result<String> {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let plane = sketch_plane(&doc, id)?;
|
||||
let raw = s.model.state_before(id);
|
||||
let component = doc.feature(id).map(|feature| feature.component).unwrap_or(0);
|
||||
let state = solidcraft_doc::state_in_frame_at(&doc, &raw, component, owner_frame(s, &doc, component));
|
||||
let plane = sketch_plane(&doc, id, &state)?;
|
||||
let resolved = solidcraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?;
|
||||
let face_name = state.body(body).and_then(|body| solidcraft_doc::naming::face_names(body).get(index).cloned());
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
@@ -538,3 +546,7 @@ pub(super) fn auto_project_face(s: &mut Session, id: u64, _at: Vec3) -> Result<O
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_project_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_model_plane_tests.rs"]
|
||||
mod model_plane_tests;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Issue #489: a sketch or an offset plane on the top face of an extrude that was itself
|
||||
//! sketched on a face. Sketching a profile over the whole face draws curves on top of the face's
|
||||
//! projected boundary; the extrude's faces must still carry an exact, certified planar source.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::FaceAttachmentStatus;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command} {params}: {e}"))
|
||||
}
|
||||
|
||||
fn profile(s: &mut Session, circle: bool, full: bool) {
|
||||
if circle {
|
||||
run(s, "sketch.circle.center", json!({"center": [20, 20], "diameter": if full { 40 } else { 16 }}));
|
||||
} else if full {
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
|
||||
} else {
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [30, 30]}));
|
||||
}
|
||||
}
|
||||
|
||||
/// A 40 mm base (square or round) 10 mm tall, then a second extrude 10 mm up from a sketch on
|
||||
/// its top face (or on a plane at that height), joined or as a new body.
|
||||
fn two_extrudes(circle: bool, full: bool, on_face: bool, operation: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
profile(&mut s, circle, true);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "First"}));
|
||||
let plane = if on_face { json!({"face": [20, 20, 10]}) } else { json!("XY") };
|
||||
let mut params = json!({"plane": plane, "name": "Upper"});
|
||||
if !on_face {
|
||||
params["offset"] = json!(10);
|
||||
}
|
||||
run(&mut s, "sketch.create", params);
|
||||
profile(&mut s, circle, full);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": operation, "name": "Second"}));
|
||||
s
|
||||
}
|
||||
|
||||
fn healthy(s: &Session, sketch: &str) -> solidcraft_geom::Plane {
|
||||
let id = s.doc.find_feature(sketch).map(|f| f.id).unwrap();
|
||||
match s.placed_document().sketch_face_attachment(id, &s.model) {
|
||||
FaceAttachmentStatus::Healthy { plane, .. } => plane,
|
||||
other => panic!("{sketch}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn no_errors(s: &Session) {
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_and_offset_plane_on_the_top_face_of_a_face_sketched_extrude() {
|
||||
for operation in ["join", "new"] {
|
||||
for circle in [false, true] {
|
||||
for full in [true, false] {
|
||||
for on_face in [true, false] {
|
||||
let case = format!("{operation} circle={circle} full={full} on_face={on_face}");
|
||||
let mut s = two_extrudes(circle, full, on_face, operation);
|
||||
let top = [20., 20., 20.];
|
||||
s.execute("sketch.create", &json!({"plane": {"face": top}, "name": "Top"})).unwrap_or_else(|e| panic!("{case}: {e}"));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let plane = healthy(&s, "Top");
|
||||
assert!((plane.origin.z - 20.).abs() < 1e-9 && (plane.normal().z - 1.).abs() < 1e-9, "{case}: {plane:?}");
|
||||
s.execute("construct.plane.offset", &json!({"base": {"face": top}, "offset": 5, "name": "Above"}))
|
||||
.unwrap_or_else(|e| panic!("{case}: {e}"));
|
||||
// The second extrude's flat side is an exact source too, unless a full-face
|
||||
// Join merged it with the first extrude's side: that face belongs to neither,
|
||||
// so it offers the explicit fixed plane with a readable reason.
|
||||
if !circle {
|
||||
let side = if full { [40., 20., 15.] } else { [30., 20., 15.] };
|
||||
let params = json!({"plane": {"face": side}, "name": "Side"});
|
||||
if full && operation == "join" {
|
||||
let error = s.execute("sketch.create", ¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains("merged") && error.contains("fixed:true"), "{case}: {error}");
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": side, "fixed": true}, "name": "Side"}));
|
||||
} else {
|
||||
s.execute("sketch.create", ¶ms).unwrap_or_else(|e| panic!("{case} side: {e}"));
|
||||
assert!((healthy(&s, "Side").normal().x - 1.).abs() < 1e-9, "{case}");
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
}
|
||||
s.refresh();
|
||||
no_errors(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Three extrudes, each sketched over the whole top face of the one before; editing the first
|
||||
/// one's height moves every later sketch with its face.
|
||||
#[test]
|
||||
fn three_stacked_extrudes_follow_an_edit_of_the_first() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "E1"}));
|
||||
for (k, top) in [(2, 10.), (3, 20.)] {
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 20, top]}, "name": format!("S{k}")}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": "join", "name": format!("E{k}")}));
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 20, 30]}, "name": "S4"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [20, 20], "diameter": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
s.refresh();
|
||||
no_errors(&s);
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
for (name, z) in [("S2", 10.), ("S3", 20.), ("S4", 30.)] {
|
||||
assert!((healthy(&s, name).origin.z - z).abs() < 1e-9, "{name}");
|
||||
}
|
||||
|
||||
run(&mut s, "timeline.edit", json!({"feature": "E1", "set": {"extent": {"distance": "25"}}}));
|
||||
s.refresh();
|
||||
no_errors(&s);
|
||||
for (name, z) in [("S2", 25.), ("S3", 35.), ("S4", 45.)] {
|
||||
assert!((healthy(&s, name).origin.z - z).abs() < 1e-9, "{name} after edit");
|
||||
}
|
||||
let volume: f64 = s.model.state().bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum();
|
||||
assert!((volume - 40. * 40. * 45.).abs() < 1e-6, "{volume}");
|
||||
|
||||
// Saved and reopened, every attachment still resolves.
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
reopened.refresh();
|
||||
no_errors(&reopened);
|
||||
assert!((healthy(&reopened, "S4").origin.z - 45.).abs() < 1e-9);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("surface.patch", "Patch", patch)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("patch")
|
||||
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges) and body?; name?, body_name?"),
|
||||
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; name?, body_name?"),
|
||||
CommandSpec::new("surface.stitch", "Stitch", stitch)
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("stitch")
|
||||
@@ -129,7 +129,8 @@ mod tests {
|
||||
solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume
|
||||
}
|
||||
|
||||
/// A rectangle patched, trimmed by a plane and thickened; a box's faces stitched back.
|
||||
/// A rectangle patched, trimmed by a plane and thickened; a box's open top patched; a rim
|
||||
/// that is not planar patched.
|
||||
#[test]
|
||||
fn patch_trim_thicken_and_stitch() {
|
||||
let mut s = Session::default();
|
||||
@@ -148,6 +149,24 @@ mod tests {
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cup"}));
|
||||
run(&mut s, "surface.patch", json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup", "body_name": "Lid"}));
|
||||
assert!(s.world_state().body("Lid").unwrap().body.is_surface());
|
||||
// A rim that is not planar: a box with its top cut away by a sideways cylinder.
|
||||
run(&mut s, "solid.box", json!({"length": 28, "width": 28, "height": 20, "corner": [-14, -14, 0], "body_name": "Saddle"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.cylinder",
|
||||
json!({"radius": 40, "height": 120, "base": [0, -60, 50], "axis": "Y", "operation": "cut", "targets": ["Saddle"]}),
|
||||
);
|
||||
let z = |x: f64| 50.0 - (1600.0 - x * x).sqrt();
|
||||
let rim = json!([[14, 0, z(14.0)], [0, 14, z(0.0)], [-14, 0, z(-14.0)], [0, -14, z(0.0)]]);
|
||||
run(&mut s, "surface.patch", json!({"edges": rim, "body": "Saddle", "body_name": "Cover"}));
|
||||
let st = s.world_state();
|
||||
let cover = &st.body("Cover").unwrap().body;
|
||||
assert!(cover.is_surface() && cover.face_count() == 1);
|
||||
// The cover is a smooth surface on the rim, close to the cylinder that cut it (its area
|
||||
// within 1% of the cylinder's inside the rim).
|
||||
let area = solidcraft_kernel::measure(cover).unwrap().area;
|
||||
let want = 28.0 * 40.0 * 2.0 * (14.0f64 / 40.0).asin();
|
||||
assert!((area - want).abs() < want * 1e-2, "{area} vs {want}");
|
||||
assert!(s.execute("surface.thicken", &json!({"bodies": ["Nope"], "thickness": 1})).is_err());
|
||||
assert!(s.execute("surface.trim", &json!({"body": "Sheet"})).is_err());
|
||||
}
|
||||
|
||||
@@ -331,6 +331,38 @@ fn revolve_and_primitives() {
|
||||
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), PI * 125.0 * 5.0 / 2.0) < 5e-4);
|
||||
}
|
||||
|
||||
/// A spherical shell (a half ring between two arcs, both radial lines on the axis) revolved on a
|
||||
/// sketch plane that does not pass through the origin: the body is the shell, as it is on XY.
|
||||
/// Before, built in place, it measured a third too large (truck tessellates the degenerate faces
|
||||
/// on the axis reliably only with the axis through the origin).
|
||||
#[test]
|
||||
fn revolve_a_spherical_shell_off_the_origin() {
|
||||
for (plane, z) in [(json!("XY"), 0.0), (json!({"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}), 10.0)] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": plane, "name": "Ring"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-40, 0], [40, 0]]}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [40, 0], "end": [-40, 0]}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [18.5, 0], "end": [-18.5, 0]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// The half ring: the larger of the two regions above the axis.
|
||||
let si = run(&mut s, "sketch.inspect", json!({"sketch": "Ring"}));
|
||||
let ring = si["profiles"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.max_by(|a, b| a["area_mm2"].as_f64().unwrap().total_cmp(&b["area_mm2"].as_f64().unwrap()))
|
||||
.unwrap()["index"]
|
||||
.clone();
|
||||
run(&mut s, "solid.revolve", json!({"sketch": "Ring", "profiles": [ring], "axis": "l1", "body_name": "Shell"}));
|
||||
let m = run(&mut s, "inspect.measure", json!({"bodies": ["Shell"]}));
|
||||
let want = 4.0 / 3.0 * PI * (40.0f64.powi(3) - 18.5f64.powi(3));
|
||||
let v = m["total"]["volume_mm3"].as_f64().unwrap();
|
||||
assert!(rel(v, want) < 5e-4, "plane at z {z}: {v} vs {want}");
|
||||
let b = &m["bodies"][0]["bbox"];
|
||||
assert!((b["min"][2].as_f64().unwrap() - (z - 40.0)).abs() < 1e-6 && (b["max"][2].as_f64().unwrap() - (z + 40.0)).abs() < 1e-6, "{b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_tools_and_constraints() {
|
||||
let mut s = Session::default();
|
||||
@@ -1882,7 +1914,8 @@ mod naming {
|
||||
let mut legacy = Session::new(solidcraft_doc::Document::from_json(&old.to_json()).unwrap());
|
||||
assert_eq!(attached(&legacy), Some(10.));
|
||||
assert!((top(&mut legacy) - 15.).abs() < 1e-6);
|
||||
// The upstream join consumes the certified body; new attachments cannot follow its substitute roof.
|
||||
// An upstream join that trims the certified face: the attachment stays on what is left
|
||||
// of it and never follows the substitute roof (see face_tests for a face covered whole).
|
||||
for target in [&mut s, &mut legacy] {
|
||||
run(target, "timeline.roll_to", json!({"feature": "Block"}));
|
||||
run(target, "sketch.create", json!({"plane": "XY", "name": "B"}));
|
||||
@@ -1903,19 +1936,14 @@ mod naming {
|
||||
};
|
||||
assert!(anchor.is_none(), "opening old face references must not infer a strict attachment");
|
||||
let failed = |s: &Session, name: &str| s.model.results.iter().find(|r| r.id == s.doc.find_feature(name).unwrap().id).unwrap().error.is_some();
|
||||
assert!(failed(&s, "Top"));
|
||||
assert!(failed(&s, "Peg"), "lost sketch cannot supply downstream geometry");
|
||||
assert_eq!(attached(&s), None);
|
||||
assert!(!failed(&s, "Top"));
|
||||
assert!(!failed(&s, "Peg"));
|
||||
assert_eq!(attached(&s), Some(10.), "the strip of Block's top left beside the boss is still that face");
|
||||
assert!((top(&mut s) - 20.0).abs() < 1e-6, "{}", top(&mut s));
|
||||
run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true}));
|
||||
assert!(!failed(&s, "Top"));
|
||||
assert!(!failed(&s, "Peg"));
|
||||
assert_eq!(attached(&s), Some(10.));
|
||||
assert!((top(&mut s) - 15.).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(failed(&s, "Top"));
|
||||
assert!(failed(&s, "Peg"));
|
||||
assert_eq!(attached(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+111
-67
@@ -154,6 +154,18 @@ pub fn check_knots(knots: &[f64]) -> SplineResult<()> {
|
||||
/// A knot vector shorter than two knots gives zeros.
|
||||
pub fn local_basis(knots: &[f64], u: f64, side: Side, n: usize) -> Vec<f64> {
|
||||
let mut out = vec![0.0; n + 1];
|
||||
let ders = local_basis_array(knots, u, side, n);
|
||||
for (o, d) in out.iter_mut().zip(ders) {
|
||||
*o = d;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`local_basis`] into a fixed array (no allocation): element `k` is the `k`-th derivative,
|
||||
/// for `k ≤ min(n, MAX_SPLINE_DEGREE)`, the rest zero.
|
||||
pub(crate) fn local_basis_array(knots: &[f64], u: f64, side: Side, n: usize) -> [f64; MAX_SPLINE_DEGREE + 1] {
|
||||
const W: usize = MAX_SPLINE_DEGREE + 1;
|
||||
let mut out = [0.0; W];
|
||||
let Some(p) = knots.len().checked_sub(2) else { return out };
|
||||
if p > MAX_SPLINE_DEGREE || !u.is_finite() {
|
||||
return out;
|
||||
@@ -163,7 +175,6 @@ pub fn local_basis(knots: &[f64], u: f64, side: Side, n: usize) -> Vec<f64> {
|
||||
// table[k][j]: k-th derivative of N_{j,q} for the current degree q (j = 0..=p−q), updated
|
||||
// in place: k descending (level k reads level k−1 of the previous degree), j ascending
|
||||
// (N_{j,q} reads N_{j+1,q−1}, not yet overwritten). Derivatives above p are zero.
|
||||
const W: usize = MAX_SPLINE_DEGREE + 1;
|
||||
let levels = n.min(p);
|
||||
let mut table = [[0.0f64; W]; W];
|
||||
for (j, slot) in table[0].iter_mut().enumerate().take(p + 1) {
|
||||
@@ -286,15 +297,16 @@ pub fn find_span(knots: &[f64], p: usize, n: usize, u: f64) -> Option<usize> {
|
||||
if u < lo || u > hi {
|
||||
return None;
|
||||
}
|
||||
// The last non-empty span in [p, n−1] that starts at or before u.
|
||||
let mut k = p;
|
||||
for i in p..n {
|
||||
if let (Some(&a), Some(&b)) = (knots.get(i), knots.get(i + 1))
|
||||
&& a <= u
|
||||
&& a < b
|
||||
{
|
||||
k = i;
|
||||
}
|
||||
// The last non-empty span in [p, n−1] that starts at or before u: by bisection (the knots
|
||||
// are sorted), and at the domain end the last non-empty span.
|
||||
let mut k = if u >= hi {
|
||||
n - 1
|
||||
} else {
|
||||
let below = knots.get(p..n)?.partition_point(|&x| x <= u);
|
||||
p + below.checked_sub(1)?
|
||||
};
|
||||
while k > p && !(knots.get(k)? < knots.get(k + 1)?) {
|
||||
k -= 1;
|
||||
}
|
||||
Some(k)
|
||||
}
|
||||
@@ -331,16 +343,62 @@ pub fn insert_knot(p: usize, knots: &[f64], ctrl: &[HPoint], u: f64) -> SplineRe
|
||||
Ok((nk, q))
|
||||
}
|
||||
|
||||
/// The binomial coefficient `(n k)` (zero for `k > n`).
|
||||
pub(crate) fn binomial(n: usize, k: usize) -> f64 {
|
||||
if k > n {
|
||||
return 0.0;
|
||||
}
|
||||
(0..k).fold(1.0, |c, m| c * (n - m) as f64 / (m + 1) as f64)
|
||||
}
|
||||
|
||||
/// Bernstein polynomial `Bᵢ,ₚ(x)`.
|
||||
pub fn bernstein(p: usize, i: usize, x: f64) -> f64 {
|
||||
if i > p {
|
||||
return 0.0;
|
||||
}
|
||||
let mut c = 1.0;
|
||||
for m in 0..i {
|
||||
c = c * (p - m) as f64 / (m + 1) as f64;
|
||||
binomial(p, i) * x.powi(i as i32) * (1.0 - x).powi((p - i) as i32)
|
||||
}
|
||||
|
||||
/// One row of a B-spline (degree `p`, knots, homogeneous control points) clamped on its domain
|
||||
/// `[knots[p], knots[n]]`: each domain end inserted until it repeats `p` times (§5.2), then the
|
||||
/// basis functions that vanish on the domain dropped (a basis function's last span does not
|
||||
/// depend on its first knot, so that knot moves to the domain end). The curve on the domain is
|
||||
/// unchanged.
|
||||
pub(crate) fn clamp_row(p: usize, knots: &[f64], row: &[HPoint]) -> SplineResult<(Vec<f64>, Vec<HPoint>)> {
|
||||
let n = row.len();
|
||||
let (Some(&a), Some(&b)) = (knots.get(p), knots.get(n)) else { return invalid("too few knots") };
|
||||
let (mut knots, mut row) = (knots.to_vec(), row.to_vec());
|
||||
for (at_end, x) in [(false, a), (true, b)] {
|
||||
let mult = |k: &[f64]| k.iter().filter(|&&y| y == x).count();
|
||||
let mut guard = 0;
|
||||
while mult(&knots) < p {
|
||||
guard += 1;
|
||||
if guard > MAX_SPLINE_DEGREE + 1 {
|
||||
return invalid("clamping did not converge");
|
||||
}
|
||||
(knots, row) = insert_knot(p, &knots, &row, x)?;
|
||||
}
|
||||
let n = row.len();
|
||||
let (first, last) = if at_end {
|
||||
// Functions i with i ≥ (first index of x) start at or past the end.
|
||||
(0, knots.iter().position(|&y| y == x).unwrap_or(n).min(n))
|
||||
} else {
|
||||
// Functions i with i + p + 1 ≤ (last index of x) end at or before the start.
|
||||
(knots.iter().rposition(|&y| y == x).unwrap_or(p).saturating_sub(p), n)
|
||||
};
|
||||
if last <= first + p {
|
||||
return invalid("too few control points remain after clamping");
|
||||
}
|
||||
let mut nk: Vec<f64> = knots.get(first..last + p + 1).map(<[f64]>::to_vec).unwrap_or_default();
|
||||
if at_end {
|
||||
nk.last_mut().into_iter().for_each(|y| *y = x);
|
||||
} else {
|
||||
nk.first_mut().into_iter().for_each(|y| *y = x);
|
||||
}
|
||||
row = row.get(first..last).map(<[HPoint]>::to_vec).unwrap_or_default();
|
||||
knots = nk;
|
||||
}
|
||||
c * x.powi(i as i32) * (1.0 - x).powi((p - i) as i32)
|
||||
Ok((knots, row))
|
||||
}
|
||||
|
||||
/// A Bézier curve's point at `x ∈ [0, 1]` (de Casteljau, NURBS Book §1.4).
|
||||
@@ -543,66 +601,37 @@ impl NurbsSurface {
|
||||
}
|
||||
|
||||
/// The same surface on the same domain with clamped knot vectors (`p+1` equal knots at
|
||||
/// each end, so the boundary curves are given by the boundary rows of control points):
|
||||
/// insert each domain end until it has multiplicity `p`, then drop the basis functions
|
||||
/// that vanish on the domain (NURBS Book §5.2 knot insertion; a basis function's last span
|
||||
/// does not depend on its first knot, so that knot can be moved to the domain end).
|
||||
/// each end, so the boundary curves are given by the boundary rows of control points): each
|
||||
/// row of control points clamped in turn ([`clamp_row`]).
|
||||
pub fn clamped(&self) -> SplineResult<NurbsSurface> {
|
||||
let mut s = self.clone();
|
||||
for dir in [Dir::S, Dir::T] {
|
||||
let d = dir.pick(0, 1);
|
||||
for d in 0..2 {
|
||||
let p = s.degree[d];
|
||||
for end in 0..2 {
|
||||
let a = s.domain()[d][end];
|
||||
while s.knots[d].iter().filter(|&&x| x == a).count() < p {
|
||||
s = s.insert_knot(dir, a)?;
|
||||
}
|
||||
s = s.trim_end(d, end, a)?;
|
||||
let [nu, nv] = s.count;
|
||||
let (lines, len) = if d == 0 { (nv, nu) } else { (nu, nv) };
|
||||
let mut knots = Vec::new();
|
||||
let mut rows = Vec::with_capacity(lines);
|
||||
for line in 0..lines {
|
||||
let row: Vec<HPoint> = (0..len).map(|k| if d == 0 { s.point(k, line) } else { s.point(line, k) }).collect();
|
||||
let (k, r) = clamp_row(p, &s.knots[d], &row)?;
|
||||
knots = k;
|
||||
rows.push(r);
|
||||
}
|
||||
let new_len = rows.first().map_or(0, Vec::len);
|
||||
let mut ctrl = Vec::with_capacity(lines * new_len);
|
||||
if d == 0 {
|
||||
rows.iter().for_each(|r| ctrl.extend_from_slice(r));
|
||||
} else {
|
||||
for j in 0..new_len {
|
||||
ctrl.extend(rows.iter().map(|r| r.get(j).copied().unwrap_or(HPoint::ZERO)));
|
||||
}
|
||||
}
|
||||
let mut all = s.knots.clone();
|
||||
all[d] = knots;
|
||||
s = NurbsSurface::from_homogeneous(s.degree, all, ctrl)?;
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Drop the basis functions that vanish on the domain past the knot `a` (multiplicity ≥ p)
|
||||
/// at the start (`end == 0`) or the end of direction `d`.
|
||||
fn trim_end(&self, d: usize, end: usize, a: f64) -> SplineResult<NurbsSurface> {
|
||||
let p = self.degree[d];
|
||||
let kn = &self.knots[d];
|
||||
let n = self.count[d];
|
||||
let (first, last) = match end {
|
||||
0 => {
|
||||
let last_a = kn.iter().rposition(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
|
||||
// Functions i with i + p + 1 ≤ last_a vanish on [a, …).
|
||||
(last_a.saturating_sub(p), n)
|
||||
}
|
||||
_ => {
|
||||
let first_a = kn.iter().position(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
|
||||
// Functions i with i ≥ first_a vanish on (…, a].
|
||||
(0, first_a.min(n))
|
||||
}
|
||||
};
|
||||
if last <= first + p {
|
||||
return invalid("clamp: too few control points remain");
|
||||
}
|
||||
let mut knots = self.knots.clone();
|
||||
let mut nk: Vec<f64> = kn.get(first..last + p + 1).map(<[f64]>::to_vec).unwrap_or_default();
|
||||
match end {
|
||||
0 => nk.first_mut().into_iter().for_each(|x| *x = a),
|
||||
_ => nk.last_mut().into_iter().for_each(|x| *x = a),
|
||||
}
|
||||
knots[d] = nk;
|
||||
let [nu, nv] = self.count;
|
||||
let mut ctrl = Vec::new();
|
||||
for j in 0..nv {
|
||||
for i in 0..nu {
|
||||
let k = if d == 0 { i } else { j };
|
||||
if k >= first && k < last {
|
||||
ctrl.push(self.point(i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
NurbsSurface::from_homogeneous(self.degree, knots, ctrl)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -666,6 +695,21 @@ pub(crate) mod tests {
|
||||
assert!(worst < 1e-5, "derivative error {worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn span_search_finds_the_last_non_empty_span_at_or_before_u() {
|
||||
let p = 3;
|
||||
let knots = [0.0, 0.0, 0.0, 0.0, 0.3, 0.3, 0.5, 0.9, 0.9, 0.9, 1.0, 1.0, 1.0, 1.0];
|
||||
let n = knots.len() - p - 1;
|
||||
for k in 0..=1000 {
|
||||
let u = k as f64 / 1000.0;
|
||||
// The plain search: the last span [knots[i], knots[i+1]) with knots[i] ≤ u.
|
||||
let want = (p..n).filter(|&i| knots[i] <= u && knots[i] < knots[i + 1]).max().unwrap();
|
||||
assert_eq!(find_span(&knots, p, n, u), Some(want), "u = {u}");
|
||||
}
|
||||
assert_eq!(find_span(&knots, p, n, 1.5), None);
|
||||
assert_eq!(find_span(&knots, p, n, f64::NAN), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_function_knot_insertion_is_exact() {
|
||||
let mut rng = Lcg(7);
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
//! Filling a closed loop of curves with one surface: a non-rational bicubic B-spline height
|
||||
//! field over the loop's best-fit plane, fitted to the loop by least squares with a smoothness
|
||||
//! term. A loop that cannot be seen as the boundary of a height field over that plane (it
|
||||
//! crosses itself, folds back or runs along the plane's normal when seen along that normal) is
|
||||
//! refused, never filled with wrong geometry.
|
||||
//!
|
||||
//! References: Piegl & Tiller, *The NURBS Book* (2nd ed.) — §9.4.1 and §9.4.3 (least squares
|
||||
//! approximation: minimise the sum of squared residuals, eq. 9.62, through the normal
|
||||
//! equations, eq. 9.65), §3.4 (tensor product B-spline surfaces), §3.3 (derivative of a
|
||||
//! B-spline curve, eq. 3.4: control values at the knot averages reproduce a linear function),
|
||||
//! A2.1 (span search).
|
||||
|
||||
use crate::Vec3;
|
||||
use crate::bspline::{HPoint, NurbsSurface, Side, SplineError, SplineResult, find_span, invalid, local_basis_array};
|
||||
|
||||
/// Most loop points a fill takes.
|
||||
pub const MAX_LOOP_POINTS: usize = 8192;
|
||||
/// Most spans of the height field in each direction.
|
||||
pub const MAX_FILL_SPANS: usize = 32;
|
||||
/// Fewest spans of the height field in each direction (the first fit tried).
|
||||
const MIN_FILL_SPANS: usize = 4;
|
||||
/// The smoothness energy's terms: `(order in u, order in v, weight)` of `∫∫ (∂ h)²`.
|
||||
const ENERGY: [(usize, usize, f64); 3] = [(2, 0, 1.0), (1, 1, 2.0), (0, 2, 1.0)];
|
||||
/// Four-point Gauss–Legendre nodes and weights on `[−1, 1]` (exact to degree 7).
|
||||
const GAUSS4: [(f64, f64); 4] = [
|
||||
(-0.861_136_311_594_052_6, 0.347_854_845_137_453_8),
|
||||
(-0.339_981_043_584_856_3, 0.652_145_154_862_546_2),
|
||||
(0.339_981_043_584_856_3, 0.652_145_154_862_546_2),
|
||||
(0.861_136_311_594_052_6, 0.347_854_845_137_453_8),
|
||||
];
|
||||
/// The smoothness term's weight per fitted point (see [`fill_loop`]).
|
||||
const SMOOTHNESS: f64 = 1e-7;
|
||||
/// A loop step shorter than this fraction of its length in space, seen along the normal, runs
|
||||
/// along the normal (steeper than about 87° from the plane).
|
||||
const MIN_STEP_SLOPE: f64 = 0.05;
|
||||
/// Consecutive steps closer to reversed than this cosine fold the loop back on itself.
|
||||
const FOLD_COS: f64 = -0.999;
|
||||
|
||||
/// A loop's fill: the surface (`S(u, v) = o + u·x + v·y + h(u, v)·n` over the plane with normal
|
||||
/// `n`, so `∂S/∂u × ∂S/∂v` points along `n`), the plane's frame, whether the loop runs counter-
|
||||
/// clockwise about it, the largest distance from a sampled loop point to the surface along `n`,
|
||||
/// and how many points of the loop were evaluated.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LoopFill {
|
||||
pub surface: NurbsSurface,
|
||||
/// The plane's origin and its axes `x`, `y` (the surface's parameter directions).
|
||||
pub origin: Vec3,
|
||||
pub axes: [Vec3; 2],
|
||||
pub normal: Vec3,
|
||||
pub counter_clockwise: bool,
|
||||
pub deviation: f64,
|
||||
pub evaluations: usize,
|
||||
}
|
||||
|
||||
/// Fill the closed loop of `sides` curves (side `k` given by `point(k, t)`, `t` from 0 at its
|
||||
/// start to 1 at its end, which is where side `k+1` starts) with one surface passing within
|
||||
/// `tol` of the loop.
|
||||
///
|
||||
/// - Sampling: each side at `t = i/s` (fitted) and halfway between (checked), `s` from 16
|
||||
/// doubling while the fit needs denser points, at most [`MAX_LOOP_POINTS`] points in all. The
|
||||
/// sample parameters are the same run either way, so the fill does not depend on where the
|
||||
/// loop starts or which way it runs.
|
||||
/// - The plane: through the fitted points' centroid, normal by Newell's method, turned so that
|
||||
/// its largest component is positive; its first axis points to the fitted point farthest from
|
||||
/// the centroid in the plane (the highest of points as far). So the plane and its axes, and with them the grid and the fill,
|
||||
/// move with the loop (a reflected axis gives the same grid).
|
||||
/// - Refused ("not supported yet"): a loop that, seen along the normal, has a step much shorter
|
||||
/// than in space (it runs along the normal), turns straight back, or crosses or touches
|
||||
/// itself; such a loop does not bound a height field over the plane.
|
||||
/// - The height field: a bicubic B-spline with uniform knots over the projected loop's box
|
||||
/// (widened by 1%); its control points sit at the knot averages in the plane, so its surface
|
||||
/// parameters are plane coordinates, and their heights are the unknowns. The heights minimise
|
||||
/// `Σ (h(uₖ, vₖ) − hₖ)² + λ·A · ∫∫ h_uu² + 2·h_uv² + h_vv²` over the fitted points — least
|
||||
/// squares (eq. 9.62, normal equations eq. 9.65) plus the thin-plate bending energy over the
|
||||
/// box, `A` the area of one grid cell (so each cell's energy counts like a squared second
|
||||
/// difference of heights, and the balance is the same at any size). The energy decides the
|
||||
/// inside, where there are no points; it is zero only for planes, so a plane through the
|
||||
/// loop is reproduced exactly, and any other rim gets the least-bending surface on it, which
|
||||
/// is free to turn at the rim (it is not the surface that made the rim). It does not depend
|
||||
/// on the grid's directions. `λ = 10⁻⁷ ·` (fitted points) `/` (control points): just enough
|
||||
/// to fix the inside, small enough not to pull the surface off the loop.
|
||||
/// - Grids of 4, 8, 16, 32 spans across the box's longer side are tried, with as many across
|
||||
/// the shorter side as keep the cells about square; a grid whose cells are under twice the
|
||||
/// fitted points' largest step in a direction (cells between points would be left to the
|
||||
/// energy alone) waits for denser sampling. The first grid within `tol` of every sampled
|
||||
/// point (measured along the normal, which bounds the distance) is the fill; otherwise the
|
||||
/// sampling doubles, and when that runs out, "not supported yet" with the closest deviation
|
||||
/// reached.
|
||||
pub fn fill_loop(sides: usize, point: &mut dyn FnMut(usize, f64) -> Option<Vec3>, tol: f64) -> SplineResult<LoopFill> {
|
||||
if sides == 0 || sides.saturating_mul(16) > MAX_LOOP_POINTS / 2 {
|
||||
return Err(SplineError::TooLarge(format!("a loop of {sides} sides (at most {})", MAX_LOOP_POINTS / 32)));
|
||||
}
|
||||
if !(tol.is_finite() && tol > 0.0) {
|
||||
return invalid("the fill tolerance must be positive");
|
||||
}
|
||||
let mut evaluations = 0usize;
|
||||
let mut eval = |k: usize, t: f64| -> SplineResult<Vec3> {
|
||||
evaluations += 1;
|
||||
point(k, t).filter(|p| p.is_finite()).ok_or_else(|| SplineError::Invalid(format!("side {k} has no finite point at {t}")))
|
||||
};
|
||||
let mut per = 16usize;
|
||||
// Fitted points: side by side, per side at i/per (i < per).
|
||||
let mut fit: Vec<Vec3> = Vec::with_capacity(sides * per);
|
||||
for k in 0..sides {
|
||||
for i in 0..per {
|
||||
fit.push(eval(k, i as f64 / per as f64)?);
|
||||
}
|
||||
}
|
||||
let mut deviation = f64::INFINITY;
|
||||
loop {
|
||||
let mut check = Vec::with_capacity(fit.len());
|
||||
for k in 0..sides {
|
||||
for i in 0..per {
|
||||
check.push(eval(k, (i as f64 + 0.5) / per as f64)?);
|
||||
}
|
||||
}
|
||||
// The loop in order: fitted and checked points alternating.
|
||||
let all: Vec<Vec3> = fit.iter().zip(&check).flat_map(|(a, b)| [*a, *b]).collect();
|
||||
match fit_once(&fit, &all, tol)? {
|
||||
Fit::Done(mut fill) => {
|
||||
fill.evaluations = evaluations;
|
||||
return Ok(*fill);
|
||||
}
|
||||
Fit::Denser(dev) => deviation = deviation.min(dev),
|
||||
Fit::Exhausted(dev) => {
|
||||
deviation = deviation.min(dev);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if all.len() * 2 > MAX_LOOP_POINTS {
|
||||
break;
|
||||
}
|
||||
fit = all;
|
||||
per *= 2;
|
||||
}
|
||||
Err(SplineError::Unsupported(format!("filling a loop this wavy with one surface (it stays {deviation:e} off the loop, tolerance {tol:e})")))
|
||||
}
|
||||
|
||||
/// What one round of [`fill_loop`] gave.
|
||||
enum Fit {
|
||||
Done(Box<LoopFill>),
|
||||
/// Within tolerance only with cells finer than the points allow: sample denser.
|
||||
Denser(f64),
|
||||
/// The finest grid tried is not within tolerance.
|
||||
Exhausted(f64),
|
||||
}
|
||||
|
||||
/// One round of [`fill_loop`]: fit the points `fit`, check every point of `all` (the loop in
|
||||
/// order, `fit` among them).
|
||||
fn fit_once(fit: &[Vec3], all: &[Vec3], tol: f64) -> SplineResult<Fit> {
|
||||
let m = fit.len();
|
||||
let o = fit.iter().fold(Vec3::ZERO, |a, p| a + *p) / m as f64;
|
||||
let at = |k: usize| fit.get(k % m).copied().unwrap_or(Vec3::ZERO);
|
||||
let area = (0..m).fold(Vec3::ZERO, |a, k| a + (at(k) - o).cross(at(k + 1) - o));
|
||||
let Some(normal) = area.normalized() else { return invalid("the loop encloses no area") };
|
||||
let largest = [normal.x, normal.y, normal.z].into_iter().fold(0.0f64, |a, c| if c.abs() > a.abs() { c } else { a });
|
||||
let counter_clockwise = largest > 0.0;
|
||||
let n = if counter_clockwise { normal } else { normal * -1.0 };
|
||||
// The first axis towards the fitted point farthest from the centroid in the plane: the axes,
|
||||
// the grid and so the fill move with the loop.
|
||||
let flat = |p: &Vec3| {
|
||||
let d = *p - o;
|
||||
d - n * d.dot(n)
|
||||
};
|
||||
// Points as far as each other (within rounding) are told apart by their height, and points
|
||||
// alike in both by their coordinates: the same point is chosen however the loop is given,
|
||||
// and as the loop moves, but for ties its symmetry makes.
|
||||
let key = |p: &Vec3| (flat(p).len(), (*p - o).dot(n));
|
||||
let reach = fit.iter().map(|p| key(p).0).fold(0.0, f64::max);
|
||||
let near = 1e-9 * reach;
|
||||
let far = fit.iter().fold(None::<&Vec3>, |best, p| match best {
|
||||
None => Some(p),
|
||||
Some(b) => {
|
||||
let ((db, hb), (dp, hp)) = (key(b), key(p));
|
||||
let better = if (dp - db).abs() > near {
|
||||
dp > db
|
||||
} else if (hp - hb).abs() > near {
|
||||
hp > hb
|
||||
} else {
|
||||
(p.x, p.y, p.z) > (b.x, b.y, b.z)
|
||||
};
|
||||
Some(if better { p } else { b })
|
||||
}
|
||||
});
|
||||
let far = far.map(flat).unwrap_or(Vec3::ZERO);
|
||||
let x = far.normalized().ok_or_else(|| SplineError::Invalid("the loop has no extent in its plane".into()))?;
|
||||
let y = n.cross(x);
|
||||
let project = |pts: &[Vec3]| -> (Vec<[f64; 2]>, Vec<f64>) {
|
||||
(pts.iter().map(|p| [(*p - o).dot(x), (*p - o).dot(y)]).collect(), pts.iter().map(|p| (*p - o).dot(n)).collect())
|
||||
};
|
||||
let (uv, h) = project(all);
|
||||
check_projectable(all, &uv)?;
|
||||
let (fuv, fh) = project(fit);
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
|
||||
for q in &uv {
|
||||
for d in 0..2 {
|
||||
lo[d] = lo[d].min(q[d]);
|
||||
hi[d] = hi[d].max(q[d]);
|
||||
}
|
||||
}
|
||||
for d in 0..2 {
|
||||
let pad = 0.01 * (hi[d] - lo[d]);
|
||||
lo[d] -= pad;
|
||||
hi[d] += pad;
|
||||
}
|
||||
if !(lo[0] < hi[0] && lo[1] < hi[1] && (hi[0] - lo[0]).is_finite() && (hi[1] - lo[1]).is_finite()) {
|
||||
return invalid("the loop's extent is not finite");
|
||||
}
|
||||
// Spans per direction: `level` across the longer side of the box, as many across the
|
||||
// shorter as keep the cells about square (at least one). A grid finer than the fitted
|
||||
// points can hold in a direction (cells under twice their largest step along it) is tried
|
||||
// only after denser sampling; the coarsest is always tried.
|
||||
let extent = [hi[0] - lo[0], hi[1] - lo[1]];
|
||||
let mut step = [0.0f64; 2];
|
||||
for k in 0..m {
|
||||
let (p, q) = (fuv.get(k).copied().unwrap_or([0.0; 2]), fuv.get((k + 1) % m).copied().unwrap_or([0.0; 2]));
|
||||
for d in 0..2 {
|
||||
step[d] = step[d].max((q[d] - p[d]).abs());
|
||||
}
|
||||
}
|
||||
let longer = extent[0].max(extent[1]);
|
||||
let mut deviation = f64::INFINITY;
|
||||
let mut level = MIN_FILL_SPANS;
|
||||
while level <= MAX_FILL_SPANS {
|
||||
let spans = [0, 1].map(|d| ((extent[d] / longer * level as f64).ceil() as usize).clamp(1, level));
|
||||
let holds = (0..2).all(|d| extent[d] / spans[d] as f64 >= 2.0 * step[d]);
|
||||
if !holds && level > MIN_FILL_SPANS {
|
||||
return Ok(Fit::Denser(deviation));
|
||||
}
|
||||
let grid = Grid::new(spans, lo, hi);
|
||||
let heights = grid.fit(&fuv, &fh)?;
|
||||
let dev = uv.iter().zip(&h).map(|(q, hk)| (grid.height(&heights, *q) - hk).abs()).fold(0.0, f64::max);
|
||||
if !dev.is_finite() {
|
||||
return invalid("the fit is not finite");
|
||||
}
|
||||
deviation = deviation.min(dev);
|
||||
if dev <= tol && holds {
|
||||
let surface = grid.surface(&heights, o, x, y, n)?;
|
||||
let fill = LoopFill { surface, origin: o, axes: [x, y], normal: n, counter_clockwise, deviation: dev, evaluations: 0 };
|
||||
return Ok(Fit::Done(Box::new(fill)));
|
||||
}
|
||||
if !holds {
|
||||
return Ok(Fit::Denser(deviation));
|
||||
}
|
||||
level *= 2;
|
||||
}
|
||||
Ok(Fit::Exhausted(deviation))
|
||||
}
|
||||
|
||||
/// Refuse a loop that does not bound a height field over its plane (see [`fill_loop`]).
|
||||
fn check_projectable(points: &[Vec3], uv: &[[f64; 2]]) -> SplineResult<()> {
|
||||
let m = uv.len();
|
||||
let q = |k: usize| uv.get(k % m).copied().unwrap_or([0.0; 2]);
|
||||
let p = |k: usize| points.get(k % m).copied().unwrap_or(Vec3::ZERO);
|
||||
let step = |k: usize| {
|
||||
let (a, b) = (q(k), q(k + 1));
|
||||
[b[0] - a[0], b[1] - a[1]]
|
||||
};
|
||||
for k in 0..m {
|
||||
let d = step(k);
|
||||
let flat = d[0].hypot(d[1]);
|
||||
let space = p(k).dist(p(k + 1));
|
||||
if space > 0.0 && flat < MIN_STEP_SLOPE * space {
|
||||
return Err(SplineError::Unsupported("filling a loop that runs along its plane's normal".into()));
|
||||
}
|
||||
let e = step(k + 1);
|
||||
let fe = e[0].hypot(e[1]);
|
||||
if flat > 0.0 && fe > 0.0 && (d[0] * e[0] + d[1] * e[1]) < FOLD_COS * flat * fe {
|
||||
return Err(SplineError::Unsupported("filling a loop that folds back on itself".into()));
|
||||
}
|
||||
}
|
||||
// Non-adjacent segments must not meet: a sweep over the segments' u extents.
|
||||
let mut segs: Vec<(f64, f64, usize)> = (0..m)
|
||||
.map(|k| {
|
||||
let (a, b) = (q(k), q(k + 1));
|
||||
(a[0].min(b[0]), a[0].max(b[0]), k)
|
||||
})
|
||||
.collect();
|
||||
segs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
for (i, &(_, hi, k)) in segs.iter().enumerate() {
|
||||
for &(lo2, _, l) in segs.get(i + 1..).into_iter().flatten() {
|
||||
if lo2 > hi {
|
||||
break;
|
||||
}
|
||||
let adjacent = (k + 1) % m == l || (l + 1) % m == k;
|
||||
if !adjacent && segments_meet(q(k), q(k + 1), q(l), q(l + 1)) {
|
||||
return Err(SplineError::Unsupported("filling a loop that crosses itself seen along its plane's normal".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the closed segments `ab` and `cd` share a point.
|
||||
fn segments_meet(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
|
||||
let orient = |p: [f64; 2], q: [f64; 2], r: [f64; 2]| (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
|
||||
let within =
|
||||
|p: [f64; 2], q: [f64; 2], r: [f64; 2]| r[0] >= p[0].min(q[0]) && r[0] <= p[0].max(q[0]) && r[1] >= p[1].min(q[1]) && r[1] <= p[1].max(q[1]);
|
||||
let (d1, d2, d3, d4) = (orient(c, d, a), orient(c, d, b), orient(a, b, c), orient(a, b, d));
|
||||
if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) {
|
||||
return true;
|
||||
}
|
||||
(d1 == 0.0 && within(c, d, a)) || (d2 == 0.0 && within(c, d, b)) || (d3 == 0.0 && within(a, b, c)) || (d4 == 0.0 && within(a, b, d))
|
||||
}
|
||||
|
||||
/// A bicubic height field's grid: uniform clamped knots over `[lo, hi]` in each direction,
|
||||
/// `count` control points along each.
|
||||
struct Grid {
|
||||
knots: [Vec<f64>; 2],
|
||||
count: [usize; 2],
|
||||
}
|
||||
|
||||
impl LoopFill {
|
||||
/// The surface's point over (or under) `p`, along the normal.
|
||||
pub fn point_over(&self, p: Vec3) -> SplineResult<Vec3> {
|
||||
let d = p - self.origin;
|
||||
let [x, y] = self.axes;
|
||||
self.surface.eval(d.dot(x), d.dot(y))
|
||||
}
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
fn new(spans: [usize; 2], lo: [f64; 2], hi: [f64; 2]) -> Grid {
|
||||
let knots = [0, 1].map(|d| {
|
||||
let (a, b, s) = (lo[d], hi[d], spans[d]);
|
||||
let mut k = vec![a; 4];
|
||||
k.extend((1..s).map(|i| a + (b - a) * i as f64 / s as f64));
|
||||
k.extend([b; 4]);
|
||||
k
|
||||
});
|
||||
Grid { knots, count: spans.map(|s| s + 3) }
|
||||
}
|
||||
|
||||
/// The length of one span in direction `d`.
|
||||
fn cell(&self, d: usize) -> f64 {
|
||||
let (Some(k), Some(&c)) = (self.knots.get(d), self.count.get(d)) else { return 0.0 };
|
||||
(k.last().copied().unwrap_or(0.0) - k.first().copied().unwrap_or(0.0)) / (c - 3) as f64
|
||||
}
|
||||
|
||||
/// The knot average `(kᵢ₊₁ + kᵢ₊₂ + kᵢ₊₃)/3` of control point `i` in direction `d`.
|
||||
fn average(&self, d: usize, i: usize) -> f64 {
|
||||
self.knots.get(d).map_or(0.0, |k| (1..=3).map(|t| k.get(i + t).copied().unwrap_or(0.0)).sum::<f64>() / 3.0)
|
||||
}
|
||||
|
||||
/// The four non-zero basis functions' `r`-th derivatives and the first index, at `x` in
|
||||
/// direction `d`.
|
||||
fn basis_derivative(&self, d: usize, x: f64, r: usize) -> Option<(usize, [f64; 4])> {
|
||||
let kn = self.knots.get(d)?;
|
||||
let x = x.clamp(*kn.first()?, *kn.last()?);
|
||||
let span = find_span(kn, 3, *self.count.get(d)?, x)?;
|
||||
let side = if x >= *kn.last()? { Side::Left } else { Side::Right };
|
||||
let first = span.checked_sub(3)?;
|
||||
let mut out = [0.0; 4];
|
||||
for (a, o) in out.iter_mut().enumerate() {
|
||||
*o = kn.get(first + a..first + a + 5).map_or(0.0, |l| local_basis_array(l, x, side, r).get(r).copied().unwrap_or(0.0));
|
||||
}
|
||||
Some((first, out))
|
||||
}
|
||||
|
||||
/// The height at `q` of the net `heights` (i fastest).
|
||||
fn height(&self, heights: &[f64], q: [f64; 2]) -> f64 {
|
||||
let (Some((i0, bu)), Some((j0, bv))) = (self.basis_derivative(0, q[0], 0), self.basis_derivative(1, q[1], 0)) else { return f64::NAN };
|
||||
let mut s = 0.0;
|
||||
for (b, wv) in bv.iter().enumerate() {
|
||||
for (a, wu) in bu.iter().enumerate() {
|
||||
s += wu * wv * heights.get((j0 + b) * self.count[0] + i0 + a).copied().unwrap_or(f64::NAN);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The control heights fitting the points (see [`fill_loop`]): the normal equations, banded
|
||||
/// (unknowns i fastest, so coupled unknowns are at most `3·count[0] + 3` apart), solved by
|
||||
/// Cholesky factorisation.
|
||||
fn fit(&self, uv: &[[f64; 2]], h: &[f64]) -> SplineResult<Vec<f64>> {
|
||||
let [cu, cv] = self.count;
|
||||
let n = cu * cv;
|
||||
let mut band = Band::new(n, 3 * cu + 3);
|
||||
let mut rhs = vec![0.0; n];
|
||||
let mut fitted = 0usize;
|
||||
for (q, hk) in uv.iter().zip(h) {
|
||||
let (Some((i0, bu)), Some((j0, bv))) = (self.basis_derivative(0, q[0], 0), self.basis_derivative(1, q[1], 0)) else {
|
||||
return invalid("a loop point is off the grid");
|
||||
};
|
||||
let mut terms = [(0usize, 0.0f64); 16];
|
||||
for (t, slot) in terms.iter_mut().enumerate() {
|
||||
let (a, b) = (t % 4, t / 4);
|
||||
*slot = ((j0 + b) * cu + i0 + a, bu.get(a).copied().unwrap_or(0.0) * bv.get(b).copied().unwrap_or(0.0));
|
||||
}
|
||||
for &(r, wr) in &terms {
|
||||
if let Some(slot) = rhs.get_mut(r) {
|
||||
*slot += wr * hk;
|
||||
}
|
||||
for &(s, ws) in &terms {
|
||||
band.add(r, s, wr * ws);
|
||||
}
|
||||
}
|
||||
fitted += 1;
|
||||
}
|
||||
// The thin-plate energy ∫∫ h_uu² + 2·h_uv² + h_vv² over the grid's box, times the area of
|
||||
// one cell: per cell that is the squared second difference of the heights, so it weighs
|
||||
// against the data term (squared heights) alike at any size. Each term is a product of
|
||||
// one-direction Gram matrices of the basis functions' derivatives.
|
||||
let lambda = SMOOTHNESS * fitted as f64 / n as f64 * self.cell(0) * self.cell(1);
|
||||
let gram = |d: usize, r: usize| -> Vec<f64> {
|
||||
let c = self.count.get(d).copied().unwrap_or(0);
|
||||
let mut g = vec![0.0; c * c];
|
||||
let Some(k) = self.knots.get(d) else { return g };
|
||||
for w in k.windows(2) {
|
||||
let &[a, b] = w else { continue };
|
||||
if !(b > a) {
|
||||
continue;
|
||||
}
|
||||
for (gx, gw) in GAUSS4 {
|
||||
let x = 0.5 * (a + b) + 0.5 * (b - a) * gx;
|
||||
let Some((first, vals)) = self.basis_derivative(d, x, r) else { continue };
|
||||
for (i, vi) in vals.iter().enumerate() {
|
||||
for (j, vj) in vals.iter().enumerate() {
|
||||
if let Some(slot) = g.get_mut((first + i) * c + first + j) {
|
||||
*slot += 0.5 * (b - a) * gw * vi * vj;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
g
|
||||
};
|
||||
let grams: [[Vec<f64>; 3]; 2] = [0, 1].map(|d| [0, 1, 2].map(|r| gram(d, r)));
|
||||
let g = |d: usize, r: usize, i: usize, k: usize| {
|
||||
let c = self.count.get(d).copied().unwrap_or(0);
|
||||
grams.get(d).and_then(|m| m.get(r)).and_then(|m| m.get(i * c + k)).copied().unwrap_or(0.0)
|
||||
};
|
||||
for j in 0..cv {
|
||||
for i in 0..cu {
|
||||
let r = j * cu + i;
|
||||
for l in j.saturating_sub(3)..(j + 4).min(cv) {
|
||||
for k in i.saturating_sub(3)..(i + 4).min(cu) {
|
||||
let e: f64 = ENERGY.iter().map(|&(ru, rv, w)| w * g(0, ru, i, k) * g(1, rv, j, l)).sum();
|
||||
band.add(r, l * cu + k, lambda * e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
band.solve(rhs)
|
||||
}
|
||||
|
||||
/// The surface of the net `heights` over the plane `o`, `x`, `y`, `n`: control point `(i, j)`
|
||||
/// at the knot averages `(ξᵢ, ηⱼ)` in the plane, `heights[j·count[0] + i]` along `n`.
|
||||
fn surface(&self, heights: &[f64], o: Vec3, x: Vec3, y: Vec3, n: Vec3) -> SplineResult<NurbsSurface> {
|
||||
let [cu, cv] = self.count;
|
||||
let mut ctrl = Vec::with_capacity(cu * cv);
|
||||
for j in 0..cv {
|
||||
for i in 0..cu {
|
||||
let hij = heights.get(j * cu + i).copied().unwrap_or(f64::NAN);
|
||||
ctrl.push(HPoint::weighted(o + x * self.average(0, i) + y * self.average(1, j) + n * hij, 1.0));
|
||||
}
|
||||
}
|
||||
NurbsSurface::from_homogeneous([3, 3], self.knots.clone(), ctrl)
|
||||
}
|
||||
}
|
||||
|
||||
/// A symmetric banded matrix: the upper band, row `r` holding columns `r ..= r + bw`.
|
||||
struct Band {
|
||||
n: usize,
|
||||
bw: usize,
|
||||
a: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Band {
|
||||
fn new(n: usize, bw: usize) -> Band {
|
||||
Band { n, bw, a: vec![0.0; n * (bw + 1)] }
|
||||
}
|
||||
fn index(&self, r: usize, s: usize) -> Option<usize> {
|
||||
let (r, s) = if r <= s { (r, s) } else { (s, r) };
|
||||
(s - r <= self.bw && s < self.n).then_some(r * (self.bw + 1) + (s - r))
|
||||
}
|
||||
fn get(&self, r: usize, s: usize) -> f64 {
|
||||
self.index(r, s).and_then(|i| self.a.get(i)).copied().unwrap_or(0.0)
|
||||
}
|
||||
fn set(&mut self, r: usize, s: usize, v: f64) {
|
||||
if let Some(x) = self.index(r, s).and_then(|i| self.a.get_mut(i)) {
|
||||
*x = v;
|
||||
}
|
||||
}
|
||||
/// Add `v` at `(r, s)` with `r ≤ s` (the lower half is the same by symmetry: callers adding
|
||||
/// a full outer product reach each stored entry once through `r ≤ s`).
|
||||
fn add(&mut self, r: usize, s: usize, v: f64) {
|
||||
if r <= s
|
||||
&& let Some(x) = self.index(r, s).and_then(|i| self.a.get_mut(i))
|
||||
{
|
||||
*x += v;
|
||||
}
|
||||
}
|
||||
/// Solve `A·x = b` by banded Cholesky factorisation `A = Uᵀ·U`; an error when `A` is not
|
||||
/// positive definite.
|
||||
fn solve(mut self, mut b: Vec<f64>) -> SplineResult<Vec<f64>> {
|
||||
let (n, bw) = (self.n, self.bw);
|
||||
for r in 0..n {
|
||||
let mut d = self.get(r, r);
|
||||
for k in r.saturating_sub(bw)..r {
|
||||
let u = self.get(k, r);
|
||||
d -= u * u;
|
||||
}
|
||||
if !(d > 0.0) {
|
||||
return invalid("the fit's equations are singular");
|
||||
}
|
||||
let d = d.sqrt();
|
||||
self.set(r, r, d);
|
||||
for s in r + 1..(r + bw + 1).min(n) {
|
||||
let mut v = self.get(r, s);
|
||||
for k in s.saturating_sub(bw)..r {
|
||||
v -= self.get(k, r) * self.get(k, s);
|
||||
}
|
||||
self.set(r, s, v / d);
|
||||
}
|
||||
}
|
||||
// Uᵀ·y = b, then U·x = y.
|
||||
for r in 0..n {
|
||||
let mut v = b.get(r).copied().unwrap_or(0.0);
|
||||
for k in r.saturating_sub(bw)..r {
|
||||
v -= self.get(k, r) * b.get(k).copied().unwrap_or(0.0);
|
||||
}
|
||||
if let Some(x) = b.get_mut(r) {
|
||||
*x = v / self.get(r, r);
|
||||
}
|
||||
}
|
||||
for r in (0..n).rev() {
|
||||
let mut v = b.get(r).copied().unwrap_or(0.0);
|
||||
for s in r + 1..(r + bw + 1).min(n) {
|
||||
v -= self.get(r, s) * b.get(s).copied().unwrap_or(0.0);
|
||||
}
|
||||
if let Some(x) = b.get_mut(r) {
|
||||
*x = v / self.get(r, r);
|
||||
}
|
||||
}
|
||||
if b.iter().any(|x| !x.is_finite()) {
|
||||
return invalid("the fit is not finite");
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The closed polygon through `corners` in space, side `k` from corner `k` to `k+1`.
|
||||
fn polygon3(corners: &[Vec3]) -> impl FnMut(usize, f64) -> Option<Vec3> + '_ {
|
||||
move |k, t| {
|
||||
let n = corners.len();
|
||||
Some(corners.get(k)?.lerp(*corners.get((k + 1) % n)?, t))
|
||||
}
|
||||
}
|
||||
|
||||
/// The polygon `corners` in the plane, lifted by `z(x, y)` all along.
|
||||
fn lifted<'a>(corners: &'a [[f64; 2]], z: &'a dyn Fn(f64, f64) -> f64) -> impl FnMut(usize, f64) -> Option<Vec3> + 'a {
|
||||
move |k, t| {
|
||||
let n = corners.len();
|
||||
let (a, b) = (corners.get(k)?, corners.get((k + 1) % n)?);
|
||||
let (x, y) = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t);
|
||||
Some(Vec3::new(x, y, z(x, y)))
|
||||
}
|
||||
}
|
||||
|
||||
fn ring(n: usize, r: f64) -> Vec<[f64; 2]> {
|
||||
(0..n).map(|k| k as f64 * std::f64::consts::TAU / n as f64).map(|a| [r * a.cos(), r * a.sin()]).collect()
|
||||
}
|
||||
|
||||
/// The farthest the surface is from `z` over the polygon's inside (sampled on a grid).
|
||||
fn off_inside(f: &LoopFill, corners: &[[f64; 2]], z: &dyn Fn(f64, f64) -> f64) -> f64 {
|
||||
let inside = |x: f64, y: f64| {
|
||||
let n = corners.len();
|
||||
let mut w = false;
|
||||
for k in 0..n {
|
||||
let (a, b) = (corners[k], corners[(k + 1) % n]);
|
||||
if (a[1] > y) != (b[1] > y) && x < a[0] + (y - a[1]) / (b[1] - a[1]) * (b[0] - a[0]) {
|
||||
w = !w;
|
||||
}
|
||||
}
|
||||
w
|
||||
};
|
||||
let s = &f.surface;
|
||||
let [du, dv] = s.domain();
|
||||
let mut worst = 0.0f64;
|
||||
for i in 0..=40 {
|
||||
for j in 0..=40 {
|
||||
let (u, v) = ((du[0] + (du[1] - du[0]) * i as f64 / 40.0).min(du[1]), (dv[0] + (dv[1] - dv[0]) * j as f64 / 40.0).min(dv[1]));
|
||||
let p = s.eval(u, v).unwrap();
|
||||
if inside(p.x, p.y) {
|
||||
worst = worst.max((p.z - z(p.x, p.y)).abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planes_are_reproduced_and_rims_of_quadrics_are_filled_closely() {
|
||||
// A tilted plane over a hexagon: the smoothness energy vanishes on it, so the fill is it.
|
||||
let hex = ring(6, 10.0);
|
||||
let plane = |x: f64, y: f64| 0.3 * x - 0.2 * y + 1.0;
|
||||
let f = fill_loop(6, &mut lifted(&hex, &plane), 1e-6).unwrap();
|
||||
let inside = off_inside(&f, &hex, &plane);
|
||||
eprintln!("plane: deviation {:e}, inside off by {inside:e}, {} evaluations", f.deviation, f.evaluations);
|
||||
assert!(f.deviation <= 1e-6 && inside < 1e-9);
|
||||
// A saddle and a dome: the least-bending fill through the rim is not the quadric (only
|
||||
// planes are reproduced), but stays near it (the plate is free to turn at the rim).
|
||||
let saddle = |x: f64, y: f64| 0.05 * x * y;
|
||||
let dome = |x: f64, y: f64| 3.0 - 0.02 * (x * x + y * y);
|
||||
for (name, z, bound) in [("saddle", &saddle as &dyn Fn(f64, f64) -> f64, 0.1), ("dome", &dome, 1.0)] {
|
||||
let f = fill_loop(6, &mut lifted(&hex, z), 2e-5).unwrap();
|
||||
let inside = off_inside(&f, &hex, z);
|
||||
eprintln!("{name}: deviation {:e}, inside off by {inside:e}", f.deviation);
|
||||
assert!(f.deviation <= 2e-5 && inside < bound, "{name}: {inside}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concave_loops_fill_and_the_result_does_not_depend_on_where_the_loop_starts() {
|
||||
// A dart, an arrow-head pentagon and a plus, on a cylinder.
|
||||
let z = |x: f64, _: f64| 50.0 - (1600.0 - x * x).sqrt();
|
||||
let dart = vec![[0.0, 0.0], [20.0, -10.0], [8.0, 0.0], [20.0, 10.0]];
|
||||
let arrow = vec![[0.0, 0.0], [10.0, -8.0], [10.0, -3.0], [20.0, 0.0], [10.0, 3.0]];
|
||||
let plus = vec![
|
||||
[-3.0, -9.0],
|
||||
[3.0, -9.0],
|
||||
[3.0, -3.0],
|
||||
[9.0, -3.0],
|
||||
[9.0, 3.0],
|
||||
[3.0, 3.0],
|
||||
[3.0, 9.0],
|
||||
[-3.0, 9.0],
|
||||
[-3.0, 3.0],
|
||||
[-9.0, 3.0],
|
||||
[-9.0, -3.0],
|
||||
[-3.0, -3.0],
|
||||
];
|
||||
let flat_corner = vec![[-10.0, -10.0], [0.0, -10.0], [10.0, -10.0], [10.0, 10.0], [-10.0, 10.0]];
|
||||
for (name, c) in [("dart", dart), ("arrow", arrow), ("plus", plus), ("flat corner", flat_corner)] {
|
||||
let n = c.len();
|
||||
let f = fill_loop(n, &mut lifted(&c, &z), 1e-4).unwrap();
|
||||
let inside = off_inside(&f, &c, &z);
|
||||
eprintln!("{name}: deviation {:e}, inside off the cylinder by {inside:e}, {} evaluations", f.deviation, f.evaluations);
|
||||
// The least-bending fill, not the cylinder: within a few tenths of a millimetre of it.
|
||||
assert!(f.deviation <= 1e-4 && inside < 0.2, "{name}: {inside}");
|
||||
// The same loop from another corner and the other way round.
|
||||
let mut back = c.clone();
|
||||
back.rotate_left(2);
|
||||
back.reverse();
|
||||
let g = fill_loop(n, &mut lifted(&back, &z), 1e-4).unwrap();
|
||||
assert_ne!(f.counter_clockwise, g.counter_clockwise);
|
||||
for (x, y) in [(4.0, 0.0), (6.0, 1.0), (5.0, -1.0)] {
|
||||
let p = Vec3::new(x, y, z(x, y));
|
||||
let gap = f.point_over(p).unwrap().dist(g.point_over(p).unwrap());
|
||||
assert!(gap < 1e-7, "{name}: {gap}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fill_is_the_same_at_any_size_and_place() {
|
||||
// The arrow on the cylinder, scaled by 100 and by 1/100, turned and moved: the same fill,
|
||||
// scaled and moved alike.
|
||||
let z = |x: f64, _: f64| 50.0 - (1600.0 - x * x).sqrt();
|
||||
let arrow = [[0.0, 0.0], [10.0, -8.0], [10.0, -3.0], [20.0, 0.0], [10.0, 3.0]];
|
||||
let f = fill_loop(5, &mut lifted(&arrow, &z), 1e-4).unwrap();
|
||||
let axis = Vec3::new(1.0, -2.0, 0.5).normalized().unwrap();
|
||||
let shift = Vec3::new(30.0, -12.0, 7.0);
|
||||
for scale in [100.0, 0.01] {
|
||||
let place = |p: Vec3| {
|
||||
// Scaled, turned by 0.9 about `axis` (Rodrigues), moved.
|
||||
let p = p * scale;
|
||||
let (c, s) = (0.9f64.cos(), 0.9f64.sin());
|
||||
p * c + axis.cross(p) * s + axis * (axis.dot(p) * (1.0 - c)) + shift * scale
|
||||
};
|
||||
let mut moved = |k: usize, t: f64| lifted(&arrow, &z)(k, t).map(place);
|
||||
let g = fill_loop(5, &mut moved, 1e-4 * scale).unwrap();
|
||||
let mut worst = 0.0f64;
|
||||
for (x, y) in [(4.0, 0.0), (6.0, 1.0), (5.0, -1.0), (9.0, -2.0), (12.0, 0.5)] {
|
||||
let p = Vec3::new(x, y, z(x, y));
|
||||
worst = worst.max(g.point_over(place(p)).unwrap().dist(place(f.point_over(p).unwrap())) / scale);
|
||||
}
|
||||
eprintln!("arrow scaled by {scale}, turned and moved: {} evaluations, surfaces apart by {worst:e} (relative)", g.evaluations);
|
||||
assert!(worst < 1e-7, "scale {scale}: {worst}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_narrow_slot_fills() {
|
||||
// A 200 × 1 mm slot on a cylinder of radius 300 running along it.
|
||||
let z = |x: f64, _: f64| 300.0 - (90_000.0 - (x - 100.0) * (x - 100.0)).sqrt();
|
||||
let slot = [[0.0, 0.0], [200.0, 0.0], [200.0, 1.0], [0.0, 1.0]];
|
||||
let f = fill_loop(4, &mut lifted(&slot, &z), 2e-4).unwrap();
|
||||
let inside = off_inside(&f, &slot, &z);
|
||||
eprintln!("slot: deviation {:e}, inside off the cylinder by {inside:e}, {} evaluations", f.deviation, f.evaluations);
|
||||
assert!(f.deviation <= 2e-4 && inside < 1e-2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loops_that_do_not_bound_a_height_field_are_refused() {
|
||||
let unsupported = |r: SplineResult<LoopFill>| matches!(r, Err(SplineError::Unsupported(_)));
|
||||
let flat = |_: f64, _: f64| 0.0;
|
||||
// A figure eight (lobes of different sizes).
|
||||
let eight = [[0.0, 0.0], [10.0, 10.0], [10.0, 0.0], [0.0, 6.0]];
|
||||
assert!(unsupported(fill_loop(4, &mut lifted(&eight, &flat), 1e-4)));
|
||||
// A loop with a vertical spike in it (up along the normal and down again).
|
||||
let spike = [
|
||||
Vec3::ZERO,
|
||||
Vec3::new(10.0, 0.0, 0.0),
|
||||
Vec3::new(10.0, 10.0, 0.0),
|
||||
Vec3::new(5.0, 10.0, 0.0),
|
||||
Vec3::new(5.0, 10.0, 3.0),
|
||||
Vec3::new(5.0, 10.0, 0.0),
|
||||
Vec3::new(0.0, 10.0, 0.0),
|
||||
];
|
||||
assert!(unsupported(fill_loop(7, &mut polygon3(&spike), 1e-4)));
|
||||
// A loop that runs back over itself.
|
||||
let back = [[0.0, 0.0], [10.0, 0.0], [4.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
|
||||
assert!(unsupported(fill_loop(5, &mut lifted(&back, &flat), 1e-4)));
|
||||
// A loop too wavy for one surface.
|
||||
let wavy = |x: f64, y: f64| (x * 9.0).sin() * (y * 7.0).cos();
|
||||
assert!(unsupported(fill_loop(64, &mut lifted(&ring(64, 10.0), &wavy), 1e-6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_loops_are_refused() {
|
||||
let flat = |_: f64, _: f64| 0.0;
|
||||
let five = ring(5, 10.0);
|
||||
assert!(fill_loop(0, &mut lifted(&five, &flat), 1e-4).is_err());
|
||||
assert!(fill_loop(5, &mut |_, _| Some(Vec3::X), 1e-4).is_err(), "no area");
|
||||
assert!(fill_loop(5, &mut |_, _| None, 1e-4).is_err());
|
||||
assert!(fill_loop(5, &mut |k, t| Some(Vec3::new(k as f64, t, f64::NAN)), 1e-4).is_err());
|
||||
assert!(fill_loop(5, &mut lifted(&five, &flat), 0.0).is_err() && fill_loop(5, &mut lifted(&five, &flat), f64::NAN).is_err());
|
||||
assert!(fill_loop(10_000, &mut lifted(&five, &flat), 1e-4).is_err());
|
||||
let huge = |x: f64, y: f64| (x * y) * 1e300;
|
||||
let _ = fill_loop(5, &mut lifted(&five, &huge), 1e-4);
|
||||
let tiny: Vec<[f64; 2]> = five.iter().map(|p| [p[0] * 1e-300, p[1] * 1e-300]).collect();
|
||||
let _ = fill_loop(5, &mut lifted(&tiny, &flat), 1e-4);
|
||||
let big: Vec<[f64; 2]> = five.iter().map(|p| [p[0] * 1e300, p[1] * 1e300]).collect();
|
||||
let _ = fill_loop(5, &mut lifted(&big, &flat), 1e-4);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Geometry foundation for SolidCraft: 2D/3D vectors, sketch planes, axis-aligned boxes,
|
||||
//! triangle meshes with their measures (volume, area, centroid), and freeform surface maths
|
||||
//! (B-spline basis and NURBS surfaces in [`bspline`], T-splines in [`tspline`]). Units are
|
||||
//! millimetres.
|
||||
//! (B-spline basis and NURBS surfaces in [`bspline`], one-surface fills of closed loops in
|
||||
//! [`fill`], T-splines in [`tspline`]). Units are millimetres.
|
||||
//!
|
||||
//! This crate has no knowledge of sketches, documents or the B-rep kernel; every other crate
|
||||
//! builds on it.
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
pub mod bspline;
|
||||
pub mod env;
|
||||
pub mod fill;
|
||||
mod mesh;
|
||||
mod plane;
|
||||
mod profile;
|
||||
|
||||
+136
-13
@@ -1,7 +1,7 @@
|
||||
//! Versioned design packages. Geometry is rebuilt from authoritative inputs; no
|
||||
//! evaluated cache is trusted here. All archive access stays in memory, never on disk.
|
||||
//! evaluated cache is trusted here. Entries are never extracted to filesystem paths.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -219,18 +219,25 @@ fn decode_mesh(bytes: &[u8], mesh: &mut MeshData) -> Result<()> {
|
||||
validate_mesh(mesh)
|
||||
}
|
||||
|
||||
struct PackageReader<'a> {
|
||||
zip: zip::ZipArchive<Cursor<&'a [u8]>>,
|
||||
struct PackageReader<R: Read + Seek> {
|
||||
zip: zip::ZipArchive<R>,
|
||||
blobs: BTreeMap<String, Vec<u8>>,
|
||||
hydrated: usize,
|
||||
}
|
||||
|
||||
impl<'a> PackageReader<'a> {
|
||||
fn new(bytes: &'a [u8]) -> Result<Self> {
|
||||
impl<R: Read + Seek> PackageReader<R> {
|
||||
fn new(mut source: R) -> Result<Self> {
|
||||
let length = source.seek(SeekFrom::End(0)).map_err(invalid)?;
|
||||
if length > MAX_DESIGN_BYTES as u64 {
|
||||
return Err(invalid("compressed file too large"));
|
||||
}
|
||||
let tail_length = length.min(65_557) as usize;
|
||||
source.seek(SeekFrom::Start(length - tail_length as u64)).map_err(invalid)?;
|
||||
let mut tail = vec![0; tail_length];
|
||||
source.read_exact(&mut tail).map_err(invalid)?;
|
||||
// zip's name index can coalesce duplicate central-directory entries. Our
|
||||
// bounded format does not need ZIP64/multiple disks: verify the declared
|
||||
// entry count as well so duplicate names cannot silently select a payload.
|
||||
let tail = bytes.get(bytes.len().saturating_sub(65_557)..).ok_or_else(|| invalid("missing ZIP directory"))?;
|
||||
let eocd = tail.windows(4).rposition(|v| v == b"PK\x05\x06").ok_or_else(|| invalid("missing ZIP directory"))?;
|
||||
let end = tail.get(eocd..).filter(|v| v.len() >= 22).ok_or_else(|| invalid("truncated ZIP directory"))?;
|
||||
let u16_at = |offset| -> Result<u16> {
|
||||
@@ -241,7 +248,7 @@ impl<'a> PackageReader<'a> {
|
||||
if declared > MAX_ENTRIES || u16_at(4)? != 0 || u16_at(6)? != 0 || u16_at(8)? as usize != declared || end.len() != 22 + u16_at(20)? as usize {
|
||||
return Err(invalid("too many entries or unsupported ZIP directory"));
|
||||
}
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(invalid)?;
|
||||
let mut zip = zip::ZipArchive::new(source).map_err(invalid)?;
|
||||
if u16_at(4)? != 0
|
||||
|| u16_at(6)? != 0
|
||||
|| u16_at(8)? as usize != zip.len()
|
||||
@@ -342,11 +349,10 @@ pub fn read_design_preview(bytes: &[u8]) -> Option<DesignPreview> {
|
||||
if bytes.len() > MAX_DESIGN_BYTES || !is_design_container(bytes) {
|
||||
return None;
|
||||
}
|
||||
read_preview(bytes).ok()
|
||||
DesignContainerReader::new(Cursor::new(bytes)).ok()?.preview()
|
||||
}
|
||||
|
||||
fn read_preview(bytes: &[u8]) -> Result<DesignPreview> {
|
||||
let mut reader = PackageReader::new(bytes)?;
|
||||
fn read_preview<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<DesignPreview> {
|
||||
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
|
||||
if !matches!(container.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
|
||||
return Err(invalid("unsupported container version"));
|
||||
@@ -398,8 +404,38 @@ fn read_preview(bytes: &[u8]) -> Result<DesignPreview> {
|
||||
Ok(preview)
|
||||
}
|
||||
|
||||
/// A bounded container reader over an immutable, seekable source.
|
||||
///
|
||||
/// Opening inspects the directory, not the source blobs. `preview` reads only the
|
||||
/// optional display entries and document metadata; `read_document` then resolves
|
||||
/// and validates authoritative blobs. Keep the source unchanged for this reader's
|
||||
/// lifetime. This type does not turn untrusted cached data into editable geometry.
|
||||
pub struct DesignContainerReader<R: Read + Seek> {
|
||||
reader: PackageReader<R>,
|
||||
}
|
||||
|
||||
impl<R: Read + Seek> DesignContainerReader<R> {
|
||||
/// Open a container (not plain JSON), enforcing the same budgets as byte IO.
|
||||
pub fn new(source: R) -> Result<Self> {
|
||||
Ok(Self { reader: PackageReader::new(source)? })
|
||||
}
|
||||
|
||||
/// Optional display-only data. Corruption or incompatibility is a cache miss.
|
||||
pub fn preview(&mut self) -> Option<DesignPreview> {
|
||||
read_preview(&mut self.reader).ok()
|
||||
}
|
||||
|
||||
/// Consume the reader, resolving every required blob before returning a document.
|
||||
pub fn read_document(mut self) -> Result<Document> {
|
||||
read_document(&mut self.reader)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn read(bytes: &[u8]) -> Result<Document> {
|
||||
let mut reader = PackageReader::new(bytes)?;
|
||||
DesignContainerReader::new(Cursor::new(bytes))?.read_document()
|
||||
}
|
||||
|
||||
fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Document> {
|
||||
let version: Value = serde_json::from_slice(&reader.part("container.json")?).map_err(invalid)?;
|
||||
if !matches!(version.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
|
||||
return Err(invalid("unsupported container version"));
|
||||
@@ -450,6 +486,85 @@ mod tests {
|
||||
use super::*;
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
struct CountingReader {
|
||||
source: Cursor<Vec<u8>>,
|
||||
count: std::rc::Rc<std::cell::Cell<usize>>,
|
||||
}
|
||||
|
||||
impl Read for CountingReader {
|
||||
fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
|
||||
let count = self.source.read(bytes)?;
|
||||
self.count.set(self.count.get() + count);
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for CountingReader {
|
||||
fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
|
||||
self.source.seek(position)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seekable_reader_rejects_oversized_source_before_reading() {
|
||||
struct Oversized;
|
||||
impl Read for Oversized {
|
||||
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
|
||||
panic!("oversized input must be rejected before any allocation or read");
|
||||
}
|
||||
}
|
||||
impl Seek for Oversized {
|
||||
fn seek(&mut self, _: SeekFrom) -> std::io::Result<u64> {
|
||||
Ok(MAX_DESIGN_BYTES as u64 + 1)
|
||||
}
|
||||
}
|
||||
assert!(DesignContainerReader::new(Oversized).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seekable_preview_defers_large_source_blob_until_document_read() {
|
||||
let mut doc = Document::new("Deferred source");
|
||||
let step = "ISO-10303-21;".repeat(200_000);
|
||||
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone(), components: Vec::new() }, None).unwrap();
|
||||
let preview = DesignPreview { meshes: vec![mesh()] };
|
||||
let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
|
||||
let count = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let source = CountingReader { source: Cursor::new(bytes), count: count.clone() };
|
||||
let mut reader = DesignContainerReader::new(source).unwrap();
|
||||
assert_eq!(reader.preview().unwrap().meshes[0].positions, preview.meshes[0].positions);
|
||||
// ZIP directory scans may touch a bounded suffix of a stored blob. They
|
||||
// must not load its multi-megabyte payload to obtain the saved picture.
|
||||
assert!(count.get() < 256 * 1024, "read {} bytes before display", count.get());
|
||||
let restored = reader.read_document().unwrap();
|
||||
assert_eq!(restored.to_json(), doc.to_json());
|
||||
assert!(count.get() >= step.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seekable_reader_validates_required_blobs_after_preview() {
|
||||
let doc = document();
|
||||
let preview = DesignPreview { meshes: vec![mesh()] };
|
||||
let mut bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
|
||||
let marker = b"ISO-10303-21;";
|
||||
let offset = bytes.windows(marker.len()).position(|part| part == marker).unwrap();
|
||||
bytes[offset] ^= 1;
|
||||
let mut reader = DesignContainerReader::new(Cursor::new(bytes)).unwrap();
|
||||
assert!(reader.preview().is_some());
|
||||
assert!(reader.read_document().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seekable_reader_without_preview_preserves_authoritative_document() {
|
||||
let doc = document();
|
||||
let bytes = write_design_container(&doc).unwrap();
|
||||
let mut source = Cursor::new(bytes);
|
||||
source.set_position(17); // Constructor starts from the whole source, not its cursor.
|
||||
let mut reader = DesignContainerReader::new(source).unwrap();
|
||||
assert!(reader.preview().is_none());
|
||||
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
|
||||
assert!(DesignContainerReader::new(Cursor::new(vec![0u8; 1])).is_err());
|
||||
}
|
||||
|
||||
fn mesh() -> MeshData {
|
||||
MeshData {
|
||||
name: "Precise mesh".into(),
|
||||
@@ -676,7 +791,7 @@ mod tests {
|
||||
let mut entries = parts(&bytes);
|
||||
let mut metadata: Value = serde_json::from_slice(&entries["document.json"]).unwrap();
|
||||
let reference = metadata["features"][0].clone();
|
||||
let mut reader = PackageReader::new(&bytes).unwrap();
|
||||
let mut reader = PackageReader::new(Cursor::new(&bytes)).unwrap();
|
||||
reader.hydrated = MAX_EXPANDED;
|
||||
assert!(reader.blob(&reference, "step_blob", "step").unwrap_err().to_string().contains("resolved payloads too large"));
|
||||
for name in ["../escape.step", "blobs/not-a-hash.step", "blobs/00.png"] {
|
||||
@@ -696,6 +811,10 @@ mod tests {
|
||||
std::panic::catch_unwind(|| {
|
||||
let _ = crate::read_design(&bytes[..end]);
|
||||
let _ = read_design_preview(&bytes[..end]);
|
||||
if let Ok(mut reader) = DesignContainerReader::new(Cursor::new(&bytes[..end])) {
|
||||
let _ = reader.preview();
|
||||
let _ = reader.read_document();
|
||||
}
|
||||
})
|
||||
.is_ok(),
|
||||
"truncated at {end}"
|
||||
@@ -713,6 +832,10 @@ mod tests {
|
||||
std::panic::catch_unwind(|| {
|
||||
let _ = crate::read_design(&changed);
|
||||
let _ = read_design_preview(&changed);
|
||||
if let Ok(mut reader) = DesignContainerReader::new(Cursor::new(&changed)) {
|
||||
let _ = reader.preview();
|
||||
let _ = reader.read_document();
|
||||
}
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
@@ -10,8 +10,8 @@ mod design;
|
||||
mod design_metadata;
|
||||
mod dxf;
|
||||
pub use design::{
|
||||
DesignCompression, DesignPreview, is_design_container, read_design_preview, write_design_container, write_design_container_with_compression,
|
||||
write_design_container_with_preview,
|
||||
DesignCompression, DesignContainerReader, DesignPreview, is_design_container, read_design_preview, write_design_container,
|
||||
write_design_container_with_compression, write_design_container_with_preview,
|
||||
};
|
||||
mod legacy_dir;
|
||||
pub mod safe_write;
|
||||
|
||||
@@ -634,8 +634,10 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
done = Some(x);
|
||||
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() {
|
||||
if first_err.is_none() || (matches!(e, KernelError::TooLarge(_)) && !matches!(first_err, Some(KernelError::TooLarge(_)))) {
|
||||
first_err = Some(e);
|
||||
}
|
||||
}
|
||||
@@ -909,7 +911,12 @@ pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
|
||||
// Corners the local blends refuse (an earlier round at an edge's end): radius per edge.
|
||||
blend(body, edges, radius, Shape::Round, "fillet").or_else(|e| {
|
||||
let sel: Vec<(Vec3, f64)> = edges.iter().map(|p| (*p, radius)).collect();
|
||||
crate::blend_corner::fillet_radii(body, &sel).map_err(|_| e)
|
||||
// The local blend's refusal, unless only the corner build found the radius too large.
|
||||
crate::blend_corner::fillet_radii(body, &sel).map_err(|e2| match (&e, e2) {
|
||||
(KernelError::TooLarge(_), _) => e,
|
||||
(_, e2 @ KernelError::TooLarge(_)) => e2,
|
||||
_ => e,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::blend::{plane_normal, unsupported, vtx};
|
||||
use crate::blend::{plane_normal, too_large, unsupported, vtx};
|
||||
use crate::body::{Body, Solid, p3, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
@@ -331,7 +331,7 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
|
||||
let check_extension = |q: Vec3, fi: usize| -> Result<Vec3> {
|
||||
let face = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
if !crate::blend::extension_fits(face, v, q, size * 1e-7) {
|
||||
return Err(unsupported("a fillet larger than the faces next to it"));
|
||||
return Err(too_large("a fillet larger than the faces next to it"));
|
||||
}
|
||||
Ok(q)
|
||||
};
|
||||
@@ -465,7 +465,7 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
|
||||
let (pa, pb) = (vtx(&a), vtx(&b));
|
||||
let orig = vtx(e.absolute_back()) - vtx(e.absolute_front());
|
||||
if (pb - pa).dot(orig) <= size * 1e-9 * orig.len() {
|
||||
return Err(unsupported("a fillet larger than the faces next to it"));
|
||||
return Err(too_large("a fillet larger than the faces next to it"));
|
||||
}
|
||||
let x = builder::line(&a, &b);
|
||||
lines.insert((e.id(), f), x.clone());
|
||||
|
||||
+80
-10
@@ -42,13 +42,54 @@ pub struct Body {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum FaceSource {
|
||||
ExtrudeSide { segment: solidcraft_geom::Seg2, plane: Option<solidcraft_geom::Plane>, coalesced: bool },
|
||||
CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String, plane: Option<solidcraft_geom::Plane> },
|
||||
ExtrudeCap { high: bool, region: solidcraft_geom::Region2, plane: solidcraft_geom::Plane },
|
||||
CertifiedExtrudeCap { producer: u64, sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>>, plane: solidcraft_geom::Plane },
|
||||
Box { producer: Option<u64>, axis: u8, high: bool, plane: solidcraft_geom::Plane },
|
||||
ShellPlanar { producer: Option<u64>, source: Box<FaceSource>, inner: bool, plane: solidcraft_geom::Plane },
|
||||
Imported { item: u64, face: u64, payload_sha256: Option<String>, producer: Option<u64> },
|
||||
ExtrudeSide {
|
||||
segment: solidcraft_geom::Seg2,
|
||||
plane: Option<solidcraft_geom::Plane>,
|
||||
coalesced: bool,
|
||||
},
|
||||
CertifiedExtrudeSide {
|
||||
producer: u64,
|
||||
sketch: u64,
|
||||
curve: String,
|
||||
plane: Option<solidcraft_geom::Plane>,
|
||||
},
|
||||
ExtrudeCap {
|
||||
high: bool,
|
||||
region: solidcraft_geom::Region2,
|
||||
plane: solidcraft_geom::Plane,
|
||||
},
|
||||
CertifiedExtrudeCap {
|
||||
producer: u64,
|
||||
sketch: u64,
|
||||
high: bool,
|
||||
outer: Vec<String>,
|
||||
holes: Vec<Vec<String>>,
|
||||
plane: solidcraft_geom::Plane,
|
||||
},
|
||||
Box {
|
||||
producer: Option<u64>,
|
||||
axis: u8,
|
||||
high: bool,
|
||||
plane: solidcraft_geom::Plane,
|
||||
},
|
||||
/// A cylinder primitive's cap: `high` at the far end (its `top`), else at the base.
|
||||
CylinderCap {
|
||||
producer: u64,
|
||||
high: bool,
|
||||
plane: solidcraft_geom::Plane,
|
||||
},
|
||||
ShellPlanar {
|
||||
producer: Option<u64>,
|
||||
source: Box<FaceSource>,
|
||||
inner: bool,
|
||||
plane: solidcraft_geom::Plane,
|
||||
},
|
||||
Imported {
|
||||
item: u64,
|
||||
face: u64,
|
||||
payload_sha256: Option<String>,
|
||||
producer: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
pub type CapSignature = (Vec<String>, Vec<Vec<String>>);
|
||||
@@ -58,9 +99,11 @@ impl FaceSource {
|
||||
fn plane_mut(&mut self) -> Option<&mut solidcraft_geom::Plane> {
|
||||
match self {
|
||||
Self::ExtrudeSide { plane, .. } | Self::CertifiedExtrudeSide { plane, .. } => plane.as_mut(),
|
||||
Self::ExtrudeCap { plane, .. } | Self::CertifiedExtrudeCap { plane, .. } | Self::Box { plane, .. } | Self::ShellPlanar { plane, .. } => {
|
||||
Some(plane)
|
||||
}
|
||||
Self::ExtrudeCap { plane, .. }
|
||||
| Self::CertifiedExtrudeCap { plane, .. }
|
||||
| Self::Box { plane, .. }
|
||||
| Self::CylinderCap { plane, .. }
|
||||
| Self::ShellPlanar { plane, .. } => Some(plane),
|
||||
Self::Imported { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -504,6 +547,7 @@ impl Body {
|
||||
FaceSource::ExtrudeCap { plane, .. }
|
||||
| FaceSource::CertifiedExtrudeCap { plane, .. }
|
||||
| FaceSource::Box { plane, .. }
|
||||
| FaceSource::CylinderCap { plane, .. }
|
||||
| FaceSource::ShellPlanar { plane, .. } => Some(*plane),
|
||||
FaceSource::Imported { .. } => self.planar_face(index),
|
||||
}
|
||||
@@ -529,6 +573,32 @@ impl Body {
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Certify the two caps of a body built by [`crate::cylinder`]. Its wall stays uncertified.
|
||||
pub fn with_cylinder_certificate(mut self, producer: u64) -> Result<Self> {
|
||||
if producer == 0 {
|
||||
return Err(KernelError::Invalid("invalid cylinder producer".into()));
|
||||
}
|
||||
let Some(sources) = &self.face_sources else {
|
||||
return Err(KernelError::Failed("cylinder construction source".into()));
|
||||
};
|
||||
let mut sources = sources.as_ref().clone();
|
||||
let mut ends = [false; 2];
|
||||
for source in sources.iter_mut() {
|
||||
if let Some(FaceSource::ExtrudeCap { high, plane, .. }) = source {
|
||||
let end = ends.get_mut(usize::from(*high)).ok_or_else(|| KernelError::Failed("cylinder cap role".into()))?;
|
||||
if std::mem::replace(end, true) {
|
||||
return Err(KernelError::Failed("duplicate cylinder cap role".into()));
|
||||
}
|
||||
*source = Some(FaceSource::CylinderCap { producer, high: *high, plane: *plane });
|
||||
}
|
||||
}
|
||||
if ends != [true; 2] {
|
||||
return Err(KernelError::Failed("cylinder cap role count".into()));
|
||||
}
|
||||
self.face_sources = Some(std::sync::Arc::new(sources));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn with_box_certificate(mut self, producer: u64) -> Result<Self> {
|
||||
if producer == 0 {
|
||||
return Err(KernelError::Invalid("invalid box producer".into()));
|
||||
|
||||
@@ -115,7 +115,9 @@ fn curved_internal_void_and_disconnected_material_keep_analytic_volume() {
|
||||
}
|
||||
let imported = step_import(&step_export(&[&body], "curved nested cavities").unwrap()).unwrap();
|
||||
assert!(imported.warnings.is_empty(), "{:?}", imported.warnings);
|
||||
let imported: Vec<_> = imported.bodies.into_iter().flat_map(|b| b.body.lumps().unwrap()).collect();
|
||||
// The two pieces are written as two solids (each with its own void), so they come back as
|
||||
// two bodies.
|
||||
let imported: Vec<_> = imported.bodies.into_iter().map(|b| b.body).collect();
|
||||
assert_eq!(imported.len(), 2);
|
||||
let imported_meshes: Vec<_> = imported.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect();
|
||||
for (p, want) in [(Vec3::ZERO, 0), (Vec3::X * 3.0, 1), (Vec3::X * 7.0, 0), (Vec3::X * 15.0, 1)] {
|
||||
|
||||
@@ -341,7 +341,15 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
|
||||
_ => nl,
|
||||
}
|
||||
};
|
||||
let given = numbered_region(r)?.map_loops(|l| reseam(snap_loop(l)));
|
||||
// Adjacent profiles revolved together leave the axis edge split where they met: one
|
||||
// edge again, so the builders for a profile with an edge on the axis take it (swept
|
||||
// pieces of the axis are zero-area faces that booleans fail on).
|
||||
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
|
||||
let axis_run = |s: &Seg2, t: &Seg2| {
|
||||
let (a, b, c) = (s.start(), s.end(), t.end());
|
||||
on_axis(a) && on_axis(b) && on_axis(c) && (b - a).dot(c - b) > 0.0
|
||||
};
|
||||
let given = numbered_region(r)?.map_loops(|l| reseam(snap_loop(l).merged_lines(axis_run)));
|
||||
let r = &given.region();
|
||||
// A half disc on the axis revolved fully is a sphere: build it without poles. Every
|
||||
// patch is swept from the half disc's arcs.
|
||||
@@ -384,9 +392,24 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
|
||||
out.push(b);
|
||||
continue;
|
||||
}
|
||||
let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?;
|
||||
// Edges on the axis (a spherical shell's two radial lines, say) sweep into degenerate faces
|
||||
// there, which truck only tessellates reliably with the axis through the world origin. Off
|
||||
// it, the body measured too large and booleans with it failed or removed the wrong side.
|
||||
// Build such a profile there and move it into place, as `revolve_touching_axis` does.
|
||||
let axis_edges = r.outer.segs.iter().filter(|s| matches!(**s, Seg2::Line { a, b } if on_axis(a) && on_axis(b))).count();
|
||||
let shift = if axis_edges > 0 { o } else { Vec3::ZERO };
|
||||
let (profile, f) = if shift.len() > 0.0 {
|
||||
let local = Plane { origin: plane.origin - shift, ..*plane };
|
||||
let profile = region_wires(&local, &given.oriented())?;
|
||||
let f = attach_profile(&wires_of(&profile))?;
|
||||
(profile, f)
|
||||
} else {
|
||||
(profile, f)
|
||||
};
|
||||
let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o - shift), v3(axis), mt::Rad(angle))))?;
|
||||
let closed = angle.abs() >= std::f64::consts::TAU;
|
||||
let history = guard("revolve history", || Ok(record::swept_face(&solid, &f, &seeds(&profile), closed)))?;
|
||||
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
|
||||
out.push(record::attach(Body::new(solid)?, &[], history, Default::default())?);
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -300,14 +300,14 @@ fn trim_curved_seam(seam: &mt::Edge, a: Option<mt::Vertex>, b: Option<mt::Vertex
|
||||
.unwrap_or(end);
|
||||
let t = curve.search_nearest_parameter(v.point(), hint, 100).ok_or_else(|| unsupported("the blend is larger than the neighbouring faces"))?;
|
||||
if !(t.is_finite() && from_p3(mt::ParametricCurve::subs(&curve, t)).dist(from_p3(v.point())) <= size * 1e-5) {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
return Err(too_large("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
Ok(t)
|
||||
};
|
||||
let (ta, tb) = (at(&a, lo)?, at(&b, hi)?);
|
||||
let margin = (hi - lo) * 1e-9;
|
||||
if ta < lo - margin || tb > hi + margin || tb - ta < (hi - lo) * 1e-6 {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
return Err(too_large("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
if tb < hi {
|
||||
let _ = curve.cut(tb);
|
||||
|
||||
@@ -299,3 +299,31 @@ fn closed_curved_seam_is_not_supported() {
|
||||
assert!(err.to_string().contains("not supported yet: a closed curved edge"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_ball_past_the_end_seam_is_too_large_and_reduces() {
|
||||
// The crossing cylinders of the test above with the small one's centre inside the large
|
||||
// one: a round of 3 would reach past the end of the cap arc it trims, so it is too large
|
||||
// (a smaller one fits), and the largest that fits is the analytic round over the full height
|
||||
// (checked on its surface: its added volume is too small beside the body's to measure).
|
||||
let (r1, r2, d, h) = (17.0, 9.5, 12.0, 10.0);
|
||||
let (c1, c2) = (Vec3::ZERO, Vec3::new(d, 0.0, 0.0));
|
||||
let body = boolean(&cylinder(c1, Vec3::Z, r1, h).unwrap(), &cylinder(c2, Vec3::Z, r2, h).unwrap(), BoolOp::Union).unwrap().unwrap();
|
||||
let along = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
|
||||
let corner = Vec3::new(along, (r1 * r1 - along * along).sqrt(), 0.0);
|
||||
let at = [corner + Vec3::Z * (h / 2.0)];
|
||||
let e = fillet(&body, &at, 3.0).unwrap_err();
|
||||
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
||||
let (r, blend) = crate::fillet_max(&body, &at, 3.0).unwrap();
|
||||
assert!(r > 1.5 && r < 3.0, "{r}");
|
||||
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
|
||||
let g = gap(c1, r1, c2, r2, corner, r);
|
||||
let surfaces: Vec<_> = blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
|
||||
let mut worst: f64 = 0.0;
|
||||
for z in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
for p in &g.arc {
|
||||
worst = worst.max(nearest_blend(&surfaces, *p + Vec3::Z * (h * z)).0);
|
||||
}
|
||||
}
|
||||
assert!(worst < 1e-3, "blend surface is {worst} mm off the analytic round at r {r}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
//! Filling a closed loop of a body's edges that is not planar with one face: a B-spline height
|
||||
//! field over the loop's best-fit plane (`solidcraft_geom::fill::fill_loop`), bounded by the
|
||||
//! body's own edges, whole. Loops that do not bound a height field over a plane are refused.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::bspline::SplineError;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3};
|
||||
use crate::plane_fit::EdgeSampler;
|
||||
use crate::record::{Rebuild, rebuilt_body};
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
/// A surface body of one face filling the closed loop `chain` of `body`'s edges (each edge
|
||||
/// oriented along the loop, each ending where the next starts), within [`face_tolerance`] of
|
||||
/// every edge. Its history is over `[body]`: the face is new, its edges the body's own. Call it
|
||||
/// under [`crate::guard`].
|
||||
pub(crate) fn fill_loop(body: &Body, chain: &[mt::Edge]) -> Result<Body> {
|
||||
let tol = face_tolerance(body);
|
||||
let mut sides: Vec<(EdgeSampler, Vec3)> = Vec::with_capacity(chain.len());
|
||||
for e in chain {
|
||||
let sampler = EdgeSampler::new(e).ok_or_else(|| KernelError::Invalid("an edge has no parameter range".into()))?;
|
||||
sides.push((sampler, from_p3(e.front().point())));
|
||||
}
|
||||
let mut point = |k: usize, t: f64| -> Option<Vec3> {
|
||||
let (sampler, start) = sides.get_mut(k)?;
|
||||
// The loop's corners are its vertices (an imported edge's curve may end a little off).
|
||||
if t <= 0.0 { Some(*start) } else { sampler.at(t) }
|
||||
};
|
||||
let fill = solidcraft_geom::fill::fill_loop(chain.len(), &mut point, tol).map_err(|e| match e {
|
||||
SplineError::Unsupported(m) => KernelError::Invalid(format!("not supported yet: {m}")),
|
||||
other => KernelError::Invalid(format!("cannot fill the loop: {other}")),
|
||||
})?;
|
||||
// The wire runs counter-clockwise about the surface's normal.
|
||||
let wire: mt::Wire =
|
||||
if fill.counter_clockwise { chain.to_vec().into() } else { chain.iter().rev().map(mt::Edge::inverse).collect::<Vec<_>>().into() };
|
||||
let face = mt::Face::try_new(vec![wire], crate::spline_conv::surface(&fill.surface)?).map_err(|e| KernelError::Failed(format!("fill: {e}")))?;
|
||||
let mut rec = Rebuild::default();
|
||||
rec.made(&face, Vec::new());
|
||||
Ok(rebuilt_body(crate::surfaces::surface_body(vec![face]), &[&body.solid], &rec))
|
||||
}
|
||||
|
||||
/// How far a loop's edges may be from a plane for its patch to be that plane: `10⁻⁷` of the
|
||||
/// body's size, at least the linear tolerance (`10⁻⁶` mm) — the body's own "same point"
|
||||
/// tolerance, since a planar face is exact and other operations treat it as containing its
|
||||
/// edges to that tolerance. A fitted face gets [`face_tolerance`], ten times looser.
|
||||
pub(crate) fn planarity_tolerance(body: &Body) -> f64 {
|
||||
(body.size() * 1e-7).max(solidcraft_geom::LINEAR_TOL)
|
||||
}
|
||||
|
||||
/// How far the fill's surface may be from the loop's edges: `10⁻⁶` of the body's size, at least
|
||||
/// `10⁻⁵` mm — ten times [`planarity_tolerance`]. Truck meshes a face from its edges' own
|
||||
/// polylines (shared with the neighbouring faces), so the mesh stays watertight whatever the
|
||||
/// gap: measured on a five-sided rim, fills whose edges were up to `4.5·10⁻⁴` mm off the surface
|
||||
/// meshed cleanly and stitched back into closed solids. Ten times the linear tolerance because a
|
||||
/// fitted surface approaches curved edges like a cubic spline, so each further factor of ten
|
||||
/// costs about twice the spans each way, and imported edges may already lie `10⁻⁵` mm or more off
|
||||
/// their vertices; two orders below what meshing tolerates. Later operations must take the face
|
||||
/// to contain its edges within this tolerance, not the linear one: the face's edges are the
|
||||
/// body's exact edges, the surface between them is the fit.
|
||||
pub(crate) fn face_tolerance(body: &Body) -> f64 {
|
||||
(body.size() * 1e-6).max(1e-5)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve, ParametricSurface};
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3, p3};
|
||||
use crate::boolean_record::tests::{Reach, checked};
|
||||
use crate::history::{Change, EdgeOrigin};
|
||||
use crate::{BoolOp, boolean, copy_faces, cylinder, extrude, measure, patch_edges, stitch};
|
||||
|
||||
/// The cutting cylinder: axis along y at x = 0, z = 50, radius 40, so the top of a prism
|
||||
/// within 20 of the axis and 20 high is cut away everywhere (to between 10 and 15.4 mm).
|
||||
fn cut_height(x: f64) -> f64 {
|
||||
50.0 - (1600.0 - x * x).sqrt()
|
||||
}
|
||||
|
||||
/// A prism on the polygon `corners`, 20 high, its top cut by the cylinder; the cut solid, the
|
||||
/// open cup without its top, and a point on each rim edge (the edges' midpoints in plan).
|
||||
/// `inside` is a point inside the polygon.
|
||||
fn cup(corners: &[Vec2], inside: Vec2) -> (Body, Body, Vec<Vec3>) {
|
||||
let prism = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(corners), holes: vec![] }], 0.0, 20.0).unwrap().pop().unwrap();
|
||||
let tool = cylinder(Vec3::new(0.0, -60.0, 50.0), Vec3::Y, 40.0, 120.0).unwrap();
|
||||
let solid = boolean(&prism, &tool, BoolOp::Cut).unwrap().unwrap();
|
||||
let n = corners.len();
|
||||
let mut keep = vec![inside.extend(0.0)];
|
||||
let mut rim = Vec::new();
|
||||
for k in 0..n {
|
||||
let m = corners[k].lerp(corners[(k + 1) % n], 0.5);
|
||||
keep.push(m.extend(5.0));
|
||||
rim.push(m.extend(cut_height(m.x)));
|
||||
}
|
||||
let open = copy_faces(&solid, &keep).unwrap();
|
||||
assert!(open.is_surface());
|
||||
(solid, open, rim)
|
||||
}
|
||||
|
||||
fn regular(n: usize, phase: f64) -> Vec<Vec2> {
|
||||
(0..n).map(|k| Vec2::from_angle(phase + k as f64 * std::f64::consts::TAU / n as f64) * 20.0).collect()
|
||||
}
|
||||
|
||||
/// The distance from `p` to the surface: the nearest of a grid of samples, then Gauss–Newton
|
||||
/// within the domain.
|
||||
fn to_surface(s: &mt::Surface, p: mt::Point3) -> f64 {
|
||||
let (Some((u0, u1)), Some((v0, v1))) = s.try_range_tuple() else { panic!("a bounded surface") };
|
||||
let n = 80;
|
||||
let mut best = (f64::MAX, u0, v0);
|
||||
for i in 0..=n {
|
||||
for j in 0..=n {
|
||||
let (u, v) = (u0 + (u1 - u0) * i as f64 / n as f64, v0 + (v1 - v0) * j as f64 / n as f64);
|
||||
let d = s.subs(u, v).distance(p);
|
||||
if d < best.0 {
|
||||
best = (d, u, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (_, mut u, mut v) = best;
|
||||
for _ in 0..50 {
|
||||
let (q, a, b) = (s.subs(u, v), s.uder(u, v), s.vder(u, v));
|
||||
let r = q - p;
|
||||
let (aa, ab, bb) = (a.dot(a), a.dot(b), b.dot(b));
|
||||
let det = aa * bb - ab * ab;
|
||||
if det.abs() < 1e-300 {
|
||||
break;
|
||||
}
|
||||
let (ga, gb) = (a.dot(r), b.dot(r));
|
||||
u = (u - (bb * ga - ab * gb) / det).clamp(u0, u1);
|
||||
v = (v - (aa * gb - ab * ga) / det).clamp(v0, v1);
|
||||
}
|
||||
s.subs(u, v).distance(p)
|
||||
}
|
||||
|
||||
/// The farthest point along the face's edges from its surface.
|
||||
fn edges_off_surface(face: &mt::Face) -> f64 {
|
||||
let s = face.surface();
|
||||
let mut worst = 0.0f64;
|
||||
for e in face.boundaries().iter().flat_map(|w| w.edge_iter()) {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
for k in 0..=24 {
|
||||
worst = worst.max(to_surface(&s, c.subs(t0 + (t1 - t0) * k as f64 / 24.0)));
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
/// The fill's mesh: every normal finite, and no triangle folded over (all face the same way
|
||||
/// as the mesh's total area vector, by a clear margin).
|
||||
fn mesh_is_clean(fill: &Body) -> f64 {
|
||||
let m = fill.tessellate(0.01).unwrap();
|
||||
assert!(!m.triangles.is_empty());
|
||||
assert!(m.normals.iter().all(|n| n.is_finite() && n.len() > 0.5), "a mesh normal is not finite");
|
||||
let tri = |t: &[u32; 3]| m.tri(t).unwrap();
|
||||
let total = m.triangles.iter().map(|t| {
|
||||
let [a, b, c] = tri(t);
|
||||
(b - a).cross(c - a)
|
||||
});
|
||||
let total = total.fold(Vec3::ZERO, |s, v| s + v);
|
||||
let n = total.normalized().unwrap();
|
||||
for t in &m.triangles {
|
||||
let [a, b, c] = tri(t);
|
||||
let v = (b - a).cross(c - a);
|
||||
if v.len() > 1e-12 {
|
||||
assert!(v.dot(n) > 0.0, "a folded triangle");
|
||||
}
|
||||
}
|
||||
measure(fill).unwrap().area
|
||||
}
|
||||
|
||||
/// Fill the rim, check the face, stitch it back: (the fill, the closed solid's volume).
|
||||
fn fill_rim(open: &Body, rim: &[Vec3]) -> (Body, f64) {
|
||||
let fill = patch_edges(open, rim).unwrap();
|
||||
assert!(fill.is_surface());
|
||||
assert_eq!(fill.face_count(), 1, "one face");
|
||||
let face = fill.solid.face_iter().next().unwrap().clone();
|
||||
assert!(!matches!(face.surface(), mt::Surface::Plane(_)));
|
||||
// Its edges are the open body's own, by identity, and nothing else.
|
||||
let own: Vec<mt::EdgeID> = open.solid.edge_iter().map(|e| e.id()).collect();
|
||||
assert!(fill.solid.edge_iter().all(|e| own.contains(&e.id())), "an edge that is not the body's");
|
||||
let h = checked(&fill, &[open], 0.05, Reach { made: 0.0, moved: 0.0 }, &|_| true);
|
||||
assert!(h.faces.iter().all(|f| f.change == Change::Generated && f.origins.is_empty()));
|
||||
assert!(h.edges.iter().all(|e| matches!(e.origins.as_slice(), [EdgeOrigin::Edge { operand: 0, .. }])));
|
||||
let off = edges_off_surface(&face);
|
||||
let tol = super::face_tolerance(open);
|
||||
assert!(off <= tol, "an edge is {off} off the face (tolerance {tol})");
|
||||
mesh_is_clean(&fill);
|
||||
let closed = stitch(&[open, &fill], 0.0).unwrap();
|
||||
assert!(!closed.is_surface(), "the fill closes the cup");
|
||||
(fill, measure(&closed).unwrap().volume)
|
||||
}
|
||||
|
||||
/// The fill's height over the point `(x, y)`: Newton's method on the surface's `x`, `y` from
|
||||
/// the nearest of a grid of samples.
|
||||
fn height_over(s: &mt::Surface, x: f64, y: f64, start: &mut Option<(f64, f64)>) -> Option<f64> {
|
||||
let (Some((u0, u1)), Some((v0, v1))) = s.try_range_tuple() else { return None };
|
||||
let (mut u, mut v) = start.unwrap_or(((u0 + u1) / 2.0, (v0 + v1) / 2.0));
|
||||
for _ in 0..30 {
|
||||
let (q, a, b) = (s.subs(u, v), s.uder(u, v), s.vder(u, v));
|
||||
let (rx, ry) = (q.x - x, q.y - y);
|
||||
let det = a.x * b.y - a.y * b.x;
|
||||
if det.abs() < 1e-300 {
|
||||
return None;
|
||||
}
|
||||
u = (u - (b.y * rx - b.x * ry) / det).clamp(u0, u1);
|
||||
v = (v - (a.x * ry - a.y * rx) / det).clamp(v0, v1);
|
||||
}
|
||||
let q = s.subs(u, v);
|
||||
*start = Some((u, v));
|
||||
((q.x - x).hypot(q.y - y) < 1e-9).then_some(q.z)
|
||||
}
|
||||
|
||||
/// The volume the cut prism gains with the fill for its top: the fill's height above the
|
||||
/// cylinder integrated over the polygon (midpoint rule on a 300 × 300 grid; the integrand
|
||||
/// vanishes on the rim, so cells across it add little error).
|
||||
fn gained(fill: &Body, corners: &[Vec2]) -> f64 {
|
||||
let s = fill.solid.face_iter().next().unwrap().surface();
|
||||
let (lo, hi) = corners.iter().fold((Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN)), |(lo, hi), p| {
|
||||
(Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)))
|
||||
});
|
||||
let n = 300;
|
||||
let (dx, dy) = ((hi.x - lo.x) / n as f64, (hi.y - lo.y) / n as f64);
|
||||
let inside = |x: f64, y: f64| {
|
||||
let mut w = false;
|
||||
for k in 0..corners.len() {
|
||||
let (a, b) = (corners[k], corners[(k + 1) % corners.len()]);
|
||||
if (a.y > y) != (b.y > y) && x < a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x) {
|
||||
w = !w;
|
||||
}
|
||||
}
|
||||
w
|
||||
};
|
||||
let mut sum = 0.0;
|
||||
let mut start = None;
|
||||
for i in 0..n {
|
||||
let x = lo.x + (i as f64 + 0.5) * dx;
|
||||
for j in 0..n {
|
||||
let y = lo.y + (j as f64 + 0.5) * dy;
|
||||
if inside(x, y) {
|
||||
let h = height_over(&s, x, y, &mut start).expect("the fill covers the polygon");
|
||||
sum += (h - cut_height(x)) * dx * dy;
|
||||
}
|
||||
}
|
||||
}
|
||||
sum
|
||||
}
|
||||
|
||||
/// The closed solid's volume against the cut prism's plus what the fill adds over the
|
||||
/// cylinder (integrated independently, [`gained`]); they agree to the integration's error
|
||||
/// (0.1% of what the fill adds) plus the error of measuring both solids on meshes
|
||||
/// (`5·10⁻⁵` of the volume).
|
||||
fn check_volume(name: &str, solid: &Body, fill: &Body, corners: &[Vec2], got: f64) {
|
||||
let prism = measure(solid).unwrap().volume;
|
||||
let extra = gained(fill, corners);
|
||||
let want = prism + extra;
|
||||
let bound = 1e-3 * extra.abs() + 5e-5 * want;
|
||||
eprintln!("{name}: closed volume {got:.4}, cut prism {prism:.4} + fill above the cylinder {extra:.4} = {want:.4} (bound ±{bound:.4})");
|
||||
assert!((got - want).abs() <= bound, "{name}: {got} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_rims_of_three_to_eight_sides_fill_with_one_face_and_close_the_cup() {
|
||||
for n in 3..=8 {
|
||||
let corners = regular(n, 0.3);
|
||||
let (solid, open, rim) = cup(&corners, Vec2::ZERO);
|
||||
let (fill, got) = fill_rim(&open, &rim);
|
||||
assert_eq!(fill.edge_count(), n, "the rim's own edges");
|
||||
check_volume(&format!("{n} sides"), &solid, &fill, &corners, got);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concave_rims_fill_with_one_face() {
|
||||
let v = |x: f64, y: f64| Vec2::new(x, y);
|
||||
let shapes = [
|
||||
("dart", vec![v(-10.0, 0.0), v(15.0, -12.0), v(3.0, 0.0), v(15.0, 12.0)], v(0.0, 0.0)),
|
||||
("arrow", vec![v(-12.0, 0.0), v(0.0, -10.0), v(0.0, -4.0), v(14.0, 0.0), v(0.0, 4.0)], v(0.0, 0.0)),
|
||||
(
|
||||
"plus",
|
||||
vec![
|
||||
v(-4.0, -14.0),
|
||||
v(4.0, -14.0),
|
||||
v(4.0, -4.0),
|
||||
v(14.0, -4.0),
|
||||
v(14.0, 4.0),
|
||||
v(4.0, 4.0),
|
||||
v(4.0, 14.0),
|
||||
v(-4.0, 14.0),
|
||||
v(-4.0, 4.0),
|
||||
v(-14.0, 4.0),
|
||||
v(-14.0, -4.0),
|
||||
v(-4.0, -4.0),
|
||||
],
|
||||
v(0.0, 0.0),
|
||||
),
|
||||
];
|
||||
for (name, corners, inside) in shapes {
|
||||
let (solid, open, rim) = cup(&corners, inside);
|
||||
let (fill, got) = fill_rim(&open, &rim);
|
||||
assert_eq!(fill.edge_count(), corners.len(), "{name}: the rim's own edges");
|
||||
check_volume(name, &solid, &fill, &corners, got);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_corner_between_two_edges_is_kept() {
|
||||
// A rim of five straight edges, two of them in line (a 180° corner between them), the
|
||||
// four true corners at different heights; walls hang below it.
|
||||
let p = |x: f64, y: f64, z: f64| mt::Vertex::new(mt::Point3::new(x, y, z));
|
||||
let v = [p(-14.0, -14.0, 12.0), p(0.0, -14.0, 12.0), p(14.0, -14.0, 12.0), p(14.0, 14.0, 15.0), p(-14.0, 14.0, 10.0)];
|
||||
let line = |a: &mt::Vertex, b: &mt::Vertex| mt::Edge::new(a, b, mt::Curve::Line(mt::Line(a.point(), b.point())));
|
||||
let wire: mt::Wire = (0..5).map(|k| line(&v[k], &v[(k + 1) % 5])).collect::<Vec<_>>().into();
|
||||
let low = mt::builder::translated(&wire, mt::Vector3::new(0.0, 0.0, -10.0));
|
||||
let walls = mt::builder::try_wire_homotopy(&wire, &low).unwrap();
|
||||
let body = crate::surfaces::surface_body(walls.face_iter().cloned().collect());
|
||||
let rim: Vec<Vec3> = (0..5).map(|k| from_p3(v[k].point()).lerp(from_p3(v[(k + 1) % 5].point()), 0.5)).collect();
|
||||
let fill = patch_edges(&body, &rim).unwrap();
|
||||
assert_eq!((fill.face_count(), fill.edge_count()), (1, 5), "one face on all five edges");
|
||||
let face = fill.solid.face_iter().next().unwrap().clone();
|
||||
assert!(edges_off_surface(&face) <= super::face_tolerance(&body));
|
||||
let area = mesh_is_clean(&fill);
|
||||
eprintln!("flat corner: one face of area {area}");
|
||||
assert!(area > 28.0 * 28.0 * 0.99 && area < 28.0 * 28.0 * 1.05, "{area}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fill_does_not_depend_on_pick_order_or_where_the_body_is() {
|
||||
let (_, open, rim) = cup(®ular(5, 0.3), Vec2::ZERO);
|
||||
let (a, _) = fill_rim(&open, &rim);
|
||||
let mut shuffled = rim.clone();
|
||||
shuffled.rotate_left(3);
|
||||
shuffled.swap(0, 1);
|
||||
let (b, _) = fill_rim(&open, &shuffled);
|
||||
let (sa, sb) = (a.solid.face_iter().next().unwrap().surface(), b.solid.face_iter().next().unwrap().surface());
|
||||
let (Some((u0, u1)), Some((v0, v1))) = sa.try_range_tuple() else { panic!() };
|
||||
let mut gap = 0.0f64;
|
||||
for (x, y) in [(0.2, 0.3), (0.5, 0.5), (0.7, 0.9)] {
|
||||
let (u, v) = (u0 + (u1 - u0) * x, v0 + (v1 - v0) * y);
|
||||
gap = gap.max(sa.subs(u, v).distance(sb.subs(u, v)));
|
||||
}
|
||||
assert!(gap < 1e-7, "pick order moved the surface by {gap}");
|
||||
let (area_a, area_b) = (mesh_is_clean(&a), mesh_is_clean(&b));
|
||||
assert!((area_a - area_b).abs() < area_a * 1e-9);
|
||||
// Moved and turned: the same area.
|
||||
let moved = crate::transform(&open, Vec3::new(130.0, -70.0, 40.0), Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0), 0.7).unwrap();
|
||||
let ax = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
|
||||
let turn = |p: Vec3| {
|
||||
// Rodrigues' rotation by 0.7 about `ax`, then the shift.
|
||||
let (c, s) = (0.7f64.cos(), 0.7f64.sin());
|
||||
p * c + ax.cross(p) * s + ax * (ax.dot(p) * (1.0 - c)) + Vec3::new(130.0, -70.0, 40.0)
|
||||
};
|
||||
let moved_rim: Vec<Vec3> = rim.iter().map(|p| turn(*p)).collect();
|
||||
let (c, _) = fill_rim(&moved, &moved_rim);
|
||||
let area_c = mesh_is_clean(&c);
|
||||
eprintln!("pick order: surfaces within {gap:e}; areas {area_a} / {area_b} / moved {area_c}");
|
||||
assert!((area_a - area_c).abs() < area_a * 1e-6, "{area_a} vs moved {area_c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saddle_rim_of_two_intersection_curves_fills() {
|
||||
// A round post with its top cut by the sideways cylinder: the rim is two intersection
|
||||
// curves; the fill is one face on them.
|
||||
let post = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
|
||||
let tool = cylinder(Vec3::new(0.0, -60.0, 50.0), Vec3::Y, 40.0, 120.0).unwrap();
|
||||
let solid = boolean(&post, &tool, BoolOp::Cut).unwrap().unwrap();
|
||||
let sides: Vec<Vec3> = (0..8).map(|k| Vec2::from_angle(k as f64 * 0.8).extend(0.0) * 10.0 + Vec3::new(0.0, 0.0, 5.0)).collect();
|
||||
let open = copy_faces(&solid, &[&[Vec3::ZERO][..], &sides].concat()).unwrap();
|
||||
let rim: Vec<Vec3> = [-0.98, 2.18]
|
||||
.map(|a: f64| {
|
||||
let (x, y) = (10.0 * a.cos(), 10.0 * a.sin());
|
||||
Vec3::new(x, y, cut_height(x))
|
||||
})
|
||||
.to_vec();
|
||||
let (fill, got) = fill_rim(&open, &rim);
|
||||
let circle: Vec<Vec2> = (0..720).map(|k| Vec2::from_angle(k as f64 * std::f64::consts::TAU / 720.0) * 10.0).collect();
|
||||
check_volume("saddle rim", &solid, &fill, &circle, got);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_edges_whose_vertices_are_off_their_curves_fill() {
|
||||
// The cup's vertices moved 2·10⁻⁵ mm off their curves, as a file might give them.
|
||||
let corners = regular(5, 0.3);
|
||||
let (solid, open, rim) = cup(&corners, Vec2::ZERO);
|
||||
let shell = open.solid.boundaries()[0].mapped(
|
||||
|p: &mt::Point3| *p + mt::Vector3::new(2e-5, 0.0, 0.0),
|
||||
|c: &mt::Curve| c.clone(),
|
||||
|s: &mt::Surface| s.clone(),
|
||||
);
|
||||
let imported = crate::surfaces::surface_body(shell.face_iter().cloned().collect());
|
||||
let (fill, got) = fill_rim(&imported, &rim);
|
||||
check_volume("imported", &solid, &fill, &corners, got);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spline_edge_bulging_out_of_plane_is_never_given_a_flat_face() {
|
||||
// A flat rectangle of edges but for one cubic edge with a control point raised 1.5 mm: no
|
||||
// plane can be certified. A one-span bump is followed by the fitted face; a narrow one
|
||||
// (64 spans, about 1 mm wide, its third derivative jumping at every knot) may be beyond
|
||||
// a 32 × 32 grid, and then it is refused.
|
||||
for (spans, raised, must_fill) in [(1, 1, true), (64, 30, false)] {
|
||||
let count = spans + 3;
|
||||
let pts: Vec<mt::Point3> =
|
||||
(0..count).map(|i| mt::Point3::new(20.0 * i as f64 / (count - 1) as f64, 0.0, if i == raised { 1.5 } else { 0.0 })).collect();
|
||||
let spline = mt::BSplineCurve::new(mt::KnotVec::uniform_knot(3, spans), pts);
|
||||
let v: Vec<mt::Vertex> =
|
||||
[(0.0, 0.0), (20.0, 0.0), (20.0, 10.0), (0.0, 10.0)].iter().map(|&(x, y)| mt::Vertex::new(mt::Point3::new(x, y, 0.0))).collect();
|
||||
let line = |a: &mt::Vertex, b: &mt::Vertex| mt::Edge::new(a, b, mt::Curve::Line(mt::Line(a.point(), b.point())));
|
||||
let bottom = mt::Edge::new(&v[0], &v[1], mt::Curve::BSplineCurve(spline.clone()));
|
||||
let wire: mt::Wire = vec![bottom, line(&v[1], &v[2]), line(&v[2], &v[3]), line(&v[3], &v[0])].into();
|
||||
let top = mt::builder::translated(&wire, mt::Vector3::new(0.0, 0.0, -5.0));
|
||||
let walls = mt::builder::try_wire_homotopy(&wire, &top).unwrap();
|
||||
let body = crate::surfaces::surface_body(walls.face_iter().cloned().collect());
|
||||
let peak = from_p3(spline.subs((raised as f64 + 1.0) / (count + 1) as f64));
|
||||
let rim = [from_p3(spline.subs(0.5)), Vec3::new(20.0, 5.0, 0.0), Vec3::new(10.0, 10.0, 0.0), Vec3::new(0.0, 5.0, 0.0)];
|
||||
match patch_edges(&body, &rim) {
|
||||
Ok(fill) => {
|
||||
let face = fill.solid.face_iter().next().unwrap().clone();
|
||||
assert!(!matches!(face.surface(), mt::Surface::Plane(_)), "a plane would miss the bulge");
|
||||
let (off, tol) = (edges_off_surface(&face), super::face_tolerance(&body));
|
||||
eprintln!("{spans}-span bump: filled, edges within {off:e} of the face");
|
||||
assert!(off <= tol && to_surface(&face.surface(), p3(peak)) <= tol);
|
||||
mesh_is_clean(&fill);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{spans}-span bump: {e}");
|
||||
assert!(!must_fill && e.to_string().contains("not supported yet"), "{e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A point on each edge that bounds one face of `body` (its free edges).
|
||||
fn free_edge_points(body: &Body) -> Vec<Vec3> {
|
||||
let mut uses: std::collections::HashMap<mt::EdgeID, (usize, mt::Edge)> = std::collections::HashMap::new();
|
||||
for f in body.solid.face_iter() {
|
||||
for e in f.boundaries().iter().flat_map(|w| w.edge_iter()) {
|
||||
uses.entry(e.id()).or_insert((0, e.clone())).0 += 1;
|
||||
}
|
||||
}
|
||||
uses.values()
|
||||
.filter(|(n, _)| *n == 1)
|
||||
.map(|(_, e)| {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
from_p3(c.subs(0.5 * (t0 + t1)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_that_does_not_bound_a_height_field_is_refused() {
|
||||
// Half a round post's side: two straight edges up and two half circles, whose plane
|
||||
// stands on the post's axis; the half circles run along its normal at their ends.
|
||||
let post = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
|
||||
let halves = crate::split_by_plane(&post, &Plane::from_normal(Vec3::ZERO, Vec3::X).unwrap()).unwrap();
|
||||
let half = halves.into_iter().find(|b| measure(b).unwrap().bbox.min.x > -1e-6).unwrap();
|
||||
let at: Vec<Vec3> =
|
||||
(-4..=4).map(|k| Vec2::from_angle(k as f64 * 0.35).extend(10.0) * 1.0).map(|p| Vec3::new(p.x * 10.0, p.y * 10.0, 10.0)).collect();
|
||||
let side = copy_faces(&half, &at).unwrap();
|
||||
let rim = free_edge_points(&side);
|
||||
let err = patch_edges(&side, &rim).unwrap_err().to_string();
|
||||
eprintln!("half a post's side: {err}");
|
||||
assert!(err.contains("not supported yet"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planar_loops_of_intersection_curves_are_one_planar_face() {
|
||||
// A round post cut by planes tilted every way, gently to steeply: each rim is an ellipse
|
||||
// of intersection curves (split where the post's side has seams) lying on the cutting
|
||||
// plane, so the patch is one planar face, and the post closes again.
|
||||
for tilt in [Vec3::new(0.0, -0.4, 1.0), Vec3::new(0.7, -0.9, 1.0), Vec3::new(1.3, 0.2, 1.0)] {
|
||||
let post = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 30.0).unwrap();
|
||||
let n = tilt.normalized().unwrap();
|
||||
let plane = Plane::from_normal(Vec3::new(0.0, 0.0, 15.0), n).unwrap();
|
||||
let pieces = crate::split_by_plane(&post, &plane).unwrap();
|
||||
let solid = pieces.into_iter().find(|b| measure(b).unwrap().bbox.min.z < 0.5).unwrap();
|
||||
let height = |x: f64, y: f64| 15.0 - (tilt.x * x + tilt.y * y) / tilt.z;
|
||||
let sides: Vec<Vec3> = (0..8).map(|k| Vec2::from_angle(k as f64 * 0.8).extend(0.0) * 10.0 + Vec3::new(0.0, 0.0, 1.0)).collect();
|
||||
let open = copy_faces(&solid, &[&[Vec3::ZERO][..], &sides].concat()).unwrap();
|
||||
let rim: Vec<Vec3> = (0..12)
|
||||
.map(|k| {
|
||||
let a = k as f64 * std::f64::consts::TAU / 12.0 + 0.2;
|
||||
let (x, y) = (10.0 * a.cos(), 10.0 * a.sin());
|
||||
Vec3::new(x, y, height(x, y))
|
||||
})
|
||||
.collect();
|
||||
let lid = patch_edges(&open, &rim).unwrap();
|
||||
assert_eq!(lid.face_count(), 1);
|
||||
let edges = lid.edge_count();
|
||||
assert!(matches!(lid.solid.face_iter().next().unwrap().surface(), mt::Surface::Plane(_)), "tilt {tilt:?}");
|
||||
let closed = stitch(&[&open, &lid], 0.0).unwrap();
|
||||
assert!(!closed.is_surface());
|
||||
let (want, got) = (measure(&solid).unwrap().volume, measure(&closed).unwrap().volume);
|
||||
eprintln!("rim cut at {tilt:?}: {edges} intersection curves, planar face, volume {got} against {want}");
|
||||
assert!((got - want).abs() < want * 1e-6, "{got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planar_loops_are_still_one_planar_face() {
|
||||
let b = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
|
||||
let open = copy_faces(
|
||||
&b,
|
||||
&[Vec3::new(5.0, 5.0, 0.0), Vec3::new(5.0, 0.0, 5.0), Vec3::new(10.0, 5.0, 5.0), Vec3::new(5.0, 10.0, 5.0), Vec3::new(0.0, 5.0, 5.0)],
|
||||
)
|
||||
.unwrap();
|
||||
let rim = [Vec3::new(5.0, 0.0, 10.0), Vec3::new(10.0, 5.0, 10.0), Vec3::new(5.0, 10.0, 10.0), Vec3::new(0.0, 5.0, 10.0)];
|
||||
let lid = patch_edges(&open, &rim).unwrap();
|
||||
assert_eq!(lid.face_count(), 1);
|
||||
assert!(matches!(lid.solid.face_iter().next().unwrap().surface(), mt::Surface::Plane(_)));
|
||||
let closed = stitch(&[&open, &lid], 0.0).unwrap();
|
||||
assert!((measure(&closed).unwrap().volume - 1000.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_partial_or_hostile_picks_are_refused() {
|
||||
let (_, open, rim) = cup(®ular(5, 0.3), Vec2::ZERO);
|
||||
assert!(patch_edges(&open, &rim[..4]).is_err(), "a loop with a missing edge");
|
||||
assert!(patch_edges(&open, &[]).is_err());
|
||||
assert!(patch_edges(&open, &[Vec3::new(f64::NAN, 0.0, 0.0)]).is_err());
|
||||
assert!(patch_edges(&open, &[Vec3::new(1e300, 0.0, 0.0)]).is_err());
|
||||
assert!(patch_edges(&open, &vec![rim[0]; 20_000]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ mod delete_face;
|
||||
mod emboss;
|
||||
#[cfg(test)]
|
||||
mod face_source_tests;
|
||||
mod fill;
|
||||
mod freeform;
|
||||
pub mod fuzz;
|
||||
mod heal;
|
||||
@@ -45,6 +46,7 @@ mod modify_history_tests;
|
||||
mod numbered;
|
||||
mod offset;
|
||||
mod ops;
|
||||
mod plane_fit;
|
||||
mod polybool;
|
||||
mod polyhedron;
|
||||
mod prism;
|
||||
@@ -54,6 +56,7 @@ pub mod provenance;
|
||||
mod record;
|
||||
mod sew;
|
||||
mod smooth_edges;
|
||||
mod spline_conv;
|
||||
mod splitface;
|
||||
mod step;
|
||||
mod step_in;
|
||||
|
||||
@@ -125,6 +125,35 @@ impl NumberedLoop {
|
||||
out
|
||||
}
|
||||
|
||||
/// Each run of lines that `joins` says continue one another as a single line, over what
|
||||
/// they all ran over. (Adjacent profiles merged into one region keep a vertex where each
|
||||
/// shared edge met the outline.)
|
||||
pub(crate) fn merged_lines(&self, joins: impl Fn(&Seg2, &Seg2) -> bool) -> NumberedLoop {
|
||||
let n = self.lp.segs.len();
|
||||
let join = |i: usize, j: usize| match (self.lp.segs.get(i), self.lp.segs.get(j)) {
|
||||
(Some(s @ Seg2::Line { .. }), Some(t @ Seg2::Line { .. })) => joins(s, t),
|
||||
_ => false,
|
||||
};
|
||||
// Start where a run starts (a loop of nothing but one run stays as it is).
|
||||
let Some(k) = (0..n).find(|&i| !join((i + n - 1) % n, i)) else { return self.clone() };
|
||||
let mut out = NumberedLoop { lp: Loop2 { segs: Vec::new() }, from: Vec::new() };
|
||||
for j in 0..n {
|
||||
let i = (k + j) % n;
|
||||
let (Some(s), from) = (self.lp.segs.get(i).copied(), self.numbers(i)) else { continue };
|
||||
if j > 0
|
||||
&& join((i + n - 1) % n, i)
|
||||
&& let (Some(Seg2::Line { b, .. }), Some(prev)) = (out.lp.segs.last_mut(), out.from.last_mut())
|
||||
{
|
||||
*b = s.end();
|
||||
*prev = union([prev.as_slice(), from]);
|
||||
continue;
|
||||
}
|
||||
out.lp.segs.push(s);
|
||||
out.from.push(from.to_vec());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split segment `k` in half, both halves over what it ran over.
|
||||
fn split_half(&mut self, k: usize) {
|
||||
if k >= self.lp.segs.len() || k >= self.from.len() {
|
||||
|
||||
+91
-24
@@ -757,6 +757,56 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
// Across the collapsed rounds next to edge `e` (its faces `fs`): a round is a strip, crossed
|
||||
// to its side that shares no vertex with the edge, through further collapsed rounds.
|
||||
let opposite_side = |e: &mt::Edge, fs: &[usize]| -> Vec<usize> {
|
||||
let mut out: Vec<usize> = Vec::new();
|
||||
let mut seen: Vec<usize> = Vec::new();
|
||||
let mut queue: Vec<(usize, [mt::VertexID; 2])> =
|
||||
fs.iter().copied().filter(|i| is_collapsed(*i)).map(|i| (i, [e.front().id(), e.back().id()])).collect();
|
||||
while let Some((cf, avoid)) = queue.pop() {
|
||||
if seen.contains(&cf) {
|
||||
continue;
|
||||
}
|
||||
seen.push(cf);
|
||||
let Some(face) = faces.get(cf) else { continue };
|
||||
for side in face.edge_iter() {
|
||||
if avoid.contains(&side.front().id()) || avoid.contains(&side.back().id()) {
|
||||
continue;
|
||||
}
|
||||
for nb in efaces.get(&side.id()).into_iter().flatten().copied() {
|
||||
if nb == cf || fs.contains(&nb) {
|
||||
continue;
|
||||
}
|
||||
if is_collapsed(nb) {
|
||||
queue.push((nb, [side.front().id(), side.back().id()]));
|
||||
} else if !out.contains(&nb) {
|
||||
out.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
// For a point on a collapsed round touching the faces `at`: the faces it keeps (`at`'s
|
||||
// own, not collapsed), and the faces along the rounds it is on, nearest `p` first.
|
||||
let across = |at: &[usize], p: Vec3| -> (Vec<usize>, Vec<usize>) {
|
||||
let own: Vec<usize> = at.iter().copied().filter(|i| !is_collapsed(*i)).collect();
|
||||
let mut cand: Vec<(f64, usize)> = expand(at)
|
||||
.into_iter()
|
||||
.filter(|nb| !own.contains(nb))
|
||||
.map(|nb| {
|
||||
let d = edge_pts
|
||||
.iter()
|
||||
.filter(|(fs, _)| fs.contains(&nb) && fs.iter().any(|i| is_collapsed(*i)))
|
||||
.flat_map(|(_, q)| q.iter().map(|x| x.dist(p)))
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
(d, nb)
|
||||
})
|
||||
.collect();
|
||||
cand.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||||
(own, cand.into_iter().map(|(_, nb)| nb).collect())
|
||||
};
|
||||
let mut newpos: HashMap<mt::VertexID, Vec3> = HashMap::new();
|
||||
// Corners whose moved faces meet at several points (see `split_corner`).
|
||||
let mut splits: HashMap<mt::VertexID, Vec<(Vec3, Vec<usize>)>> = HashMap::new();
|
||||
@@ -766,22 +816,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
let fs: Vec<usize> = if at.iter().any(|i| is_collapsed(*i)) {
|
||||
// On a collapsed round: its own faces, then the faces along the rounds it is on,
|
||||
// nearest first, until they fix a point.
|
||||
let group = expand(at);
|
||||
let mut own: Vec<usize> = at.iter().copied().filter(|i| !is_collapsed(*i)).collect();
|
||||
let mut cand: Vec<(f64, usize)> = group
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|nb| !own.contains(nb))
|
||||
.map(|nb| {
|
||||
let d = edge_pts
|
||||
.iter()
|
||||
.filter(|(fs, _)| fs.contains(&nb) && fs.iter().any(|i| is_collapsed(*i)))
|
||||
.flat_map(|(_, q)| q.iter().map(|x| x.dist(p)))
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
(d, nb)
|
||||
})
|
||||
.collect();
|
||||
cand.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||||
let (mut own, cand) = across(at, p);
|
||||
let rank = |ids: &[usize]| -> usize {
|
||||
let mut basis: Vec<Vec3> = Vec::new();
|
||||
for i in ids {
|
||||
@@ -796,7 +831,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
}
|
||||
basis.len()
|
||||
};
|
||||
for (_, c) in cand {
|
||||
for c in cand {
|
||||
if rank(&own) >= 3 {
|
||||
break;
|
||||
}
|
||||
@@ -904,7 +939,8 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
let mut edges: HashMap<mt::EdgeID, (mt::Edge, bool)> = HashMap::new();
|
||||
// Moved faces and edges; an edge where neighbours of a collapsed face meet is new.
|
||||
let mut rec = crate::record::Rebuild::default();
|
||||
let mut merged: HashMap<(mt::VertexID, mt::VertexID), mt::Edge> = HashMap::new();
|
||||
// New edges along collapsed rounds: ends and points along them, for their twins.
|
||||
let mut merged: Vec<(mt::VertexID, mt::VertexID, Vec<Vec3>, mt::Edge)> = Vec::new();
|
||||
for e in b.solid.edge_iter() {
|
||||
if edges.contains_key(&e.id()) {
|
||||
continue;
|
||||
@@ -921,13 +957,44 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
continue;
|
||||
}
|
||||
if touches {
|
||||
// Along a collapsed round: a straight edge, shared with its twin from the
|
||||
// round's other side.
|
||||
let ne = match merged.get(&(a.id(), c.id())).or_else(|| merged.get(&(c.id(), a.id()))) {
|
||||
Some(x) => x.clone(),
|
||||
// Along a collapsed round: where the edge's own face meets the face across the
|
||||
// round, between the edge's moved ends, through the edge's middle moved onto
|
||||
// both: a line (planes, or a plane along a cylinder's axis) or an arc (a plane
|
||||
// square to a coaxial cylinder); faces that meet in another curve are refused.
|
||||
// Shared with its twin from the round's other side: the new edge with the same
|
||||
// ends through the same middle (two arcs of one circle have the same ends).
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let fs = efaces.get(&e.id()).ok_or_else(|| fail("edge faces"))?;
|
||||
let cv = e.curve();
|
||||
let (t0, t1) = cv.range_tuple();
|
||||
let mid = from_p3(cv.subs((t0 + t1) / 2.0));
|
||||
// The face across: the strip's far side (`opposite_side`), the nearest of them
|
||||
// when several; the nearest along the rounds when the strip has none.
|
||||
let (mut meet, cand) = across(fs, mid);
|
||||
let far = opposite_side(&e, fs);
|
||||
meet.extend(cand.iter().find(|c| far.contains(c)).or(cand.first()));
|
||||
if meet.len() < 2 {
|
||||
return Err(fail("an edge along a collapsed round with no face across it"));
|
||||
}
|
||||
let (o, s, n) = pick(&meet);
|
||||
let m = move_point(mid, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("faces that don't meet along a collapsed round"))?;
|
||||
let near = size * 1e-5;
|
||||
let twin = merged.iter().find(|(x, y, pts, _)| {
|
||||
((*x == a.id() && *y == c.id()) || (*x == c.id() && *y == a.id()))
|
||||
&& pts.windows(2).any(|w| w.first().zip(w.get(1)).is_some_and(|(p, q)| m.dist_to_segment(*p, *q) < near))
|
||||
});
|
||||
let ne = match twin {
|
||||
Some((_, _, _, x)) => x.clone(),
|
||||
None => {
|
||||
let x = builder::line(a, c);
|
||||
merged.insert((a.id(), c.id()), x.clone());
|
||||
let (pa, pc) = (from_p3(a.point()), from_p3(c.point()));
|
||||
let x = if m.dist_to_segment(pa, pc) < near { builder::line(a, c) } else { builder::circle_arc(a, c, p3(m)) };
|
||||
let xc = x.curve();
|
||||
let (u0, u1) = xc.range_tuple();
|
||||
let pts: Vec<Vec3> = (0..=16).map(|j| from_p3(xc.subs(u0 + (u1 - u0) * j as f64 / 16.0))).collect();
|
||||
if !pts.iter().all(|p| n.iter().all(|sf| sf.dist(*p).abs() < near)) {
|
||||
return Err(fail("not supported yet: faces meeting along a collapsed round in a curve other than a line or circle"));
|
||||
}
|
||||
merged.push((a.id(), c.id(), pts, x.clone()));
|
||||
x
|
||||
}
|
||||
};
|
||||
|
||||
@@ -248,8 +248,10 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
};
|
||||
}
|
||||
r = r.map(|result| result.map(|body| body.inherit_regularization_protection(a).inherit_regularization_protection(b)));
|
||||
if matches!(op, BoolOp::Cut | BoolOp::Union) {
|
||||
r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, op == BoolOp::Cut)).transpose());
|
||||
match op {
|
||||
BoolOp::Cut => r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, true)).transpose()),
|
||||
BoolOp::Union => r = r.and_then(|result| result.map(|body| body.with_preserved_union_planar_sources(a, b)).transpose()),
|
||||
BoolOp::Intersect => {}
|
||||
}
|
||||
if r.is_ok() {
|
||||
merge(diagnostics);
|
||||
|
||||
@@ -30,6 +30,7 @@ fn base_plane(source: &FaceSource, actual: &Plane) -> Option<Plane> {
|
||||
FaceSource::CertifiedExtrudeSide { plane: Some(p), .. }
|
||||
| FaceSource::CertifiedExtrudeCap { plane: p, .. }
|
||||
| FaceSource::Box { producer: Some(_), plane: p, .. }
|
||||
| FaceSource::CylinderCap { plane: p, .. }
|
||||
| FaceSource::ShellPlanar { producer: Some(_), plane: p, .. } => Some(*p),
|
||||
FaceSource::Imported { producer: Some(_), payload_sha256: Some(_), .. } => Some(*actual),
|
||||
_ => None,
|
||||
@@ -41,6 +42,7 @@ pub(super) fn certified_plane(source: &FaceSource) -> Option<Plane> {
|
||||
FaceSource::CertifiedExtrudeSide { plane: Some(p), .. }
|
||||
| FaceSource::CertifiedExtrudeCap { plane: p, .. }
|
||||
| FaceSource::Box { producer: Some(_), plane: p, .. }
|
||||
| FaceSource::CylinderCap { plane: p, .. }
|
||||
| FaceSource::ShellPlanar { producer: Some(_), plane: p, .. } => Some(*p),
|
||||
_ => None,
|
||||
}
|
||||
@@ -93,7 +95,7 @@ pub(super) fn descriptor_cost(source: &FaceSource) -> Option<usize> {
|
||||
}
|
||||
cost = cost.checked_add(count.checked_mul(128)?)?;
|
||||
}
|
||||
FaceSource::ExtrudeSide { .. } | FaceSource::Imported { .. } | FaceSource::Box { .. } => {}
|
||||
FaceSource::ExtrudeSide { .. } | FaceSource::Imported { .. } | FaceSource::Box { .. } | FaceSource::CylinderCap { .. } => {}
|
||||
_ => return None,
|
||||
}
|
||||
Some(cost)
|
||||
@@ -102,13 +104,13 @@ pub(super) fn descriptor_cost(source: &FaceSource) -> Option<usize> {
|
||||
impl Body {
|
||||
/// Preserve uniquely certified unchanged planar construction supports.
|
||||
pub fn with_preserved_planar_sources(self, input: &Body) -> Result<Self> {
|
||||
self.map_planar_sources(input, None, None, &[], false)
|
||||
self.map_planar_sources(&[input], None, None, &[], false)
|
||||
}
|
||||
|
||||
/// Generate distinct outer/inner roles from the exact original supports of
|
||||
/// an inside shell. Generated rims and curved walls are intentionally absent.
|
||||
pub(crate) fn with_shell_planar_sources(self, input: &Body, thickness: f64, open: &[Vec3]) -> Result<Self> {
|
||||
self.map_planar_sources(input, Some(thickness), None, open, false)
|
||||
self.map_planar_sources(&[input], Some(thickness), None, open, false)
|
||||
}
|
||||
|
||||
/// Retain exact shell roles, and construction roles for Cut, only on unchanged
|
||||
@@ -118,19 +120,38 @@ impl Body {
|
||||
if !input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, constructed))) {
|
||||
return Ok(self_);
|
||||
}
|
||||
self_.map_planar_sources(input, None, Some(tool), &[], constructed)
|
||||
self_.map_planar_sources(&[input], None, Some(tool), &[], constructed)
|
||||
}
|
||||
|
||||
fn map_planar_sources(mut self, input: &Body, shell: Option<f64>, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result<Self> {
|
||||
self.regularization_protected |= input.regularization_protected;
|
||||
let Some(sources) = input.face_sources() else { return Ok(self) };
|
||||
if input.solid.face_iter().count() > 16_384
|
||||
/// Retain exact construction and shell roles through a Union from both operands, only on
|
||||
/// unchanged supports. Coplanar supports of the two operands carry distinct identities, so
|
||||
/// a face merged from both stays without a role.
|
||||
pub(crate) fn with_preserved_union_planar_sources(self, a: &Body, b: &Body) -> Result<Self> {
|
||||
let self_ = self.inherit_regularization_protection(a).inherit_regularization_protection(b);
|
||||
if ![a, b].iter().any(|input| input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, true)))) {
|
||||
return Ok(self_);
|
||||
}
|
||||
self_.map_planar_sources(&[a, b], None, None, &[], true)
|
||||
}
|
||||
|
||||
fn map_planar_sources(mut self, inputs: &[&Body], shell: Option<f64>, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result<Self> {
|
||||
// Every boolean (a Cut's tool, or a Union's second operand) leaves the result with
|
||||
// faces whose support no input role describes.
|
||||
let boolean = blocker.is_some() || inputs.len() > 1;
|
||||
for input in inputs {
|
||||
self.regularization_protected |= input.regularization_protected;
|
||||
}
|
||||
if inputs.iter().all(|input| input.face_sources().is_none()) {
|
||||
return Ok(self);
|
||||
}
|
||||
if inputs.len() > 2
|
||||
|| inputs.iter().any(|input| input.solid.face_iter().count() > 16_384)
|
||||
|| self.solid.face_iter().count() > 16_384
|
||||
|| blocker.is_some_and(|b| b.solid.face_iter().count() > 16_384)
|
||||
{
|
||||
return Err(KernelError::Invalid("planar source face budget exceeded".into()));
|
||||
}
|
||||
if sources.len() != input.solid.face_iter().count() {
|
||||
if inputs.iter().any(|input| input.face_sources().is_some_and(|sources| sources.len() != input.solid.face_iter().count())) {
|
||||
return Err(KernelError::Invalid("planar source alignment mismatch".into()));
|
||||
}
|
||||
if shell.is_some_and(|t| !t.is_finite() || t <= 0.) {
|
||||
@@ -139,20 +160,22 @@ impl Body {
|
||||
if open.len() > 256 {
|
||||
return Ok(self);
|
||||
}
|
||||
let opening_tolerance = if shell.is_some() { input.size() * 1e-4 + 1e-6 } else { 0. };
|
||||
let opening_tolerance = if shell.is_some() { inputs.iter().map(|input| input.size()).fold(0., f64::max) * 1e-4 + 1e-6 } else { 0. };
|
||||
let mapped = guard("exact planar support lineage", || {
|
||||
let competing: Vec<_> = blocker.into_iter().flat_map(|b| b.solid.face_iter()).filter_map(actual_plane).collect();
|
||||
let mut candidates = Vec::new();
|
||||
let mut unknown_supports = Vec::new();
|
||||
for (face, source) in input.solid.face_iter().zip(sources) {
|
||||
let faces = inputs.iter().flat_map(|input| {
|
||||
let sources = input.face_sources().unwrap_or_default();
|
||||
input.solid.face_iter().enumerate().map(move |(index, face)| (face, sources.get(index).and_then(Option::as_ref)))
|
||||
});
|
||||
for (face, source) in faces {
|
||||
let Some(actual) = actual_plane(face) else { continue };
|
||||
let Some(source) = source.as_ref() else {
|
||||
let Some(source) = source else {
|
||||
unknown_supports.push(actual);
|
||||
continue;
|
||||
};
|
||||
if (blocker.is_some() && !boolean_source(source, constructed))
|
||||
|| (shell.is_some() && matches!(source, FaceSource::ShellPlanar { .. }))
|
||||
{
|
||||
if (boolean && !boolean_source(source, constructed)) || (shell.is_some() && matches!(source, FaceSource::ShellPlanar { .. })) {
|
||||
unknown_supports.push(actual);
|
||||
continue;
|
||||
}
|
||||
@@ -207,7 +230,7 @@ impl Body {
|
||||
.face_sources()
|
||||
.and_then(|sources| sources.get(index))
|
||||
.and_then(|s| s.as_ref())
|
||||
.filter(|s| (blocker.is_some() || self.face_was_coalesced(index)) && !boolean_source(s, constructed));
|
||||
.filter(|s| (boolean || self.face_was_coalesced(index)) && !boolean_source(s, constructed));
|
||||
let keep = existing.and_then(|s| {
|
||||
descriptor_cost(s).and_then(|cost| {
|
||||
metadata.checked_sub(cost).map(|remaining| {
|
||||
@@ -251,7 +274,7 @@ impl Body {
|
||||
});
|
||||
}
|
||||
}
|
||||
if found.is_none() && (blocker.is_some() || self.face_was_coalesced(index)) {
|
||||
if found.is_none() && (boolean || self.face_was_coalesced(index)) {
|
||||
found = self
|
||||
.face_sources()
|
||||
.and_then(|sources| sources.get(index))
|
||||
@@ -428,6 +451,34 @@ mod tests {
|
||||
assert!((copy.tessellate(0.01).unwrap().measure().volume - mesh.measure().volume).abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn union_keeps_unchanged_supports_of_both_operands_and_drops_shared_ones() {
|
||||
use crate::{BoolOp, boolean};
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(20., 20., 10.)).unwrap().with_box_certificate(1).unwrap();
|
||||
// A taller box overlapping one side: the bottoms share z = 0, the tops do not.
|
||||
let b = box_solid(Vec3::new(10., 5., 0.), Vec3::new(30., 15., 15.)).unwrap().with_box_certificate(2).unwrap();
|
||||
let joined = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
|
||||
let sources = joined.face_sources().unwrap();
|
||||
let mut seen = Vec::new();
|
||||
for (i, face) in joined.solid.face_iter().enumerate() {
|
||||
let plane = actual_plane(face).unwrap();
|
||||
let source = sources[i].as_ref();
|
||||
let height = plane.origin.z;
|
||||
if plane.normal().z > 0.999 && (height - 10.).abs() < 1e-9 {
|
||||
assert!(matches!(source, Some(FaceSource::Box { producer: Some(1), axis: 2, high: true, .. })), "{source:?}");
|
||||
seen.push("a top");
|
||||
} else if plane.normal().z > 0.999 && (height - 15.).abs() < 1e-9 {
|
||||
assert!(matches!(source, Some(FaceSource::Box { producer: Some(2), axis: 2, high: true, .. })), "{source:?}");
|
||||
seen.push("b top");
|
||||
} else if plane.normal().z < -0.999 {
|
||||
assert!(source.is_none(), "a bottom merged from both operands has no single identity: {source:?}");
|
||||
seen.push("bottom");
|
||||
}
|
||||
}
|
||||
for want in ["a top", "b top", "bottom"] {
|
||||
assert!(seen.contains(&want), "{want} missing from {seen:?}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn coincident_competing_source_planes_and_tool_planes_stay_unknown() {
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(1).unwrap();
|
||||
let b = box_solid(Vec3::new(20., 0., 0.), Vec3::new(30., 10., 10.)).unwrap().with_box_certificate(2).unwrap();
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Planes fitted to points around a loop (Newell's method), for planar faces of polygons read
|
||||
//! from files and for patches filling a planar loop of edges, and points sampled along edges
|
||||
//! (shared with the fill of a loop that is not planar).
|
||||
|
||||
use mt::{BoundedCurve, EuclideanSpace, InnerSpace, MetricSpace, ParametricCurve};
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{from_p3, p3};
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
/// The plane through points round a loop: its normal by Newell's method (turning with the
|
||||
/// loop), through their centroid; `None` for fewer than three points or no area.
|
||||
pub(crate) fn plane_through(pts: &[mt::Point3]) -> Option<mt::Plane> {
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
return None;
|
||||
}
|
||||
let mut nrm = mt::Vector3::new(0.0, 0.0, 0.0);
|
||||
let mut c = mt::Vector3::new(0.0, 0.0, 0.0);
|
||||
for i in 0..n {
|
||||
let (a, b) = (pts.get(i)?, pts.get((i + 1) % n)?);
|
||||
nrm.x += (a.y - b.y) * (a.z + b.z);
|
||||
nrm.y += (a.z - b.z) * (a.x + b.x);
|
||||
nrm.z += (a.x - b.x) * (a.y + b.y);
|
||||
c += a.to_vec();
|
||||
}
|
||||
let len = nrm.magnitude();
|
||||
if !(len > 1e-12) {
|
||||
return None;
|
||||
}
|
||||
let z = nrm / len;
|
||||
let o = mt::Point3::from_vec(c / n as f64);
|
||||
let a = if z.x.abs() < 0.9 { mt::Vector3::unit_x() } else { mt::Vector3::unit_y() };
|
||||
let x = (a - z * a.dot(z)).normalize();
|
||||
let y = z.cross(x);
|
||||
Some(mt::Plane::new(o, o + x, o + y))
|
||||
}
|
||||
|
||||
/// The point of `c` at `t` (`None` where an intersection curve cannot find it).
|
||||
fn curve_point(c: &mt::Curve, t: f64) -> Option<Vec3> {
|
||||
let p = match c {
|
||||
mt::Curve::IntersectionCurve(ic) => ic.search_triple(t)?.0,
|
||||
other => other.subs(t),
|
||||
};
|
||||
let p = from_p3(p);
|
||||
p.is_finite().then_some(p)
|
||||
}
|
||||
|
||||
/// Points along an edge, in its own direction, at `s ∈ [0, 1]` of its parameter range. An
|
||||
/// intersection curve's point is searched from the surface parameters of the point found before
|
||||
/// when that one is near (an eighth of the range), and kept only when it lies near the curve's
|
||||
/// leader (a seeded search can slide to another branch of the surfaces' intersection); else the
|
||||
/// unseeded search decides. `None` where no point is found.
|
||||
pub(crate) struct EdgeSampler {
|
||||
curve: mt::Curve,
|
||||
range: (f64, f64),
|
||||
hint: Option<(f64, mt::Point2, mt::Point2)>,
|
||||
}
|
||||
|
||||
impl EdgeSampler {
|
||||
pub(crate) fn new(edge: &mt::Edge) -> Option<EdgeSampler> {
|
||||
let curve = edge.oriented_curve();
|
||||
let range = curve.range_tuple();
|
||||
(range.0.is_finite() && range.1.is_finite() && range.0 < range.1).then_some(EdgeSampler { curve, range, hint: None })
|
||||
}
|
||||
|
||||
pub(crate) fn at(&mut self, s: f64) -> Option<Vec3> {
|
||||
let (t0, t1) = self.range;
|
||||
let t = if s >= 1.0 { t1 } else { t0 + (t1 - t0) * s };
|
||||
let mt::Curve::IntersectionCurve(ic) = &self.curve else { return curve_point(&self.curve, t) };
|
||||
let leader = ic.leader();
|
||||
let (p, d) = (leader.subs(t), leader.der(t));
|
||||
let near = |q: mt::Point3| q.distance(p) <= 4.0 * ic.tolerance() + 1e-6;
|
||||
let seeded = self
|
||||
.hint
|
||||
.filter(|(tp, _, _)| (t - tp).abs() <= (t1 - t0) / 8.0)
|
||||
.and_then(|(_, a, b)| mt::double_projection(ic.surface0(), Some((a.x, a.y)), ic.surface1(), Some((b.x, b.y)), p, d, 100))
|
||||
.filter(|f| near(f.0));
|
||||
let found = seeded.or_else(|| mt::double_projection(ic.surface0(), None, ic.surface1(), None, p, d, 100))?;
|
||||
self.hint = Some((t, found.1, found.2));
|
||||
let q = from_p3(found.0);
|
||||
q.is_finite().then_some(q)
|
||||
}
|
||||
}
|
||||
|
||||
/// The planar face bounded by the closed loop `chain` (each edge oriented along it) when its
|
||||
/// edges are certainly in one plane, `None` otherwise (also when an edge cannot be sampled).
|
||||
/// The plane is fitted to points along the edges ([`plane_through`], the normal turning with the
|
||||
/// loop); the edges are certified within `tol` of it conservatively: a line by its ends, a
|
||||
/// B-spline or NURBS curve with positive weights by its control points (the curve lies in their
|
||||
/// convex hull), an intersection curve only when one of its two surfaces is a plane that the
|
||||
/// loop's points lie on and the fitted plane agrees with over the loop (the curve lies in that
|
||||
/// plane). Samples alone are never trusted: a spline can bulge out of the plane between them.
|
||||
pub(crate) fn planar_face(chain: &[mt::Edge], tol: f64) -> Result<Option<mt::Face>> {
|
||||
const SAMPLES: usize = 16;
|
||||
let mut pts = Vec::with_capacity(chain.len() * SAMPLES);
|
||||
for e in chain {
|
||||
let Some(mut sampler) = EdgeSampler::new(e) else { return Ok(None) };
|
||||
for k in 0..SAMPLES {
|
||||
let Some(p) = sampler.at(k as f64 / SAMPLES as f64) else { return Ok(None) };
|
||||
pts.push(p3(p));
|
||||
}
|
||||
}
|
||||
let Some(plane) = plane_through(&pts) else { return Ok(None) };
|
||||
let (o, n) = (plane.origin(), plane.normal());
|
||||
let off = |p: mt::Point3, o: mt::Point3, n: mt::Vector3| (p - o).dot(n).abs();
|
||||
let on_plane = |p: mt::Point3| off(p, o, n) <= tol;
|
||||
if !pts.iter().all(|p| on_plane(*p)) {
|
||||
return Ok(None);
|
||||
}
|
||||
// A support plane `q` of an intersection curve holds the loop when the loop's points lie on
|
||||
// it and their feet on the fitted plane do too.
|
||||
let holds = |q: &mt::Plane| {
|
||||
let (qo, qn) = (q.origin(), q.normal());
|
||||
pts.iter().all(|p| off(*p, qo, qn) <= tol && off(*p - n * (*p - o).dot(n), qo, qn) <= tol)
|
||||
};
|
||||
for e in chain {
|
||||
let certified = match e.curve() {
|
||||
mt::Curve::Line(l) => on_plane(l.0) && on_plane(l.1),
|
||||
mt::Curve::BSplineCurve(b) => b.control_points().iter().all(|p| on_plane(*p)),
|
||||
mt::Curve::NurbsCurve(c) => {
|
||||
c.non_rationalized().control_points().iter().all(|h| h.w > 0.0 && on_plane(mt::Point3::new(h.x / h.w, h.y / h.w, h.z / h.w)))
|
||||
}
|
||||
mt::Curve::IntersectionCurve(ic) => [ic.surface0(), ic.surface1()].into_iter().any(|s| matches!(s, mt::Surface::Plane(q) if holds(q))),
|
||||
};
|
||||
if !certified {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let wire: mt::Wire = chain.to_vec().into();
|
||||
Ok(Some(mt::Face::try_new(vec![wire], mt::Surface::Plane(plane)).map_err(|e| KernelError::Failed(format!("patch: {e}")))?))
|
||||
}
|
||||
@@ -460,7 +460,14 @@ fn sew_impl(faces: &[FaceSpec], touching: bool, sources: Option<&[Option<crate::
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let face = match &f.surface {
|
||||
SurfSpec::Plane => builder::try_attach_plane(&wires).map_err(|e| KernelError::Failed(format!("sew: face {fi}: {e}")))?,
|
||||
SurfSpec::Plane => match builder::try_attach_plane(&wires) {
|
||||
Ok(face) => face,
|
||||
// truck's fit loses thin faces away from the origin: see `plane_about_the_mean`.
|
||||
Err(e) => {
|
||||
let plane = plane_about_the_mean(&wires).ok_or_else(|| KernelError::Failed(format!("sew: face {fi}: {e}")))?;
|
||||
mt::Face::try_new(wires, mt::Surface::Plane(plane)).map_err(|e| KernelError::Failed(format!("sew: face {fi}: {e}")))?
|
||||
}
|
||||
},
|
||||
SurfSpec::Revolved { a, b, axis_origin, axis, angle } => {
|
||||
let k = axis.normalized().ok_or_else(|| KernelError::Invalid("sew: axis".into()))?;
|
||||
let (va, vb) = (builder::vertex(p3(*a)), builder::vertex(p3(*b)));
|
||||
@@ -558,6 +565,35 @@ fn curved(mut surf: mt::Surface, wires: Vec<mt::Wire>, probe: Option<(Vec3, Vec3
|
||||
mt::Face::try_new(wires, surf).map_err(|e| KernelError::Failed(format!("sew: face {fi}: {e}")))
|
||||
}
|
||||
|
||||
/// The plane of a boundary that truck's fit (`builder::try_attach_plane`) refused: truck's fit
|
||||
/// again, on the boundary moved so that the mean of its control points is the origin, and the
|
||||
/// plane moved back. None when truck refuses that one too (the boundary is not flat within
|
||||
/// truck's 1e-6, or has no area).
|
||||
///
|
||||
/// truck sums the cross products of consecutive control points about a centre, and takes the
|
||||
/// points' sum for that centre (it divides by the number of wires, not of points): eight times as
|
||||
/// far from the origin as a rectangle of four lines. The sum is the same about any centre; its
|
||||
/// rounding is not. It grows with the square of the centre's distance, and on a thin face, whose
|
||||
/// normal is short, turns the normal far enough for truck's own flatness test to fail: truck
|
||||
/// finds a 0.00013 x 30 mm face 360 mm from the origin 2e-6 mm out of the plane it fitted. With
|
||||
/// the centre at the origin the same face is 1e-14 mm out at most.
|
||||
fn plane_about_the_mean(wires: &[mt::Wire]) -> Option<mt::Plane> {
|
||||
let (mut sum, mut count) = (Vec3::ZERO, 0usize);
|
||||
for p in wires.iter().flat_map(|w| w.edge_iter()).flat_map(|e| e.oriented_curve().lift_up().control_points().clone()) {
|
||||
sum += Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w);
|
||||
count += 1;
|
||||
}
|
||||
let mean = sum * (1.0 / count.max(1) as f64);
|
||||
if !mean.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let moved: Vec<mt::Wire> = wires.iter().map(|w| builder::transformed(w, mt::Matrix4::from_translation(v3(-mean)))).collect();
|
||||
match builder::try_attach_plane(&moved).ok()?.surface() {
|
||||
mt::Surface::Plane(plane) => Some(mt::Transformed::transformed(&plane, mt::Matrix4::from_translation(v3(mean)))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -734,4 +770,61 @@ mod tests {
|
||||
let e = sew(&bulged_box(bulge(0.0), whole)).unwrap_err().to_string();
|
||||
assert!(e.contains("ruled chains differ"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_faces_away_from_the_origin_get_their_planes() {
|
||||
// Slabs 30 x 2 mm and 0.00013 to 0.1 mm thick, away from the origin and turned off the
|
||||
// axes. truck's plane fit sums the cross products of a boundary's points about a centre
|
||||
// and takes the points' sum for it, eight times as far from the origin as a rectangle;
|
||||
// on the thin faces its normal then comes out so far off that truck finds a
|
||||
// 0.00013 x 30 mm face at (-300, -200, 5) 2e-6 mm out of its own plane and refuses it:
|
||||
// `cannot attach a plane to a wire that is not on one plane`. Nine of these 45 slabs
|
||||
// have such a face. (Thinner slabs meet another limit, at the origin too: a face
|
||||
// 0.00003 mm wide and askew to the axes is sewn but gets no triangles.)
|
||||
let turned = |about_z: f64, about_x: f64| {
|
||||
let (sz, cz) = about_z.sin_cos();
|
||||
let (sx, cx) = about_x.sin_cos();
|
||||
// The x, y and z axes turned about x, then about z.
|
||||
(Vec3::new(cz, sz, 0.0), Vec3::new(-sz * cx, cz * cx, sx), Vec3::new(sz * sx, -cz * sx, cx))
|
||||
};
|
||||
// A box on the corner `o` with the edges `u`, `v` and `w` (right-handed).
|
||||
let skew_box = |o: Vec3, u: Vec3, v: Vec3, w: Vec3| {
|
||||
let p = |x: f64, y: f64, z: f64| o + u * x + v * y + w * z;
|
||||
let (a, b, c, d) = (p(0., 0., 0.), p(1., 0., 0.), p(1., 1., 0.), p(0., 1., 0.));
|
||||
let (e, f, g, h) = (p(0., 0., 1.), p(1., 0., 1.), p(1., 1., 1.), p(0., 1., 1.));
|
||||
vec![quad(a, d, c, b), quad(e, f, g, h), quad(a, b, f, e), quad(b, c, g, f), quad(c, d, h, g), quad(d, a, e, h)]
|
||||
};
|
||||
for o in [Vec3::new(-300.0, -200.0, 5.0), Vec3::new(2500.0, -1800.0, 40.0), Vec3::ZERO] {
|
||||
for width in [1.3e-4, 1e-3, 0.1] {
|
||||
for (about_z, about_x) in [(0.0, 0.0), (0.0, 0.45), (0.9, 0.0), (-0.65, 0.45), (2.2, 1.1)] {
|
||||
let tag = format!("{o:?}, {width} wide, turned {about_z} and {about_x}");
|
||||
let (u, v, w) = turned(about_z, about_x);
|
||||
let body = sew(&skew_box(o, u * width, v * 2.0, w * 30.0)).unwrap_or_else(|e| panic!("{tag}: {e}"));
|
||||
assert!(body.validity().is_empty() && body.shell_count() == 1 && body.face_count() == 6, "{tag}: {:?}", body.validity());
|
||||
// (The measure sums about the origin: it is good to about 1e-14 of the
|
||||
// distance cubed, which three metres out is more than the thinnest slabs hold.)
|
||||
let (got, want) = (crate::measure(&body).unwrap().volume, width * 60.0);
|
||||
assert!((got - want).abs() < 1e-5 * want + 1e-14 * o.len().powi(3) + 1e-9, "{tag}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// A boundary that leaves its plane is refused as before: one corner of a face lifted by
|
||||
// a thousandth of a millimetre, on a cube and on a sliver.
|
||||
for size in [Vec3::new(10.0, 10.0, 10.0), Vec3::new(10.0, 2e-4, 10.0)] {
|
||||
let mut warped = box_faces(Vec3::new(500.0, 300.0, 0.0), size);
|
||||
if let EdgeSpec::Line { b, .. } = &mut warped[1].loops[0][1] {
|
||||
b.z += 1e-3;
|
||||
}
|
||||
if let EdgeSpec::Line { a, .. } = &mut warped[1].loops[0][2] {
|
||||
a.z += 1e-3;
|
||||
}
|
||||
let e = sew(&warped).unwrap_err().to_string();
|
||||
assert!(e.contains("face 1") && e.contains("not on one plane"), "{e}");
|
||||
}
|
||||
// So is one with no area.
|
||||
let mut flat = box_faces(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0));
|
||||
let p = Vec3::new(0.0, 0.0, 10.0);
|
||||
flat[1] = quad(p, p + Vec3::X * 3.0, p + Vec3::X * 7.0, p + Vec3::X * 10.0);
|
||||
assert!(sew(&flat).unwrap_err().to_string().contains("not on one plane"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! geom's B-spline curves and surfaces as truck's NURBS geometry, weights kept (homogeneous
|
||||
//! control points carried over as they are).
|
||||
|
||||
use solidcraft_geom::bspline::{HPoint, NurbsSurface};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
fn fail(m: impl std::fmt::Display) -> KernelError {
|
||||
KernelError::Failed(format!("spline geometry: {m}"))
|
||||
}
|
||||
|
||||
/// A homogeneous control point as truck's.
|
||||
pub(crate) fn h4(h: HPoint) -> mt::Vector4 {
|
||||
mt::Vector4::new(h.x, h.y, h.z, h.w)
|
||||
}
|
||||
|
||||
/// A NURBS curve of knots and homogeneous control points.
|
||||
pub(crate) fn nurbs_curve(knots: mt::KnotVec, ctrl: &[HPoint]) -> Result<mt::Curve> {
|
||||
let c = mt::BSplineCurve::try_new(knots, ctrl.iter().copied().map(h4).collect()).map_err(fail)?;
|
||||
Ok(mt::Curve::NurbsCurve(mt::NurbsCurve::new(c)))
|
||||
}
|
||||
|
||||
/// A NURBS surface of knots and homogeneous control points (`rows[i][j]`: i along u).
|
||||
pub(crate) fn nurbs_surface(knots: (mt::KnotVec, mt::KnotVec), rows: Vec<Vec<mt::Vector4>>) -> Result<mt::Surface> {
|
||||
let s = mt::BSplineSurface::try_new(knots, rows).map_err(fail)?;
|
||||
Ok(mt::Surface::NurbsSurface(mt::NurbsSurface::new(s)))
|
||||
}
|
||||
|
||||
/// geom's tensor surface as truck's.
|
||||
pub(crate) fn surface(s: &NurbsSurface) -> Result<mt::Surface> {
|
||||
let [nu, nv] = s.count();
|
||||
let [ku, kv] = s.knots();
|
||||
let rows: Vec<Vec<mt::Vector4>> = (0..nu).map(|i| (0..nv).map(|j| h4(s.point(i, j))).collect()).collect();
|
||||
nurbs_surface((mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone())), rows)
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mt::{BoundedCurve, EuclideanSpace, InnerSpace, MetricSpace, ParametricCurve};
|
||||
use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use super::Ctx;
|
||||
@@ -244,7 +244,7 @@ impl Builder {
|
||||
})
|
||||
.collect();
|
||||
let surface = if e.name() == "FACE" {
|
||||
plane_through(&extent).ok_or("polygonal face without a plane")?
|
||||
crate::plane_fit::plane_through(&extent).map(mt::Surface::Plane).ok_or("polygonal face without a plane")?
|
||||
} else {
|
||||
let sid = rid(p.get(2), "face geometry")?;
|
||||
let sense = p.get(3).and_then(Param::as_bool).unwrap_or(true);
|
||||
@@ -344,30 +344,6 @@ impl Builder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Plane through a polygon (Newell's normal).
|
||||
fn plane_through(pts: &[mt::Point3]) -> Option<mt::Surface> {
|
||||
let n = pts.len();
|
||||
let mut nrm = mt::Vector3::new(0.0, 0.0, 0.0);
|
||||
let mut c = mt::Vector3::new(0.0, 0.0, 0.0);
|
||||
for i in 0..n {
|
||||
let (a, b) = (pts.get(i)?, pts.get((i + 1) % n)?);
|
||||
nrm.x += (a.y - b.y) * (a.z + b.z);
|
||||
nrm.y += (a.z - b.z) * (a.x + b.x);
|
||||
nrm.z += (a.x - b.x) * (a.y + b.y);
|
||||
c += a.to_homogeneous().truncate();
|
||||
}
|
||||
let len = nrm.magnitude();
|
||||
if !(len > 1e-12) || n == 0 {
|
||||
return None;
|
||||
}
|
||||
let z = nrm / len;
|
||||
let o = mt::Point3::from_vec(c / n as f64);
|
||||
let a = if z.x.abs() < 0.9 { mt::Vector3::unit_x() } else { mt::Vector3::unit_y() };
|
||||
let x = (a - z * a.dot(z)).normalize();
|
||||
let y = z.cross(x);
|
||||
Some(mt::Surface::Plane(mt::Plane::new(o, o + x, o + y)))
|
||||
}
|
||||
|
||||
/// Read a solid-like representation item: MANIFOLD_SOLID_BREP, BREP_WITH_VOIDS, FACETED_BREP,
|
||||
/// or a SHELL_BASED_SURFACE_MODEL (closed shells give solids, open ones open bodies).
|
||||
pub(crate) fn read_item(cx: &Ctx, id: u64) -> R<BrepOut> {
|
||||
|
||||
@@ -1214,6 +1214,39 @@ fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap<u64, V
|
||||
Ok((ex, merged, paint))
|
||||
}
|
||||
|
||||
/// The separate pieces of a body for export, each keeping its face colours: a body of several
|
||||
/// lumps (a plate and the islands a cut left inside its holes, such as letter counters) is
|
||||
/// several solids, never one solid whose other pieces are written as voids. A body in one
|
||||
/// piece, or one whose pieces cannot be told apart, is itself.
|
||||
fn export_lumps(b: &Body) -> Vec<Body> {
|
||||
if b.mesh.is_some() || b.solid.boundaries().len() < 2 {
|
||||
return vec![b.clone()];
|
||||
}
|
||||
let lumps = match b.lumps() {
|
||||
Ok(l) if l.len() > 1 => l,
|
||||
_ => return vec![b.clone()],
|
||||
};
|
||||
let index: HashMap<_, usize> = b.solid.face_iter().enumerate().map(|(i, f)| (f.id(), i)).collect();
|
||||
lumps
|
||||
.into_iter()
|
||||
.map(|lump| {
|
||||
let paint = b.paint().map(|p| crate::body::Paint {
|
||||
opacity: p.opacity,
|
||||
faces: lump
|
||||
.solid
|
||||
.face_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(k, f)| {
|
||||
let i = index.get(&f.id())?;
|
||||
p.faces.iter().find(|fp| fp.face == *i).map(|fp| crate::body::FacePaint { face: k, ..fp.clone() })
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
lump.with_color(b.color()).with_split_lines(b.split_lines().cloned()).with_paint(paint)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Faces in the exchange's shells (fewer after merging).
|
||||
fn shell_face_count(ex: &p21::Exchange) -> usize {
|
||||
ex.entities
|
||||
@@ -1366,26 +1399,28 @@ fn products_text(products: &[ExportProduct], root: usize, header: &StepHeader, m
|
||||
if !p.bodies.is_empty() {
|
||||
let mut solids = Vec::new();
|
||||
for b in &p.bodies {
|
||||
let (ex, merged, face_paint) = brep_exchange(b.body, merge)?;
|
||||
o.merged = merged;
|
||||
o.faces_out.clear();
|
||||
let mut roots: Vec<u64> =
|
||||
ex.entities.iter().filter(|(_, e)| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS")).map(|(i, _)| *i).collect();
|
||||
roots.sort();
|
||||
for r in roots {
|
||||
let id = o.copy_solid(&ex, r, &b.name).map_err(|e| KernelError::Failed(format!("STEP export: {e}")))?;
|
||||
solids.push(id);
|
||||
let opacity = b.body.paint().map(|p| p.opacity).unwrap_or(1.0);
|
||||
if let Some(c) = b.color {
|
||||
styled.push(style(&mut o, id, c, opacity));
|
||||
for lump in export_lumps(b.body) {
|
||||
let (ex, merged, face_paint) = brep_exchange(&lump, merge)?;
|
||||
o.merged = merged;
|
||||
o.faces_out.clear();
|
||||
let mut roots: Vec<u64> =
|
||||
ex.entities.iter().filter(|(_, e)| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS")).map(|(i, _)| *i).collect();
|
||||
roots.sort();
|
||||
for r in roots {
|
||||
let id = o.copy_solid(&ex, r, &b.name).map_err(|e| KernelError::Failed(format!("STEP export: {e}")))?;
|
||||
solids.push(id);
|
||||
let opacity = lump.paint().map(|p| p.opacity).unwrap_or(1.0);
|
||||
if let Some(c) = b.color {
|
||||
styled.push(style(&mut o, id, c, opacity));
|
||||
}
|
||||
}
|
||||
// Faces with colours of their own.
|
||||
let mut painted: Vec<(u64, ([f32; 3], f32))> =
|
||||
face_paint.into_iter().filter_map(|(f, look)| Some((*o.faces_out.get(&f)?, look))).collect();
|
||||
painted.sort_by_key(|(f, _)| *f);
|
||||
for (f, (c, op)) in painted {
|
||||
styled.push(style(&mut o, f, c, op));
|
||||
}
|
||||
}
|
||||
// Faces with colours of their own.
|
||||
let mut painted: Vec<(u64, ([f32; 3], f32))> =
|
||||
face_paint.into_iter().filter_map(|(f, look)| Some((*o.faces_out.get(&f)?, look))).collect();
|
||||
painted.sort_by_key(|(f, _)| *f);
|
||||
for (f, (c, op)) in painted {
|
||||
styled.push(style(&mut o, f, c, op));
|
||||
}
|
||||
}
|
||||
let list = solids.iter().chain(std::iter::once(&origin)).map(|i| format!("#{i}")).collect::<Vec<_>>().join(",");
|
||||
@@ -1484,6 +1519,34 @@ pub fn step_export_bodies(bodies: &[ExportBody], header: &StepHeader) -> Result<
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn islands_are_solids_of_their_own_not_voids() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let square = |a: f64, b: f64| Loop2::polygon(&[Vec2::new(a, a), Vec2::new(b, a), Vec2::new(b, b), Vec2::new(a, b)]);
|
||||
// A plate with a square hole, and an island inside the hole (a letter's counter).
|
||||
let plate = Region2 { outer: square(0.0, 30.0), holes: vec![square(10.0, 20.0).reversed()] };
|
||||
let island = Region2 { outer: square(12.0, 18.0), holes: vec![] };
|
||||
let body = crate::extrude_planar_regions(&Plane::XY, &[plate, island], 0.0, 2.0).unwrap();
|
||||
assert_eq!(body.shell_count(), 2);
|
||||
let text = crate::step_export(&[&body], "test").unwrap();
|
||||
assert_eq!(text.matches("MANIFOLD_SOLID_BREP(").count(), 2, "{text}");
|
||||
assert_eq!(text.matches("BREP_WITH_VOIDS").count(), 0);
|
||||
let imp = crate::step_import(&text).unwrap();
|
||||
let v: f64 = imp.bodies.iter().map(|b| crate::measure(&b.body).unwrap().volume).sum();
|
||||
assert!((v - (800.0 + 36.0) * 2.0).abs() < 1e-6, "{v}");
|
||||
// A real void stays a void.
|
||||
let hollow = crate::boolean(
|
||||
&crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(),
|
||||
&crate::box_solid(Vec3::new(3.0, 3.0, 3.0), Vec3::new(7.0, 7.0, 7.0)).unwrap(),
|
||||
crate::BoolOp::Cut,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(hollow.shell_count(), 2);
|
||||
let text = crate::step_export(&[&hollow], "test").unwrap();
|
||||
assert_eq!(text.matches("BREP_WITH_VOIDS").count(), 1, "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_and_strings() {
|
||||
assert_eq!(real(1.0), "1.0");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Surface bodies (open shells) and the operations that turn them into solids: Patch (a planar
|
||||
//! face filling a closed loop of edges or a sketch region), Stitch (surfaces sharing edges sewn
|
||||
//! Surface bodies (open shells) and the operations that turn them into solids: Patch (a face
|
||||
//! filling a closed loop of edges — planar, or B-spline patches for a loop that is not — or a
|
||||
//! planar sketch region), Stitch (surfaces sharing edges sewn
|
||||
//! into one body, a solid when they close), Trim (a surface cut by a tool, one side kept),
|
||||
//! Thicken (a surface offset along its normals into a solid) and copying faces out of a body.
|
||||
|
||||
@@ -76,13 +77,31 @@ pub(crate) fn edge_near(body: &Body, p: Vec3) -> Option<mt::Edge> {
|
||||
best.filter(|(d, _)| *d <= body.size() * 0.05 + 1e-6).map(|(_, e)| e)
|
||||
}
|
||||
|
||||
/// A planar surface filling a closed loop of a body's edges (each picked by a point on it). Its
|
||||
/// history is over `[body]`: the face is new, its edges the body's.
|
||||
/// A surface filling a closed loop of a body's edges (each picked by a point on it): one face on
|
||||
/// the body's own edges, planar when the loop is certainly planar ([`crate::plane_fit`]), else a
|
||||
/// B-spline height field over the loop's plane ([`crate::fill`]; loops that do not bound one are
|
||||
/// refused). Its history is over `[body]`: the face is new, its edges the body's own.
|
||||
pub fn patch_edges(body: &Body, at: &[Vec3]) -> Result<Body> {
|
||||
body.require_brep("patch")?;
|
||||
if at.is_empty() || at.len() > 10_000 {
|
||||
return Err(KernelError::Invalid("pick the edges of a closed loop".into()));
|
||||
}
|
||||
guard("patch", || {
|
||||
let chain = picked_loop(body, at)?;
|
||||
let Some(face) = crate::plane_fit::planar_face(&chain, crate::fill::planarity_tolerance(body))? else {
|
||||
// Not certainly planar: a fitted surface.
|
||||
return crate::fill::fill_loop(body, &chain);
|
||||
};
|
||||
// A new face on the body's own edges.
|
||||
let mut rec = Rebuild::default();
|
||||
rec.made(&face, Vec::new());
|
||||
Ok(rebuilt_body(surface_body(vec![face]), &[&body.solid], &rec))
|
||||
})
|
||||
}
|
||||
|
||||
/// The edges of `body` picked by points on them, chained into one closed loop (each turned to
|
||||
/// run along it). Call it under [`guard`]: sampling an intersection curve can fail in truck.
|
||||
pub(crate) fn picked_loop(body: &Body, at: &[Vec3]) -> Result<Vec<mt::Edge>> {
|
||||
let mut edges: Vec<mt::Edge> = Vec::new();
|
||||
for p in at {
|
||||
let e = edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?;
|
||||
@@ -90,34 +109,25 @@ pub fn patch_edges(body: &Body, at: &[Vec3]) -> Result<Body> {
|
||||
edges.push(e);
|
||||
}
|
||||
}
|
||||
guard("patch", || {
|
||||
// Chain the edges into one loop (turning them round as needed).
|
||||
let mut pool = edges;
|
||||
let first = pool.pop().ok_or_else(|| KernelError::Invalid("no edges".into()))?;
|
||||
let start = first.front().id();
|
||||
let mut at_v = first.back().clone();
|
||||
let mut chain = vec![first];
|
||||
while at_v.id() != start {
|
||||
let k = pool
|
||||
.iter()
|
||||
.position(|e| e.front().id() == at_v.id() || e.back().id() == at_v.id())
|
||||
.ok_or_else(|| KernelError::Invalid("the edges do not form a closed loop".into()))?;
|
||||
let e = pool.remove(k);
|
||||
let e = if e.front().id() == at_v.id() { e } else { e.inverse() };
|
||||
at_v = e.back().clone();
|
||||
chain.push(e);
|
||||
}
|
||||
if !pool.is_empty() {
|
||||
return Err(KernelError::Invalid("the edges form more than one loop".into()));
|
||||
}
|
||||
let wire: mt::Wire = chain.into();
|
||||
let face = mt::builder::try_attach_plane(&[wire])
|
||||
.map_err(|_| KernelError::Invalid("not supported yet: patching a loop that is not planar".into()))?;
|
||||
// A new face on the body's own edges.
|
||||
let mut rec = Rebuild::default();
|
||||
rec.made(&face, Vec::new());
|
||||
Ok(rebuilt_body(surface_body(vec![face]), &[&body.solid], &rec))
|
||||
})
|
||||
let mut pool = edges;
|
||||
let first = pool.pop().ok_or_else(|| KernelError::Invalid("no edges".into()))?;
|
||||
let start = first.front().id();
|
||||
let mut at_v = first.back().clone();
|
||||
let mut chain = vec![first];
|
||||
while at_v.id() != start {
|
||||
let k = pool
|
||||
.iter()
|
||||
.position(|e| e.front().id() == at_v.id() || e.back().id() == at_v.id())
|
||||
.ok_or_else(|| KernelError::Invalid("the edges do not form a closed loop".into()))?;
|
||||
let e = pool.remove(k);
|
||||
let e = if e.front().id() == at_v.id() { e } else { e.inverse() };
|
||||
at_v = e.back().clone();
|
||||
chain.push(e);
|
||||
}
|
||||
if !pool.is_empty() {
|
||||
return Err(KernelError::Invalid("the edges form more than one loop".into()));
|
||||
}
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
/// Faces of a body copied out as a surface body (faces picked by points on them). Its history is
|
||||
|
||||
@@ -125,6 +125,32 @@ fn revolve_washer_and_sphere() {
|
||||
assert!(rel(ms.volume, 4.0 / 3.0 * PI * 1000.0) < 1e-3, "{}", ms.volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_adjacent_profiles_on_the_axis() {
|
||||
// Two rectangles on the axis, revolved together: their merged outline keeps a vertex
|
||||
// where the shared edge met the axis. The swept axis pieces used to be zero-area faces,
|
||||
// and cutting the body's flat end out of another failed.
|
||||
let r = Region2 {
|
||||
outer: Loop2::polygon(&[
|
||||
Vec2::new(0.0, 0.0),
|
||||
Vec2::new(30.0, 0.0),
|
||||
Vec2::new(60.0, 0.0),
|
||||
Vec2::new(60.0, 20.0),
|
||||
Vec2::new(30.0, 20.0),
|
||||
Vec2::new(0.0, 20.0),
|
||||
]),
|
||||
holes: vec![],
|
||||
};
|
||||
let b = revolve(&Plane::XZ, &[r], Vec2::ZERO, Vec2::X, 2.0 * PI).unwrap().pop().unwrap();
|
||||
assert!(rel(measure(&b).unwrap().volume, PI * 400.0 * 60.0) < 5e-4);
|
||||
let faces = b.faces(1e-3).unwrap();
|
||||
assert!(faces.iter().all(|f| f.area > 1e-6), "{:?}", faces.iter().map(|f| f.area).collect::<Vec<_>>());
|
||||
let disc = cylinder(Vec3::new(-1.0, 0.0, 0.0), Vec3::X, 40.0, 3.0).unwrap();
|
||||
let cut = boolean(&disc, &b, BoolOp::Cut).unwrap().unwrap();
|
||||
let want = PI * 1600.0 * 3.0 - PI * 400.0 * 2.0;
|
||||
assert!(rel(measure(&cut).unwrap().volume, want) < 1e-3, "{} vs {want}", measure(&cut).unwrap().volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfer_and_step() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap();
|
||||
@@ -356,6 +382,29 @@ fn fillet_max_finds_the_largest_radius_that_works() {
|
||||
assert!(fillet_max(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).is_none());
|
||||
}
|
||||
|
||||
/// A corner's rounds too large for its faces are refused as too large, so a fit can reduce
|
||||
/// them, also where only the corner build finds it (the edge-by-edge blend failing otherwise).
|
||||
#[test]
|
||||
fn corner_rounds_larger_than_their_faces_are_too_large() {
|
||||
let (l, eps) = (20.0, 1e-9);
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(l, l, l)).unwrap();
|
||||
let corner = [Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(0.0, 0.0, 10.0)];
|
||||
// As wide as the faces: the first edge's round uses up its neighbour, then the edge-by-edge
|
||||
// blend can't find it; the corner build is what says the radius is too large.
|
||||
let e = fillet(&b, &corner, l).unwrap_err();
|
||||
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
||||
let (r, body) = fillet_max(&b, &corner, l).unwrap();
|
||||
assert!((r - 19.99).abs() < eps, "{r}");
|
||||
assert!(body.validity().is_empty(), "{:?}", body.validity());
|
||||
// Three rounds along the edges past the corner cube, and the cube less its sphere octant.
|
||||
let removed = 3.0 * (1.0 - PI / 4.0) * r * r * (l - r) + r * r * r * (1.0 - PI / 6.0);
|
||||
assert!(rel(measure(&body).unwrap().volume, l * l * l - removed) < 2e-4);
|
||||
// Different radii per edge, one wider than its faces.
|
||||
let sel: Vec<(Vec3, f64)> = corner.iter().copied().zip([21.0, 5.0, 5.0]).collect();
|
||||
let e = crate::blend_corner::fillet_radii(&b, &sel).unwrap_err();
|
||||
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tapered_extrudes() {
|
||||
// 40 x 40 square, 30 up, 10° outward: a frustum.
|
||||
@@ -2123,3 +2172,29 @@ fn shell_open_beside_a_round_as_large_as_the_wall_is_refused() {
|
||||
let f = fillet(&b, &loop_, 1.5).unwrap();
|
||||
assert!(shell(&f, &bottom, 2.0).unwrap().validity().is_empty());
|
||||
}
|
||||
|
||||
/// A disc whose circular edge is rounded smaller than the wall (#319): moved in by the
|
||||
/// thickness the round collapses to a sharp edge, where the floor meets the side along a
|
||||
/// circle (two arcs with the same ends), not a straight line.
|
||||
#[test]
|
||||
fn shell_of_a_disc_rounded_smaller_than_the_wall() {
|
||||
use std::f64::consts::PI;
|
||||
let (rd, h, r) = (15.0, 5.0, 1.5);
|
||||
let disc = cylinder(Vec3::new(0.0, 0.0, -h), Vec3::Z, rd, h).unwrap();
|
||||
// The round takes a corner of area r²(1 − π/4) whose centroid is r(10 − 3π)/(3(4 − π))
|
||||
// in from the rim, swept round the axis (Pappus).
|
||||
let corner = r * r * (1.0 - PI / 4.0) * 2.0 * PI * (rd - r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
|
||||
let (top, bottom) = ([Vec3::ZERO], [Vec3::new(0.0, 0.0, -h)]);
|
||||
for (edge_z, open) in [(-h, &top), (-h, &bottom), (0.0, &bottom)] {
|
||||
let rounded = fillet(&disc, &[Vec3::new(0.0, rd, edge_z), Vec3::new(0.0, -rd, edge_z)], r).unwrap();
|
||||
let v0 = PI * rd * rd * h - corner;
|
||||
for t in [1.6, 2.0] {
|
||||
let s = shell(&rounded, open, t).unwrap_or_else(|e| panic!("round at z {edge_z}, t {t}: {e}"));
|
||||
assert!(s.validity().is_empty(), "round at z {edge_z}, t {t}: {:?}", s.validity());
|
||||
// The cavity: a plain cylinder the thickness in from the side and the closed end.
|
||||
let want = v0 - PI * (rd - t).powi(2) * (h - t);
|
||||
let v = measure(&s).unwrap().volume;
|
||||
assert!(rel(v, want) < 1e-3, "round at z {edge_z}, t {t}: {v} vs {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use solidcraft_geom::tspline::TSpline;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::spline_conv::{h4, nurbs_curve, nurbs_surface};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
/// A T-spline as a surface body (an open shell of NURBS faces whose normals follow
|
||||
@@ -34,25 +35,11 @@ fn fail(m: impl std::fmt::Display) -> KernelError {
|
||||
KernelError::Failed(format!("t-spline sheet: {m}"))
|
||||
}
|
||||
|
||||
fn h4(h: HPoint) -> mt::Vector4 {
|
||||
mt::Vector4::new(h.x, h.y, h.z, h.w)
|
||||
}
|
||||
|
||||
fn vertex(h: HPoint) -> Result<mt::Vertex> {
|
||||
let p = h.project().ok_or_else(|| fail("a corner has no position"))?;
|
||||
Ok(mt::builder::vertex(crate::body::p3(p)))
|
||||
}
|
||||
|
||||
fn nurbs_curve(knots: mt::KnotVec, ctrl: &[HPoint]) -> Result<mt::Curve> {
|
||||
let c = mt::BSplineCurve::try_new(knots, ctrl.iter().copied().map(h4).collect()).map_err(fail)?;
|
||||
Ok(mt::Curve::NurbsCurve(mt::NurbsCurve::new(c)))
|
||||
}
|
||||
|
||||
fn surface(knots: (mt::KnotVec, mt::KnotVec), rows: Vec<Vec<mt::Vector4>>) -> Result<mt::Surface> {
|
||||
let s = mt::BSplineSurface::try_new(knots, rows).map_err(fail)?;
|
||||
Ok(mt::Surface::NurbsSurface(mt::NurbsSurface::new(s)))
|
||||
}
|
||||
|
||||
/// One face from a clamped tensor surface, bounded by its four boundary curves.
|
||||
fn tensor_face(n: &NurbsSurface) -> Result<mt::Face> {
|
||||
let [nu, nv] = n.count();
|
||||
@@ -69,8 +56,7 @@ fn tensor_face(n: &NurbsSurface) -> Result<mt::Face> {
|
||||
let top = edge(v3, v2, nurbs_curve(ku.clone(), &row(nv - 1))?)?;
|
||||
let left = edge(v0, v3, nurbs_curve(kv.clone(), &column(0))?)?;
|
||||
let wire: mt::Wire = vec![bottom, right, top.inverse(), left.inverse()].into();
|
||||
let rows: Vec<Vec<mt::Vector4>> = (0..nu).map(|i| (0..nv).map(|j| h4(n.point(i, j))).collect()).collect();
|
||||
mt::Face::try_new(vec![wire], surface((ku, kv), rows)?).map_err(fail)
|
||||
mt::Face::try_new(vec![wire], crate::spline_conv::surface(n)?).map_err(fail)
|
||||
}
|
||||
|
||||
/// A parameter point, exact (knot values are shared, so equal points have equal bits).
|
||||
@@ -148,7 +134,7 @@ fn patch_faces(patches: &[BezierPatch]) -> Result<Vec<mt::Face>> {
|
||||
}
|
||||
let [ps, pt] = p.degree;
|
||||
let rows: Vec<Vec<mt::Vector4>> = (0..=ps).map(|i| (0..=pt).map(|j| h4(p.point(i, j))).collect()).collect();
|
||||
let surf = surface((mt::KnotVec::bezier_knot(ps), mt::KnotVec::bezier_knot(pt)), rows)?;
|
||||
let surf = nurbs_surface((mt::KnotVec::bezier_knot(ps), mt::KnotVec::bezier_knot(pt)), rows)?;
|
||||
faces.push(mt::Face::try_new(vec![wire.into()], surf).map_err(fail)?);
|
||||
}
|
||||
Ok(faces)
|
||||
|
||||
@@ -42,7 +42,7 @@ pub use curves::{
|
||||
pub use decompose::Budget;
|
||||
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
|
||||
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
|
||||
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
|
||||
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, sketch_axis_geom};
|
||||
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, MAX_CONSTRAINTS, MAX_CURVES, MAX_POINTS, SPoint, Sketch, SketchError, SketchView};
|
||||
pub use offset::OffsetChain;
|
||||
pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRoleKind, OffsetTopology};
|
||||
|
||||
@@ -55,6 +55,9 @@ pub enum LinkSource {
|
||||
Origin,
|
||||
/// An origin axis (`X`, `Y`, `Z`).
|
||||
Axis { name: String },
|
||||
/// One of the sketch's own axes (`x` or `y`): the line through the sketch origin along it,
|
||||
/// whatever plane the sketch is on (see [`sketch_axis_geom`]).
|
||||
SketchAxis { name: String },
|
||||
/// An origin plane (`XY`, `XZ`, `YZ`) or a construction plane (by name): its trace on the
|
||||
/// sketch plane.
|
||||
Plane { name: String },
|
||||
@@ -90,6 +93,7 @@ impl LinkSource {
|
||||
LinkSource::SketchPoint { point, .. } => format!("sketch point {point}"),
|
||||
LinkSource::Origin => "origin".into(),
|
||||
LinkSource::Axis { name } => format!("{name} axis"),
|
||||
LinkSource::SketchAxis { name } => format!("sketch {name} axis"),
|
||||
LinkSource::Plane { name } => format!("{name} plane"),
|
||||
LinkSource::OnSurface { curve, .. } => format!("{curve} on a face"),
|
||||
LinkSource::Intersection { a, b } => format!("intersection of {} and {}", a.describe(), b.describe()),
|
||||
@@ -100,6 +104,19 @@ impl LinkSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Half length of the line a sketch axis link produces.
|
||||
pub const SKETCH_AXIS_HALF: f64 = 100.0;
|
||||
|
||||
/// The geometry of sketch axis `name` (`x` or `y`, sketch coordinates); `None` for any other name.
|
||||
pub fn sketch_axis_geom(name: &str) -> Option<LinkGeom> {
|
||||
let u = match name {
|
||||
"x" => Vec2::X,
|
||||
"y" => Vec2::Y,
|
||||
_ => return None,
|
||||
};
|
||||
Some(LinkGeom::Line(Vec2::ZERO - u * SKETCH_AXIS_HALF, u * SKETCH_AXIS_HALF))
|
||||
}
|
||||
|
||||
/// One link and its state.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Link {
|
||||
@@ -417,6 +434,34 @@ impl Sketch {
|
||||
self.curves.get(ci).and_then(|c| c.link.as_deref()).and_then(|l| self.link(l)).is_some_and(|l| l.lost)
|
||||
}
|
||||
|
||||
/// The line of sketch axis `name` (`x` or `y`) when the sketch already has one (see
|
||||
/// [`Sketch::sketch_axis_line`]).
|
||||
pub fn sketch_axis_curve(&self, name: &str) -> Option<usize> {
|
||||
self.links
|
||||
.iter()
|
||||
.filter(|l| matches!(&l.source, LinkSource::SketchAxis { name: n } if n == name))
|
||||
.find_map(|l| self.link_curves(&l.id).into_iter().find(|c| matches!(self.curves.get(*c).map(|c| &c.kind), Some(CurveKind::Line { .. }))))
|
||||
}
|
||||
|
||||
/// The line of sketch axis `name` (`x` or `y`) that constraints can refer to: a linked
|
||||
/// construction line on the axis (it stays there and is left out of profiles), shared by
|
||||
/// every reference to that axis and added on first use. `None` when `name` is not a sketch
|
||||
/// axis.
|
||||
pub fn sketch_axis_line(&mut self, name: &str) -> Result<Option<usize>> {
|
||||
let Some(geom) = sketch_axis_geom(name) else { return Ok(None) };
|
||||
if let Some(c) = self.sketch_axis_curve(name) {
|
||||
return Ok(Some(c));
|
||||
}
|
||||
let id = self.add_link(LinkKind::Project, LinkSource::SketchAxis { name: name.into() }, &[geom])?;
|
||||
let curves = self.link_curves(&id);
|
||||
for c in &curves {
|
||||
if let Some(cu) = self.curves.get_mut(*c) {
|
||||
cu.construction = true;
|
||||
}
|
||||
}
|
||||
curves.first().copied().map(Some).ok_or_else(|| SketchError::Invalid(format!("sketch {name} axis")))
|
||||
}
|
||||
|
||||
/// Add linked geometry. Returns the link id; the new curve and point ids are
|
||||
/// [`Sketch::link_curves`] / [`Sketch::link_points`] of it.
|
||||
pub fn add_link(&mut self, kind: LinkKind, source: LinkSource, geom: &[LinkGeom]) -> Result<String> {
|
||||
|
||||
@@ -164,6 +164,8 @@ pub struct MenuState {
|
||||
pub confirm: Option<crate::delete::Pending>,
|
||||
/// The Properties window: title and measurements.
|
||||
pub props: Option<(String, Value)>,
|
||||
/// The viewport menu opened over something: it offers Select Other.
|
||||
pub over_geometry: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -346,6 +348,11 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
|
||||
v.push(act("ui.repeat", &format!("Repeat {label}"), "redo"));
|
||||
v.push(Item::sep());
|
||||
}
|
||||
// Everything under the cursor, for what lies beneath something else.
|
||||
if app.menu.over_geometry {
|
||||
v.push(act("ui.selectOther", "Select Other…", "").key("Hold click"));
|
||||
v.push(Item::sep());
|
||||
}
|
||||
if app.session.active_sketch.is_some() {
|
||||
v.extend(sketch_entity_items(app));
|
||||
v.push(Item::sep());
|
||||
@@ -726,6 +733,9 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
app.start(&id);
|
||||
}
|
||||
}
|
||||
"ui.selectOther" => {
|
||||
crate::select_other::open(app, at);
|
||||
}
|
||||
"ui.delete" if item.params.is_some() => crate::delete::request(app, p),
|
||||
"ui.delete" => delete(app),
|
||||
"ui.finishSketch" => app.finish_sketch(),
|
||||
@@ -1161,6 +1171,8 @@ fn commit_rename(app: &mut SolveApp, r: &Rename) {
|
||||
/// Open the viewport menu at a screen position. A right-click on something not selected
|
||||
/// selects it first, so the menu is about what is under the cursor.
|
||||
pub fn open(app: &mut SolveApp, at: Pos2) {
|
||||
app.menu.over_geometry = app.viewport.hover.is_some();
|
||||
crate::select_other::close(app);
|
||||
if app.session.active_sketch.is_none()
|
||||
&& let Some(h) = app.viewport.hover.clone()
|
||||
&& let Some((_, sel)) = crate::viewport::candidate(app, &[h])
|
||||
|
||||
@@ -501,6 +501,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
json!({"diagnostics": crate::help::diagnostics(app), "version": crate::help::VERSION, "commit": crate::help::COMMIT, "built": crate::help::BUILD_DATE}),
|
||||
),
|
||||
},
|
||||
"ui.selectOther" => wrap(crate::select_other::control(app, p)),
|
||||
"ui.menuPick" => {
|
||||
let Some(key) = s("item") else { return err("missing `item` (id or label)") };
|
||||
wrap(crate::context_menu::pick(app, key))
|
||||
|
||||
@@ -2321,7 +2321,7 @@ pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> {
|
||||
_ => return Err("Select an origin plane, construction plane or planar body face".into()),
|
||||
};
|
||||
let before = app.cam;
|
||||
app.run("sketch.create", json!({ "plane": plane }))?;
|
||||
app.run("sketch.create", json!({ "plane": plane })).map_err(|error| crate::friendly_error("sketch.create", &error))?;
|
||||
app.pre_sketch_cam = Some(before);
|
||||
look_at_sketch(app);
|
||||
// On a face: look at the picked point, not the plane's origin (a corner of the face),
|
||||
|
||||
@@ -46,6 +46,9 @@ pub enum Sm {
|
||||
distance: String,
|
||||
reverse: bool,
|
||||
rule: usize,
|
||||
/// Edge flanges: the edges with an angle and/or a height of their own (the picked point,
|
||||
/// angle, height; an empty text means the dialog's), from the rows per edge.
|
||||
own: Vec<(Vec3, String, String)>,
|
||||
},
|
||||
Hem {
|
||||
length: String,
|
||||
@@ -125,6 +128,7 @@ fn new_flange(ty: usize) -> Sm {
|
||||
distance: "20 mm".into(),
|
||||
reverse: false,
|
||||
rule: 0,
|
||||
own: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +192,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
|
||||
let t = Tokens::get();
|
||||
let mut enter = false;
|
||||
match k {
|
||||
Sm::Flange { ty, height, angle, radius, position, flip, distance, reverse, rule } => {
|
||||
Sm::Flange { ty, height, angle, radius, position, flip, distance, reverse, rule, own } => {
|
||||
*ty = flange_type(inputs);
|
||||
row_label(ui, "Type");
|
||||
ui.label(FLANGE_TYPES.get(*ty).copied().unwrap_or(""));
|
||||
@@ -209,6 +213,34 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
|
||||
crate::params_dialog::complete(ui, &r, radius);
|
||||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.end_row();
|
||||
// With several edges, a row for each: an angle and a height of its own, left
|
||||
// empty for the values above (shown as the hints).
|
||||
let points = edge_point_list(inputs);
|
||||
if points.len() > 1 || own.iter().any(|o| !(o.1.trim().is_empty() && o.2.trim().is_empty())) {
|
||||
row_label(ui, "Per Edge");
|
||||
ui.label(RichText::new("angle, height").weak()).on_hover_text("An edge's own angle and height. Empty: the values above.");
|
||||
ui.end_row();
|
||||
let w = ((ui.spacing().text_edit_width - ui.spacing().item_spacing.x) / 2.0).max(40.0);
|
||||
for (n, p) in points.iter().enumerate() {
|
||||
let slot = own_slot(own, *p);
|
||||
let Some((_, a, h)) = own.get_mut(slot) else { continue };
|
||||
row_label(ui, &format!("Edge {}", n + 1));
|
||||
ui.horizontal(|ui| {
|
||||
for (text, hint, what, id) in
|
||||
[(a, angle.as_str(), "angle", "sm_edge_angle"), (h, height.as_str(), "height", "sm_edge_height")]
|
||||
{
|
||||
let r = ui
|
||||
.add(egui::TextEdit::singleline(text).hint_text(hint).desired_width(w).id_salt((id, n)))
|
||||
.on_hover_text(format!("The {what} of the edge picked at ({:.1}, {:.1}, {:.1})", p.x, p.y, p.z));
|
||||
// For scenarios and the control channel: `dialog:Edge 2 angle`.
|
||||
crate::scenario::publish_handle(&format!("dialog:Edge {} {what}", n + 1), r.rect.center());
|
||||
crate::params_dialog::complete(ui, &r, text);
|
||||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
row_label(ui, "Distance");
|
||||
@@ -419,14 +451,48 @@ fn edge_points(inputs: &[SelInput]) -> Vec<Value> {
|
||||
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// The picked edges' points.
|
||||
fn edge_point_list(inputs: &[SelInput]) -> Vec<Vec3> {
|
||||
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(*point) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// Where the own angle and height of the edge picked at `p` are kept (an empty entry on first use).
|
||||
fn own_slot(own: &mut Vec<(Vec3, String, String)>, p: Vec3) -> usize {
|
||||
own.iter().position(|o| o.0.dist(p) < 1e-9).unwrap_or_else(|| {
|
||||
own.push((p, String::new(), String::new()));
|
||||
own.len() - 1
|
||||
})
|
||||
}
|
||||
|
||||
/// The edges for `sheet.flange`: a plain point, or `{point, angle?, height?}` for an edge with
|
||||
/// an angle and/or a height of its own.
|
||||
fn flange_edges(inputs: &[SelInput], own: &[(Vec3, String, String)]) -> Vec<Value> {
|
||||
edge_point_list(inputs)
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let (a, h) = own.iter().find(|o| o.0.dist(p) < 1e-9).map(|o| (o.1.trim(), o.2.trim())).unwrap_or(("", ""));
|
||||
if a.is_empty() && h.is_empty() {
|
||||
return pt(p);
|
||||
}
|
||||
let mut e = json!({"point": pt(p)});
|
||||
for (k, v) in [("angle", a), ("height", h)] {
|
||||
if !v.is_empty() {
|
||||
e[k] = json!(v);
|
||||
}
|
||||
}
|
||||
e
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The commands the dialog's OK runs (`extra`: parameters kept from the feature being edited).
|
||||
pub fn commands(app: &SolveApp, k: &Sm, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
|
||||
let s = &app.session;
|
||||
let (cmd, mut p): (&str, Value) = match k {
|
||||
Sm::Flange { height, angle, radius, position, flip, distance, reverse, rule, .. } => {
|
||||
Sm::Flange { height, angle, radius, position, flip, distance, reverse, rule, own, .. } => {
|
||||
let mut p = match flange_type(inputs) {
|
||||
1 => {
|
||||
let mut p = json!({"type": "edge", "edges": edge_points(inputs), "height": height, "angle": angle,
|
||||
let mut p = json!({"type": "edge", "edges": flange_edges(inputs, own), "height": height, "angle": angle,
|
||||
"position": POSITIONS.get(*position).copied().unwrap_or("inside")});
|
||||
if !radius.trim().is_empty() {
|
||||
p["radius"] = json!(radius);
|
||||
@@ -572,6 +638,16 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelI
|
||||
if let Some(b) = body {
|
||||
extra.insert("body".into(), json!(b));
|
||||
}
|
||||
// The edges, and the angle and height some have of their own (kept by the point the
|
||||
// edge's selection carries).
|
||||
let (mut picked, mut own) = (Vec::new(), Vec::new());
|
||||
for e in edges {
|
||||
let Some(sel) = edge_sel(s, e.point) else { continue };
|
||||
if let (Sel::Edge { point, .. }, true) = (&sel, e.angle.is_some() || e.height.is_some()) {
|
||||
own.push((*point, e.angle.clone().unwrap_or_default(), e.height.clone().unwrap_or_default()));
|
||||
}
|
||||
picked.push(sel);
|
||||
}
|
||||
let f = Sm::Flange {
|
||||
ty: 1,
|
||||
height: height.clone(),
|
||||
@@ -582,8 +658,9 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelI
|
||||
distance: "20 mm".into(),
|
||||
reverse: false,
|
||||
rule: 0,
|
||||
own,
|
||||
};
|
||||
(f, edges.iter().filter_map(|p| edge_sel(s, *p)).collect())
|
||||
(f, picked)
|
||||
}
|
||||
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
|
||||
if let Some(b) = body {
|
||||
|
||||
@@ -318,3 +318,65 @@ fn pocket_depth_labels_follow_inches_without_changing_flat_data() {
|
||||
assert_eq!(super::pocket_label(&app, &json!({"side":"top","depth_mm":25.4*0.1234})).as_deref(), Some("Top pocket: 0.1234 in deep"));
|
||||
assert!(super::pocket_label(&app, &json!({"side":"top","depth_mm":-1})).is_none());
|
||||
}
|
||||
|
||||
/// Edges with an angle and/or a height of their own: the texts of the dialog's rows per edge
|
||||
/// become `{point, angle?, height?}` edges while the others stay plain points; Edit Feature
|
||||
/// brings them back and rebuilds the same flanges.
|
||||
#[test]
|
||||
fn edge_flanges_can_have_an_angle_and_a_height_of_their_own() {
|
||||
let mut app = plate();
|
||||
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
|
||||
let edges: Vec<Sel> = [[50.0, 0.0, 2.5], [50.0, 60.0, 2.5], [0.0, 30.0, 2.5], [100.0, 30.0, 2.5]]
|
||||
.iter()
|
||||
.filter_map(|p| edge_sel(&app.session, Vec3::new(p[0], p[1], p[2])))
|
||||
.collect();
|
||||
assert_eq!(edges.len(), 4);
|
||||
let point = |i: usize| if let Sel::Edge { point, .. } = &edges[i] { *point } else { Vec3::ZERO };
|
||||
app.session.selection = edges.clone();
|
||||
app.start("sheet.flange");
|
||||
let mut d = app.dialog.clone().unwrap();
|
||||
if let Kind::Sheet(Sm::Flange { height, angle, own, .. }) = &mut d.kind {
|
||||
*height = "20 mm".into();
|
||||
*angle = "75 deg".into();
|
||||
// The second edge: its own angle; the fourth: both; the third: texts left blank.
|
||||
own.push((point(1), "100 deg".into(), String::new()));
|
||||
own.push((point(3), "90 deg".into(), "15 mm".into()));
|
||||
own.push((point(2), " ".into(), String::new()));
|
||||
}
|
||||
let c = apply_commands(&app, &d).unwrap();
|
||||
assert_eq!(
|
||||
c[0].1["edges"],
|
||||
json!([pt(point(0)), {"point": pt(point(1)), "angle": "100 deg"}, pt(point(2)), {"point": pt(point(3)), "angle": "90 deg", "height": "15 mm"}]),
|
||||
"no text or a blank one: a plain point"
|
||||
);
|
||||
run_all(&mut app, c);
|
||||
let bends = app.session.execute("sheet.flat_pattern", &json!({})).unwrap()["bends"].clone();
|
||||
let angles: Vec<f64> = bends.as_array().unwrap().iter().map(|b| b["angle_deg"].as_f64().unwrap().round()).collect();
|
||||
assert_eq!(angles, [75.0, 100.0, 75.0, 90.0], "{bends}");
|
||||
assert!(app.session.model.results.iter().all(|r| r.error.is_none()), "{:?}", app.session.model.results);
|
||||
// Edit Feature: the edges and their own values come back, and OK rebuilds the same part.
|
||||
let f = app.session.doc.features.last().unwrap().clone();
|
||||
let mut e = edit_dialog(&app, f.id, None).unwrap();
|
||||
assert_eq!(e.inputs[0].items.len(), 4);
|
||||
let Kind::Sheet(Sm::Flange { own, .. }) = &e.kind else { panic!("not a flange dialog") };
|
||||
assert_eq!(own.len(), 2, "{own:?}");
|
||||
assert!(own.iter().any(|o| o.0.dist(point(1)) < 1e-9 && o.1 == "100 deg" && o.2.is_empty()), "{own:?}");
|
||||
assert!(own.iter().any(|o| o.0.dist(point(3)) < 1e-9 && o.1 == "90 deg" && o.2 == "15 mm"), "{own:?}");
|
||||
let volume = |app: &mut SolveApp| app.session.execute("inspect.measure", &json!({})).unwrap()["total"]["volume_mm3"].as_f64().unwrap();
|
||||
let before = volume(&mut app);
|
||||
let c = apply_commands(&app, &e).unwrap();
|
||||
assert_eq!(c[0].0, "timeline.redefine");
|
||||
assert_eq!(c[0].1["params"]["edges"][1], json!({"point": pt(point(1)), "angle": "100 deg"}));
|
||||
assert_eq!(c[0].1["params"]["edges"][3]["height"], "15 mm");
|
||||
run_all(&mut app, c);
|
||||
let after = volume(&mut app);
|
||||
assert!((after - before).abs() < 1e-6 * before, "{after} vs {before}");
|
||||
// Taking edges out of the selection leaves each value with its edge.
|
||||
e.inputs[0].items.remove(2);
|
||||
e.inputs[0].items.remove(0);
|
||||
let c = apply_commands(&app, &e).unwrap();
|
||||
assert_eq!(
|
||||
c[0].1["params"]["edges"],
|
||||
json!([{"point": pt(point(1)), "angle": "100 deg"}, {"point": pt(point(3)), "angle": "90 deg", "height": "15 mm"}])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,6 +165,13 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there.",
|
||||
)
|
||||
.size(11.0)
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.separator();
|
||||
let q = app.keymap.filter.to_lowercase();
|
||||
egui::ScrollArea::both().max_height(330.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
|
||||
@@ -79,6 +79,7 @@ mod revolve_edit_tests;
|
||||
#[cfg(test)]
|
||||
mod revolve_manipulator_tests;
|
||||
pub mod scenario;
|
||||
pub mod select_other;
|
||||
pub mod selection;
|
||||
#[cfg(test)]
|
||||
mod shell_face_sketch_ui_tests;
|
||||
|
||||
@@ -414,7 +414,9 @@ pub fn zoom_sign(app: &SolveApp) -> f64 {
|
||||
}
|
||||
|
||||
/// Orbit by pixel deltas: around the view's centre, or, with "orbit around the cursor", around
|
||||
/// the model point that was under the cursor when the drag began (kept still on screen).
|
||||
/// the model point that was under the cursor when the drag began (kept still on screen). A drag
|
||||
/// that begins over empty space turns around the centre of the visible geometry instead, so the
|
||||
/// model never swings around a far-off point. The pivot is chosen once, when the drag begins.
|
||||
pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Option<Pos2>) {
|
||||
if !app.preferences.orbit_cursor {
|
||||
app.cam.orbit(dx, dy);
|
||||
@@ -438,7 +440,8 @@ pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Opti
|
||||
}
|
||||
}
|
||||
|
||||
/// The model point under a screen position, or the point at the view's depth.
|
||||
/// The model point under a screen position. Over empty space: the centre of the visible bodies
|
||||
/// and sketches (what Fit frames), or the view's centre when nothing is visible.
|
||||
fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 {
|
||||
use crate::viewport::Hit;
|
||||
for h in crate::viewport::pick(app, proj, at) {
|
||||
@@ -448,10 +451,8 @@ fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let (o, d) = proj.ray(at);
|
||||
let n = app.cam.back();
|
||||
let den = d.dot(n);
|
||||
if den.abs() > 1e-9 { o + d * ((app.cam.target - o).dot(n) / den) } else { app.cam.target }
|
||||
let b = solidcraft_engine::view::bounds(&app.session);
|
||||
if b.is_empty() { app.cam.target } else { b.center() }
|
||||
}
|
||||
|
||||
/// Units per millimetre and the unit's name.
|
||||
@@ -815,7 +816,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
});
|
||||
ui.end_row();
|
||||
let snap_help = if app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" };
|
||||
note(ui, &format!("{}{snap_help}; the wheel zooms", nav_preset(&app.preferences.nav).help));
|
||||
note(ui, &format!("{}; Ctrl+left drag on empty space orbits{snap_help}; the wheel zooms", nav_preset(&app.preferences.nav).help));
|
||||
ui.label("Context menu");
|
||||
ui.checkbox(&mut app.preferences.radial_context_menu, "Radial context menu");
|
||||
ui.end_row();
|
||||
@@ -1319,6 +1320,31 @@ mod tests {
|
||||
assert!(s.distance(at) < 2.0, "{s:?}");
|
||||
}
|
||||
|
||||
/// A drag that starts over empty space turns around the visible geometry's centre, not a
|
||||
/// point far off to the side; with nothing visible it turns around the view's centre.
|
||||
#[test]
|
||||
fn orbiting_from_empty_space_turns_around_the_visible_geometry() {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
app.run("solid.box", json!({"length": 40, "width": 40, "height": 40})).unwrap();
|
||||
app.preferences.orbit_cursor = true;
|
||||
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||||
app.fit_view();
|
||||
let corner = egui::pos2(10.0, 10.0);
|
||||
let proj = crate::viewport::projection(&app, rect);
|
||||
let hits = crate::viewport::pick(&app, &proj, corner);
|
||||
use crate::viewport::Hit;
|
||||
assert!(!hits.iter().any(|h| matches!(h, Hit::Face { .. } | Hit::Edge { .. } | Hit::Vertex { .. })), "the corner is empty space");
|
||||
let centre = solidcraft_engine::view::bounds(&app.session).center();
|
||||
orbit(&mut app, rect, 40.0, 25.0, Some(corner));
|
||||
assert_eq!(app.viewport.orbit_pivot, Some(centre));
|
||||
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
app.preferences.orbit_cursor = true;
|
||||
app.cam.target = solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0);
|
||||
orbit(&mut app, rect, 40.0, 25.0, Some(corner));
|
||||
assert_eq!(app.viewport.orbit_pivot, Some(solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0)));
|
||||
}
|
||||
|
||||
/// The Y up preference turns the view: the camera's up axis follows it, startup included.
|
||||
#[test]
|
||||
fn up_axis_preference_turns_the_view() {
|
||||
|
||||
@@ -709,6 +709,31 @@ fn surface_dialogs_make_their_commands() {
|
||||
assert!((v - 30.0 * 20.0 * 2.0).abs() < 1.0, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_dialog_fills_a_loop_of_edges_that_is_not_planar() {
|
||||
// A box with its top cut away by a sideways cylinder: its rim is a closed loop of four
|
||||
// curved edges, not in a plane. Picking them in the Patch dialog fills it with one face.
|
||||
let mut app = app_with(json!([
|
||||
{"command": "solid.box", "params": {"length": 28, "width": 28, "height": 20, "corner": [-14, -14, 0], "body_name": "Saddle"}},
|
||||
{"command": "solid.cylinder", "params": {"radius": 40, "height": 120, "base": [0, -60, 50], "axis": "Y", "operation": "cut", "targets": ["Saddle"]}}
|
||||
]));
|
||||
let z = |x: f64| 50.0 - (1600.0 - x * x).sqrt();
|
||||
let rim = [Vec3::new(14.0, 0.0, z(14.0)), Vec3::new(0.0, 14.0, z(0.0)), Vec3::new(-14.0, 0.0, z(-14.0)), Vec3::new(0.0, -14.0, z(0.0))];
|
||||
let mut d = start(&mut app, vec![], "surface.patch");
|
||||
d.inputs[0].items = rim.iter().enumerate().map(|(i, p)| Sel::Edge { body: "Saddle".into(), index: i, point: *p }).collect();
|
||||
let cmds = apply_commands(&app, &d).unwrap();
|
||||
assert_eq!(cmds.len(), 1);
|
||||
assert_eq!(cmds[0].0, "surface.patch");
|
||||
app.session.execute(&cmds[0].0, &cmds[0].1).unwrap();
|
||||
let st = app.session.model.state();
|
||||
let fill = st.bodies.iter().find(|b| b.name != "Saddle").unwrap();
|
||||
assert!(fill.body.is_surface() && fill.body.face_count() == 1);
|
||||
// One smooth face over the rim: its area is close to the cylinder's inside the rim.
|
||||
let area = fill.mesh().measure().area;
|
||||
let want = 28.0 * 40.0 * 2.0 * (14.0f64 / 40.0).asin();
|
||||
assert!((area - want).abs() < want * 1e-2, "{area} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_body_by_a_curved_face_sends_it_as_a_tool() {
|
||||
let mut app = app_with(json!([
|
||||
|
||||
@@ -276,7 +276,13 @@ fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> B
|
||||
fn as_new_body(cmds: &[(String, Value)]) -> Option<(Vec<(String, Value)>, String)> {
|
||||
let mut out = cmds.to_vec();
|
||||
let mut changed: Option<String> = None;
|
||||
for (_, p) in &mut out {
|
||||
for (command, p) in &mut out {
|
||||
let inner = p.get("command").and_then(Value::as_str).map(str::to_string);
|
||||
// A combine's operation is between bodies that already exist: it has no new-body form
|
||||
// (and no tool body of its own to show).
|
||||
if inner.as_deref().unwrap_or(command.as_str()) == "solid.combine" {
|
||||
continue;
|
||||
}
|
||||
let p = if p.get("command").is_some() { p.get_mut("params")? } else { p };
|
||||
if let Some(op) = p.get_mut("operation")
|
||||
&& let Some(o) = op.as_str().filter(|o| *o != "new").map(str::to_string)
|
||||
@@ -455,6 +461,22 @@ mod tests {
|
||||
assert_eq!(pv.error.as_deref(), Some("current invalid expression"), "current errors still prevent invalid Apply");
|
||||
}
|
||||
|
||||
/// Combine previews its result (cut, join or intersect between existing bodies) without an
|
||||
/// error, so its dialog's OK is enabled. Before, the preview also tried the command as a new
|
||||
/// body, which a combine has no form of, and always reported "operation must be join, cut or
|
||||
/// intersect".
|
||||
#[test]
|
||||
fn combine_previews_its_result_without_an_error() {
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "Cube"})).unwrap();
|
||||
s.execute("solid.cylinder", &json!({"base": [10, 10, -5], "diameter": 6, "height": 30, "body_name": "Pin"})).unwrap();
|
||||
for op in ["cut", "join", "intersect"] {
|
||||
let cmd = ("solid.combine".to_string(), json!({"target": "Cube", "tools": ["Pin"], "operation": op, "keep_tools": false}));
|
||||
let built = compute(&s, &[cmd], colors()).unwrap_or_else(|e| panic!("{op}: {e}"));
|
||||
assert!(built.error.is_none(), "{op}: {:?}", built.error);
|
||||
}
|
||||
}
|
||||
|
||||
fn count(bytes: &[u8], stride: usize, col: [u8; 4]) -> usize {
|
||||
bytes.chunks(stride).filter(|v| v.get(24..28) == Some(&col[..])).count()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! - `{"widget": "Revolute", "near"?: "Type"}`: click a dialog field, choice or button by its
|
||||
//! text (with `near`: the one in that label's row)
|
||||
//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`,
|
||||
//! `{"hold": AT, "frames"?}` (press and hold still: Select Other),
|
||||
//! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}`
|
||||
//! or `{"sketch": [x,y]}` (the sketch being edited)
|
||||
//! - `{"queued": "engine.execute", "params": {…}, "min_frames"?: n, "max_frames"?: n}`: a request sent the way an
|
||||
@@ -378,6 +379,16 @@ impl Harness {
|
||||
let [x, y] = found.ok_or_else(|| format!("no widget `{text}` on screen"))?;
|
||||
self.call("ui.move", json!({"x": x, "y": y}));
|
||||
self.call("ui.click", json!({"x": x, "y": y}))
|
||||
} else if let Some(at) = s.get("hold") {
|
||||
// Press and hold still (Select Other): `frames` with the button down (default 40,
|
||||
// about 0.67 s), then let go.
|
||||
let [x, y] = self.at(at)?;
|
||||
self.call("ui.move", json!({"x": x, "y": y}));
|
||||
let frames = s.get("frames").and_then(Value::as_u64).unwrap_or(40);
|
||||
self.call(
|
||||
"ui.drag",
|
||||
json!({"x0": x, "y0": y, "x1": x, "y1": y, "steps": 1, "hold": frames, "shift": flag("shift"), "ctrl": flag("ctrl")}),
|
||||
)
|
||||
} else if let Some(at) = s.get("move") {
|
||||
let [x, y] = self.at(at)?;
|
||||
self.call("ui.move", json!({"x": x, "y": y}))
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
//! Select Other: everything under the cursor, for picking what lies under something else (two
|
||||
//! lines drawn on top of each other, coincident points, an edge in front of a face, overlapping
|
||||
//! bodies).
|
||||
//!
|
||||
//! - Holding the left button still (or a finger, on touch screens) for [`HOLD_SECS`], or the
|
||||
//! viewport menu's "Select Other…", opens a list at the cursor of every pickable thing there,
|
||||
//! in the selection filter's and the open dialog's terms: sketch points and curves first, then
|
||||
//! vertices, edges, faces, bodies, profiles, planes and axes, each kind nearest first.
|
||||
//! - Hovering a row lights its entity up in the viewport; clicking it selects it (Shift/Ctrl add,
|
||||
//! as clicks do) or, with a dialog open, gives it to the dialog's active selection input.
|
||||
//! Esc or a click elsewhere closes the list.
|
||||
//! - Clicking again on the same spot with nothing changed selects the next thing in that list
|
||||
//! (click cycling). The second click of a double-click keeps its own actions.
|
||||
//!
|
||||
//! The control channel's `ui.selectOther` opens, describes and picks from the list.
|
||||
|
||||
use egui::{Align2, FontId, Pos2, Rect, Sense, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::sketch::CurveKind;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::viewport::{Hit, Proj};
|
||||
|
||||
/// How long the left button stays down, without moving, before the list opens.
|
||||
pub const HOLD_SECS: f64 = 0.5;
|
||||
/// How far (screen points) a repeat click may be from the last one and still cycle.
|
||||
const CYCLE_SLOP: f32 = 4.0;
|
||||
|
||||
/// One row of the list.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Row {
|
||||
pub hit: Hit,
|
||||
pub sel: Sel,
|
||||
pub label: String,
|
||||
pub icon: &'static str,
|
||||
}
|
||||
|
||||
/// The open list.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Popup {
|
||||
pub at: Pos2,
|
||||
pub rows: Vec<Row>,
|
||||
/// The row under the pointer (lit in the viewport).
|
||||
pub hovered: Option<usize>,
|
||||
/// The model and dialog it was made for: it closes when they change.
|
||||
revision: u64,
|
||||
dialog: bool,
|
||||
/// The browser's body highlight is ours to clear.
|
||||
lit_body: bool,
|
||||
}
|
||||
|
||||
/// The last plain click, for cycling.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Cycle {
|
||||
at: Pos2,
|
||||
chosen: Sel,
|
||||
selection: Vec<Sel>,
|
||||
key: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub popup: Option<Popup>,
|
||||
/// A left press that may become a hold: where and when.
|
||||
hold: Option<(Pos2, f64)>,
|
||||
/// The current press belongs to Select Other (it opened or closed the list): the viewport
|
||||
/// doesn't also click, drag or box-select with it.
|
||||
held: bool,
|
||||
cycle: Option<Cycle>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// What is under the cursor
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Every pickable thing under `pos`, ordered by kind (the sketch being edited first), then depth.
|
||||
pub fn rows(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Row> {
|
||||
let hits = crate::viewport::pick_all(app, proj, pos);
|
||||
let mut found = crate::viewport::candidates(app, &hits);
|
||||
let dialog = app.dialog.as_ref().is_some_and(|d| d.wants_picks());
|
||||
// A body behind each face (body picking already turns faces into bodies).
|
||||
if !dialog && !app.ui.pick_bodies {
|
||||
for h in &hits {
|
||||
if let Hit::Face { body, .. } = h {
|
||||
found.push((h.clone(), Sel::Body { name: body.clone() }));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out: Vec<(Hit, Sel)> = Vec::new();
|
||||
for (h, s) in found {
|
||||
if !out.iter().any(|(_, x)| crate::selection::same_item(x, &s)) {
|
||||
out.push((h, s));
|
||||
}
|
||||
}
|
||||
let sketching = app.session.active_sketch.is_some();
|
||||
out.sort_by_key(|(_, s)| rank(s, sketching));
|
||||
out.into_iter()
|
||||
.map(|(hit, sel)| {
|
||||
let label = label(app, &sel);
|
||||
Row { icon: icon(app, &sel), hit, sel, label }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sort key: in a sketch its own points and curves come first; then by kind.
|
||||
fn rank(s: &Sel, sketching: bool) -> (u8, u8) {
|
||||
let kind = match s {
|
||||
Sel::SketchPoint { .. } | Sel::ExternalSketchPoint { .. } => 0,
|
||||
Sel::SketchCurve { .. } | Sel::ExternalSketchCurve { .. } => 1,
|
||||
Sel::SketchConstraint { .. } => 2,
|
||||
Sel::Vertex { .. } => 3,
|
||||
Sel::Edge { .. } => 4,
|
||||
Sel::Face { .. } | Sel::Feature { .. } => 5,
|
||||
Sel::Body { .. } | Sel::Occurrence { .. } => 6,
|
||||
Sel::Profile { .. } => 7,
|
||||
Sel::Plane { .. } => 8,
|
||||
Sel::Axis { .. } => 9,
|
||||
};
|
||||
let own = matches!(s, Sel::SketchPoint { .. } | Sel::SketchCurve { .. });
|
||||
(if sketching && !own { 1 } else { 0 }, kind)
|
||||
}
|
||||
|
||||
/// The number at the end of an id (`l3` → 3).
|
||||
fn id_number(id: &str) -> Option<u64> {
|
||||
let digits: String = id.chars().rev().take_while(char::is_ascii_digit).collect::<Vec<_>>().into_iter().rev().collect();
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
fn curve_kind_name(k: &CurveKind) -> &'static str {
|
||||
match k {
|
||||
CurveKind::Line { .. } => "Line",
|
||||
CurveKind::Circle { .. } => "Circle",
|
||||
CurveKind::Arc { .. } => "Arc",
|
||||
CurveKind::Ellipse { .. } => "Ellipse",
|
||||
CurveKind::Spline { .. } => "Spline",
|
||||
CurveKind::Conic { .. } => "Conic",
|
||||
}
|
||||
}
|
||||
|
||||
fn sketch_name(app: &SolveApp, sketch: u64) -> String {
|
||||
app.session.doc.feature(sketch).map(|f| f.name.clone()).unwrap_or_else(|| format!("Sketch {sketch}"))
|
||||
}
|
||||
|
||||
/// "Line 3" (its kind and number), with what kind of geometry it is when that matters.
|
||||
fn curve_label(sk: &solidcraft_engine::sketch::Sketch, id: &str) -> String {
|
||||
if let Some((i, c)) = sk.curves.iter().enumerate().find(|(_, c)| c.id == id) {
|
||||
let kind = curve_kind_name(&c.kind);
|
||||
let n = id_number(id).unwrap_or_else(|| sk.curves.iter().take(i + 1).filter(|x| curve_kind_name(&x.kind) == kind).count() as u64);
|
||||
let mut s = format!("{kind} {n}");
|
||||
if c.link.is_some() {
|
||||
s.push_str(" (projected)");
|
||||
} else if c.construction {
|
||||
s.push_str(" (construction)");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
if sk.wires.iter().any(|w| w.id == id) {
|
||||
return format!("3D Curve {}", id_number(id).unwrap_or(0));
|
||||
}
|
||||
id.to_string()
|
||||
}
|
||||
|
||||
/// "Point (arc 2 centre)": the role it plays in the first curve that uses it.
|
||||
fn point_label(sk: &solidcraft_engine::sketch::Sketch, id: &str) -> String {
|
||||
let Some(pi) = sk.points.iter().position(|p| p.id == id) else { return id.to_string() };
|
||||
let role = sk.curves.iter().find_map(|c| {
|
||||
let name = format!("{} {}", curve_kind_name(&c.kind).to_lowercase(), id_number(&c.id).unwrap_or(0));
|
||||
let (start, end) = if c.reversed { ("end", "start") } else { ("start", "end") };
|
||||
let role = match &c.kind {
|
||||
CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. } | CurveKind::Conic { a, b, .. } if *a == pi => Some(start.to_string()),
|
||||
CurveKind::Line { b, .. } | CurveKind::Arc { b, .. } | CurveKind::Conic { b, .. } if *b == pi => Some(end.to_string()),
|
||||
CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } | CurveKind::Ellipse { c, .. } if *c == pi => Some("centre".to_string()),
|
||||
CurveKind::Ellipse { m, .. } if *m == pi => Some("major axis end".to_string()),
|
||||
CurveKind::Conic { apex, .. } if *apex == pi => Some("apex".to_string()),
|
||||
CurveKind::Spline { pts, .. } => pts.iter().position(|p| *p == pi).map(|k| format!("point {}", k + 1)),
|
||||
_ => None,
|
||||
}?;
|
||||
Some(format!("{name} {role}"))
|
||||
});
|
||||
let fixed = if sk.point_locked(pi) { ", fixed" } else { "" };
|
||||
match role {
|
||||
Some(r) => format!("Point ({r}{fixed})"),
|
||||
None if fixed.is_empty() => format!("Point {}", id_number(id).unwrap_or(pi as u64 + 1)),
|
||||
None => format!("Point {} (fixed)", id_number(id).unwrap_or(pi as u64 + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A row's name: "Line 3", "Point (arc 2 centre)", "Edge 4 · Body1", "XY Plane"…
|
||||
pub fn label(app: &SolveApp, sel: &Sel) -> String {
|
||||
let st = app.session.world_state();
|
||||
let in_sketch =
|
||||
|sketch: u64, f: &dyn Fn(&solidcraft_engine::sketch::Sketch) -> String| st.sketch(sketch).map(|ss| f(&ss.sketch)).unwrap_or_default();
|
||||
match sel {
|
||||
Sel::SketchPoint { id } => app.session.active_sketch.map(|s| in_sketch(s, &|sk| point_label(sk, id))).unwrap_or_else(|| id.clone()),
|
||||
Sel::SketchCurve { id } => app.session.active_sketch.map(|s| in_sketch(s, &|sk| curve_label(sk, id))).unwrap_or_else(|| id.clone()),
|
||||
Sel::ExternalSketchPoint { sketch, id } => format!("{} · {}", in_sketch(*sketch, &|sk| point_label(sk, id)), sketch_name(app, *sketch)),
|
||||
Sel::ExternalSketchCurve { sketch, id } => format!("{} · {}", in_sketch(*sketch, &|sk| curve_label(sk, id)), sketch_name(app, *sketch)),
|
||||
Sel::SketchConstraint { id } => format!("Constraint {id}"),
|
||||
Sel::Vertex { body, .. } => format!("Vertex · {body}"),
|
||||
Sel::Edge { body, index, .. } => format!("Edge {} · {body}", index + 1),
|
||||
Sel::Face { body, index, .. } => format!("Face {} · {body}", index + 1),
|
||||
Sel::Body { name } => format!("Body · {name}"),
|
||||
Sel::Occurrence { path } => format!("Occurrence {path:?}"),
|
||||
Sel::Feature { id } => app.session.doc.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| format!("Feature {id}")),
|
||||
Sel::Profile { sketch, index } => format!("Profile {} · {}", index + 1, sketch_name(app, *sketch)),
|
||||
Sel::Plane { name } if matches!(name.as_str(), "XY" | "XZ" | "YZ") => format!("{name} Plane"),
|
||||
Sel::Axis { name } if matches!(name.as_str(), "X" | "Y" | "Z") => format!("{name} Axis"),
|
||||
Sel::Plane { name } | Sel::Axis { name } => name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(app: &SolveApp, sel: &Sel) -> &'static str {
|
||||
let st = app.session.world_state();
|
||||
let curve = |sketch: Option<u64>, id: &str| {
|
||||
let kind = sketch.and_then(|s| st.sketch(s)).and_then(|ss| ss.sketch.curves.iter().find(|c| c.id == id).map(|c| c.kind.clone()));
|
||||
match kind {
|
||||
Some(CurveKind::Circle { .. }) => "circle",
|
||||
Some(CurveKind::Arc { .. }) => "arc_tangent",
|
||||
Some(CurveKind::Ellipse { .. }) => "ellipse",
|
||||
Some(CurveKind::Spline { .. }) => "spline",
|
||||
Some(CurveKind::Conic { .. }) => "conic",
|
||||
_ => "line",
|
||||
}
|
||||
};
|
||||
match sel {
|
||||
Sel::SketchPoint { .. } | Sel::ExternalSketchPoint { .. } | Sel::Vertex { .. } => "point",
|
||||
Sel::SketchCurve { id } => curve(app.session.active_sketch, id),
|
||||
Sel::ExternalSketchCurve { sketch, id } => curve(Some(*sketch), id),
|
||||
Sel::SketchConstraint { .. } => "dimension",
|
||||
Sel::Edge { .. } => "fillet",
|
||||
Sel::Face { .. } | Sel::Feature { .. } => "presspull",
|
||||
Sel::Body { .. } => "body",
|
||||
Sel::Occurrence { .. } => "component",
|
||||
Sel::Profile { .. } => "sketch",
|
||||
Sel::Plane { .. } => "plane",
|
||||
Sel::Axis { .. } => "axis",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Opening, picking, closing
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
fn projection_now(app: &SolveApp) -> Option<Proj> {
|
||||
app.viewport.rect.map(|r| crate::viewport::projection(app, r))
|
||||
}
|
||||
|
||||
/// Open the list at a screen point; false (with a status message) when nothing is there.
|
||||
pub fn open(app: &mut SolveApp, at: Pos2) -> bool {
|
||||
let Some(proj) = projection_now(app) else { return false };
|
||||
open_with(app, &proj, at)
|
||||
}
|
||||
|
||||
fn open_with(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool {
|
||||
let rows = rows(app, proj, at);
|
||||
if rows.is_empty() {
|
||||
app.status = Some(("Nothing to select here".into(), crate::now_ms(), false));
|
||||
return false;
|
||||
}
|
||||
close(app);
|
||||
app.viewport.select_other.popup =
|
||||
Some(Popup { at, rows, hovered: None, revision: app.session.revision, dialog: app.dialog.is_some(), lit_body: false });
|
||||
true
|
||||
}
|
||||
|
||||
pub fn close(app: &mut SolveApp) {
|
||||
if let Some(p) = app.viewport.select_other.popup.take()
|
||||
&& p.lit_body
|
||||
{
|
||||
app.viewport.hover_bodies.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_open(app: &SolveApp) -> bool {
|
||||
app.viewport.select_other.popup.is_some()
|
||||
}
|
||||
|
||||
/// Pick row `i` of the open list (`add`: Shift/Ctrl, add to the selection) and close it.
|
||||
pub fn choose(app: &mut SolveApp, i: usize, add: bool) -> Result<Row, String> {
|
||||
let Some(p) = app.viewport.select_other.popup.as_ref() else { return Err("Select Other is not open".into()) };
|
||||
let at = p.at;
|
||||
let row = p.rows.get(i).cloned().ok_or_else(|| format!("no row {i} (the list has {})", p.rows.len()))?;
|
||||
close(app);
|
||||
apply(app, &row, add);
|
||||
remember(app, at, if add { None } else { Some(row.sel.clone()) });
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Select a row's entity: into the dialog's active input, or as a click does.
|
||||
fn apply(app: &mut SolveApp, row: &Row, add: bool) {
|
||||
if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) {
|
||||
crate::viewport::dialog_pick(app, &row.hit, row.sel.clone());
|
||||
} else {
|
||||
crate::viewport::select(app, Some(row.sel.clone()), add);
|
||||
}
|
||||
}
|
||||
|
||||
/// The open list as JSON (for `ui.selectOther`).
|
||||
pub fn describe(app: &SolveApp) -> Value {
|
||||
match &app.viewport.select_other.popup {
|
||||
None => json!({ "open": false }),
|
||||
Some(p) => json!({
|
||||
"open": true,
|
||||
"at": [p.at.x, p.at.y],
|
||||
"hovered": p.hovered,
|
||||
"rows": p.rows.iter().map(|r| json!({ "label": r.label, "icon": r.icon, "sel": r.sel })).collect::<Vec<_>>(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The control channel's `ui.selectOther`: `{x, y}` opens the list there, `{pick: i, add?}`
|
||||
/// picks a row, `{hover: i}` lights one up, `{close: true}` closes it; it answers with the list.
|
||||
pub fn control(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
|
||||
if p.get("close").and_then(Value::as_bool) == Some(true) {
|
||||
close(app);
|
||||
return Ok(describe(app));
|
||||
}
|
||||
if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
||||
if !(x.is_finite() && y.is_finite()) {
|
||||
return Err("x and y must be numbers".into());
|
||||
}
|
||||
if app.viewport.rect.is_none() {
|
||||
return Err("no viewport".into());
|
||||
}
|
||||
open(app, pos2(x as f32, y as f32));
|
||||
return Ok(describe(app));
|
||||
}
|
||||
if let Some(i) = p.get("hover").and_then(Value::as_u64) {
|
||||
let Some(pop) = app.viewport.select_other.popup.as_mut() else { return Err("Select Other is not open".into()) };
|
||||
pop.hovered = Some(i as usize).filter(|i| *i < pop.rows.len());
|
||||
hover_override(app);
|
||||
return Ok(describe(app));
|
||||
}
|
||||
if let Some(i) = p.get("pick").and_then(Value::as_u64) {
|
||||
let row = choose(app, i as usize, p.get("add").and_then(Value::as_bool).unwrap_or(false))?;
|
||||
return Ok(json!({ "picked": row.label, "sel": row.sel, "selection": app.session.selection }));
|
||||
}
|
||||
Ok(describe(app))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Press and hold, and click cycling (called by the viewport)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Follow a left press held still in the viewport; open the list when it has been held long
|
||||
/// enough. True while the press belongs to Select Other (the viewport must not also click,
|
||||
/// drag or box-select with it).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn track_hold(
|
||||
app: &mut SolveApp,
|
||||
ctx: &egui::Context,
|
||||
proj: &Proj,
|
||||
inside: bool,
|
||||
pressed: bool,
|
||||
down: bool,
|
||||
hover: Option<Pos2>,
|
||||
navigating: bool,
|
||||
) -> bool {
|
||||
let consumed = app.viewport.select_other.held;
|
||||
let now = ctx.input(|i| i.time);
|
||||
let free = app.tool.is_none()
|
||||
&& app.viewport.context_menu.is_none()
|
||||
&& !is_open(app)
|
||||
&& app.dialog.as_ref().is_none_or(|d| d.wants_picks())
|
||||
&& app.viewport.point_drag.is_none();
|
||||
if pressed && inside && free && !navigating {
|
||||
app.viewport.select_other.hold = hover.map(|p| (p, now));
|
||||
}
|
||||
let mut opened = false;
|
||||
if let Some((p0, t0)) = app.viewport.select_other.hold {
|
||||
let slop = ctx.options(|o| o.input_options.max_click_dist);
|
||||
if !down || navigating || !free || hover.is_some_and(|p| p.distance(p0) > slop) {
|
||||
app.viewport.select_other.hold = None;
|
||||
} else if now - t0 >= HOLD_SECS {
|
||||
app.viewport.select_other.hold = None;
|
||||
opened = open_with(app, proj, p0);
|
||||
app.viewport.select_other.held = true;
|
||||
} else {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(30));
|
||||
}
|
||||
}
|
||||
if !down {
|
||||
app.viewport.select_other.held = false;
|
||||
}
|
||||
consumed || opened
|
||||
}
|
||||
|
||||
/// While the list is open, the hovered row is what the viewport lights up.
|
||||
pub fn hover_override(app: &mut SolveApp) {
|
||||
let Some(p) = app.viewport.select_other.popup.as_mut() else { return };
|
||||
let row = p.hovered.and_then(|i| p.rows.get(i));
|
||||
let body = match row.map(|r| &r.sel) {
|
||||
Some(Sel::Body { name }) => Some(name.clone()),
|
||||
_ => None,
|
||||
};
|
||||
app.viewport.hover = if body.is_some() { None } else { row.map(|r| r.hit.clone()) };
|
||||
match body {
|
||||
Some(b) => {
|
||||
app.viewport.hover_bodies = vec![b];
|
||||
p.lit_body = true;
|
||||
}
|
||||
None if p.lit_body => {
|
||||
app.viewport.hover_bodies.clear();
|
||||
p.lit_body = false;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a repeat click must find unchanged: the model, the view, the sketch and the filter.
|
||||
fn cycle_key(app: &SolveApp) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
app.session.revision.hash(&mut h);
|
||||
for v in [app.cam.yaw, app.cam.pitch, app.cam.roll, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] {
|
||||
v.to_bits().hash(&mut h);
|
||||
}
|
||||
app.session.active_sketch.hash(&mut h);
|
||||
app.ui.pick_off.hash(&mut h);
|
||||
app.ui.pick_bodies.hash(&mut h);
|
||||
app.dialog.is_some().hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn remember(app: &mut SolveApp, at: Pos2, chosen: Option<Sel>) {
|
||||
app.viewport.select_other.cycle = chosen.map(|chosen| Cycle { at, chosen, selection: app.session.selection.clone(), key: cycle_key(app) });
|
||||
}
|
||||
|
||||
/// A plain click selected `sel` at `at`: a repeat click there may cycle from it.
|
||||
pub fn note_click(app: &mut SolveApp, at: Pos2, sel: Option<Sel>) {
|
||||
remember(app, at, sel);
|
||||
}
|
||||
|
||||
/// A plain click at `at`: when it repeats the last one (same spot, nothing changed since), select
|
||||
/// the next thing under the cursor and return true.
|
||||
pub fn cycle_click(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool {
|
||||
let Some(c) = app.viewport.select_other.cycle.clone() else { return false };
|
||||
if c.at.distance(at) > CYCLE_SLOP || c.key != cycle_key(app) || c.selection != app.session.selection {
|
||||
return false;
|
||||
}
|
||||
let rows = rows(app, proj, at);
|
||||
if rows.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
let next = rows.iter().position(|r| crate::selection::same_item(&r.sel, &c.chosen)).map_or(0, |k| (k + 1) % rows.len());
|
||||
let Some(row) = rows.get(next).cloned() else { return false };
|
||||
apply(app, &row, false);
|
||||
app.status = Some((format!("{} ({} of {})", row.label, next + 1, rows.len()), crate::now_ms(), false));
|
||||
remember(app, c.at, Some(row.sel));
|
||||
true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Drawing
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Draw the open list; handle its hover, clicks, and a press elsewhere (which closes it).
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(p) = app.viewport.select_other.popup.clone() else { return };
|
||||
if p.revision != app.session.revision || p.dialog != app.dialog.is_some() || app.tool.is_some() {
|
||||
close(app);
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let mut hovered = None;
|
||||
let mut picked = None;
|
||||
let w = crate::context_menu::LIST_W - 10.0;
|
||||
let resp = crate::frame::area(egui::Id::new("sc_select_other"))
|
||||
.fixed_pos(p.at + vec2(12.0, 12.0))
|
||||
.constrain(true)
|
||||
.order(egui::Order::Foreground)
|
||||
.show(ctx, |ui| {
|
||||
crate::context_menu::menu_frame(ui, |ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover());
|
||||
ui.painter().text(
|
||||
pos2(r.left() + 8.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
crate::i18n::tr_label("Select Other").to_uppercase(),
|
||||
FontId::proportional(10.5),
|
||||
t.text_dim,
|
||||
);
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
for (i, row) in p.rows.iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click());
|
||||
crate::scenario::publish_handle(&format!("select_other:{i}"), r.center());
|
||||
if resp.hovered() {
|
||||
hovered = Some(i);
|
||||
ui.painter().rect_filled(r.shrink2(vec2(2.0, 1.0)), 4.0, t.hover);
|
||||
}
|
||||
crate::icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(16.0, 16.0)),
|
||||
row.icon,
|
||||
t.icon,
|
||||
t.icon_fill,
|
||||
t.accent,
|
||||
);
|
||||
ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, &row.label, FontId::proportional(13.0), t.text);
|
||||
if resp.clicked() {
|
||||
picked = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(pop) = app.viewport.select_other.popup.as_mut()
|
||||
&& pop.hovered != hovered
|
||||
{
|
||||
pop.hovered = hovered;
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let (pressed, pointer, mods) = ctx.input(|i| (i.pointer.any_pressed(), i.pointer.interact_pos(), i.modifiers));
|
||||
if let Some(i) = picked {
|
||||
let _ = choose(app, i, mods.shift || mods.ctrl || mods.command);
|
||||
} else if pressed && !pointer.is_some_and(|q| resp.response.rect.contains(q)) {
|
||||
// A press elsewhere only closes the list.
|
||||
close(app);
|
||||
app.viewport.select_other.held = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "select_other_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Select Other: press and hold, the viewport menu's entry, click cycling and dialog inputs.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
|
||||
use crate::scenario::Harness;
|
||||
|
||||
/// A sketch with two lines drawn on top of each other (l1, then l2), off the origin axes.
|
||||
fn two_lines() -> (Harness, Value) {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
for _ in 0..2 {
|
||||
h.app.run("sketch.line", json!({"points":[[0,10],[20,10]],"infer":false,"connect":false})).unwrap();
|
||||
}
|
||||
h.app.cam.distance = 60.0;
|
||||
h.settle();
|
||||
let at = h.call("ui.at", json!({"sketch":[10,10]}))["result"].clone();
|
||||
(h, at)
|
||||
}
|
||||
|
||||
fn labels(h: &Harness) -> Vec<String> {
|
||||
crate::select_other::describe(&h.app)["rows"].as_array().into_iter().flatten().filter_map(|r| r["label"].as_str().map(String::from)).collect()
|
||||
}
|
||||
|
||||
fn curves(h: &Harness) -> Vec<String> {
|
||||
h.app.session.selection.iter().filter_map(|s| if let Sel::SketchCurve { id } = s { Some(id.clone()) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holding_still_lists_both_coincident_lines_and_picks_the_one_underneath() {
|
||||
let (mut h, at) = two_lines();
|
||||
let before = h.app.session.doc.to_json();
|
||||
h.step(&json!({"hold": at})).unwrap();
|
||||
assert!(crate::select_other::is_open(&h.app), "a held press opens Select Other");
|
||||
let rows = labels(&h);
|
||||
assert!(rows.len() >= 2, "{rows:?}");
|
||||
assert_eq!(&rows[..2], ["Line 1", "Line 2"], "the sketch's own curves come first");
|
||||
// Letting go of the hold neither selected nor dragged anything.
|
||||
assert!(h.app.session.selection.is_empty(), "{:?}", h.app.session.selection);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
// Hovering a row lights it up in the viewport.
|
||||
h.step(&json!({"move": {"handle": "select_other:1"}})).unwrap();
|
||||
assert!(matches!(&h.app.viewport.hover, Some(crate::viewport::Hit::SketchCurve { id, .. }) if id == "l2"), "{:?}", h.app.viewport.hover);
|
||||
h.step(&json!({"click": {"handle": "select_other:1"}})).unwrap();
|
||||
assert!(!crate::select_other::is_open(&h.app));
|
||||
assert_eq!(curves(&h), ["l2"]);
|
||||
// Shift-picking from the list adds, like a Shift-click.
|
||||
h.step(&json!({"hold": at})).unwrap();
|
||||
h.step(&json!({"click": {"handle": "select_other:0"}, "shift": true})).unwrap();
|
||||
let mut got = curves(&h);
|
||||
got.sort();
|
||||
assert_eq!(got, ["l1", "l2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_and_a_click_elsewhere_close_the_list_without_selecting() {
|
||||
let (mut h, at) = two_lines();
|
||||
h.step(&json!({"hold": at})).unwrap();
|
||||
assert!(crate::select_other::is_open(&h.app));
|
||||
h.step(&json!({"key": "Escape"})).unwrap();
|
||||
assert!(!crate::select_other::is_open(&h.app));
|
||||
assert!(h.app.session.active_sketch.is_some(), "Esc only closed the list");
|
||||
h.step(&json!({"hold": at})).unwrap();
|
||||
h.step(&json!({"click": [at[0].as_f64().unwrap() + 200.0, at[1].as_f64().unwrap() + 150.0]})).unwrap();
|
||||
assert!(!crate::select_other::is_open(&h.app));
|
||||
assert!(h.app.session.selection.is_empty(), "{:?}", h.app.session.selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_again_on_the_same_spot_selects_the_next_thing_under_it() {
|
||||
let (mut h, at) = two_lines();
|
||||
h.step(&json!({"click": at})).unwrap();
|
||||
assert_eq!(curves(&h), ["l1"]);
|
||||
// A deliberate second click (not part of a double-click).
|
||||
h.frames(30);
|
||||
h.step(&json!({"click": at})).unwrap();
|
||||
assert_eq!(curves(&h), ["l2"]);
|
||||
h.frames(30);
|
||||
h.step(&json!({"click": at})).unwrap();
|
||||
assert_eq!(curves(&h), ["l1"], "it wraps around");
|
||||
// A click somewhere else starts over.
|
||||
let x = at[0].as_f64().unwrap();
|
||||
let y = at[1].as_f64().unwrap();
|
||||
h.frames(30);
|
||||
h.step(&json!({"click": [x + 40.0, y]})).unwrap();
|
||||
h.frames(30);
|
||||
h.step(&json!({"click": at})).unwrap();
|
||||
assert_eq!(curves(&h), ["l1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_click_does_not_cycle() {
|
||||
let (mut h, at) = two_lines();
|
||||
h.step(&json!({"click": at, "double": true})).unwrap();
|
||||
// The double-click's chain starts from the line under the cursor.
|
||||
assert!(curves(&h).contains(&"l1".to_string()), "{:?}", h.app.session.selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_viewport_menu_offers_select_other() {
|
||||
let (mut h, at) = two_lines();
|
||||
h.step(&json!({"click": at, "button": "right"})).unwrap();
|
||||
let menu = h.call("ui.menu", json!({}))["result"].clone();
|
||||
let items: Vec<&str> = menu["items"].as_array().into_iter().flatten().filter_map(|i| i["id"].as_str()).collect();
|
||||
assert!(items.contains(&"ui.selectOther"), "{items:?}");
|
||||
let r = h.call("ui.menuPick", json!({"item": "ui.selectOther"}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(labels(&h).starts_with(&["Line 1".to_string(), "Line 2".to_string()]), "{:?}", labels(&h));
|
||||
// The control channel picks rows too.
|
||||
let r = h.call("ui.selectOther", json!({"pick": 1}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(curves(&h), ["l2"]);
|
||||
// Over empty space there is nothing to offer.
|
||||
h.step(&json!({"click": [40, 400], "button": "right"})).unwrap();
|
||||
let menu = h.call("ui.menu", json!({}))["result"].clone();
|
||||
assert!(!menu["items"].as_array().into_iter().flatten().any(|i| i["id"] == "ui.selectOther"), "{menu}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_bodies_list_both_and_the_hidden_one_can_be_picked() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Inner"})).unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[5,5,5],"body_name":"Outer"})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.step(&json!({"hold": {"world": [7, 7, 15]}})).unwrap();
|
||||
let rows = labels(&h);
|
||||
assert!(rows.iter().any(|r| r == "Body · Outer") && rows.iter().any(|r| r == "Body · Inner"), "{rows:?}");
|
||||
let faces = rows.iter().filter(|r| r.starts_with("Face ")).count();
|
||||
assert_eq!(faces, 2, "the nearest face of each body: {rows:?}");
|
||||
let i = rows.iter().position(|r| r == "Body · Inner").unwrap();
|
||||
h.call("ui.selectOther", json!({"pick": i}));
|
||||
assert_eq!(h.app.session.selection, [Sel::Body { name: "Inner".into() }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dialog_selection_input_takes_its_pick_from_the_list() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})).unwrap();
|
||||
h.call("ui.view", json!({"view":"home"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.step(&json!({"start": "solid.fillet"})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"hold": {"world": [5, 0, 10]}})).unwrap();
|
||||
let rows = crate::select_other::describe(&h.app)["rows"].clone();
|
||||
let sels: Vec<&Value> = rows.as_array().into_iter().flatten().map(|r| &r["sel"]).collect();
|
||||
assert!(!sels.is_empty(), "{rows}");
|
||||
// The edge in front, then the face behind it; no bodies, which the fillet doesn't take.
|
||||
assert_eq!(sels[0]["type"], "edge", "{rows}");
|
||||
assert!(sels.iter().all(|s| s["type"] == "edge" || s["type"] == "face"), "only what the fillet's input accepts: {rows}");
|
||||
h.call("ui.selectOther", json!({"pick": 0}));
|
||||
let d = h.app.dialog.as_ref().expect("the fillet dialog stays open");
|
||||
assert!(d.inputs.iter().any(|i| i.items.iter().any(|x| matches!(x, Sel::Edge { .. }))), "{:?}", d.inputs);
|
||||
assert!(h.app.session.selection.is_empty(), "the pick went to the dialog, not the selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_name_points_by_their_curves() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.circle.center", json!({"center":[10,10],"radius":3})).unwrap();
|
||||
h.app.cam.distance = 60.0;
|
||||
h.settle();
|
||||
let at = h.call("ui.at", json!({"sketch":[10,10]}))["result"].clone();
|
||||
h.call("ui.selectOther", json!({"x": at[0], "y": at[1]}));
|
||||
let rows = labels(&h);
|
||||
assert!(rows.iter().any(|r| r.starts_with("Point (") && r.contains("centre")), "{rows:?}");
|
||||
}
|
||||
@@ -167,6 +167,10 @@ pub fn panel(app: &mut SolveApp, ctx: &egui::Context, at: Pos2) {
|
||||
ui.label("Legacy face attachment. Rebinding to a certified face creates an exact attachment.");
|
||||
*plane
|
||||
}
|
||||
FaceAttachmentStatus::Sheet { plane } => {
|
||||
ui.label("Attached to a flat face of a sheet metal body; it follows that panel when the sheet changes.");
|
||||
*plane
|
||||
}
|
||||
FaceAttachmentStatus::Fixed { plane } => {
|
||||
ui.label("Sketch plane has no body-face attachment.");
|
||||
*plane
|
||||
|
||||
@@ -115,10 +115,16 @@ fn lost_attachment_ghost_rebind_preview_cancel_apply_and_undo_preserve_sketch()
|
||||
let repaired = h.app.session.doc.to_json();
|
||||
let path = h.dir.join("rebound-face.solidcraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
// Saving the untitled design names it after its file; everything else is exact.
|
||||
let named = |doc: &str| {
|
||||
let mut doc = solidcraft_engine::doc::Document::from_json(doc).unwrap();
|
||||
doc.name = "rebound-face".into();
|
||||
doc.to_json()
|
||||
};
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.doc.to_json(), named(&before));
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), repaired);
|
||||
assert_eq!(h.app.session.doc.to_json(), named(&repaired));
|
||||
assert_eq!(raw_sketch(&h, sketch), raw);
|
||||
assert!(h.app.session.model.result(sketch).unwrap().error.is_none());
|
||||
}
|
||||
@@ -324,3 +330,43 @@ fn nested_anchor_saved_fixed_conversion_preserves_rotated_second_author_plane()
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
}
|
||||
|
||||
/// Issue #489: extrude, sketch a rectangle over the whole top face (its corners snap to the
|
||||
/// face's projected boundary), extrude it up as a Join, then pick the new top face for a sketch.
|
||||
#[test]
|
||||
fn picked_top_face_of_a_stacked_join_extrude_takes_a_sketch() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,40]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.run("solid.extrude", json!({"distance":10,"name":"First"})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
let pick_face = |h: &mut Harness, z: f64| {
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[20,20,z]}})).unwrap();
|
||||
h.settle();
|
||||
let error = h.app.dialog.as_ref().and_then(|d| d.error.clone());
|
||||
let sketch = h.app.session.active_sketch.unwrap_or_else(|| panic!("top face at z={z} must take a sketch: {error:?}"));
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(_), .. }, .. } = &h.app.session.doc.feature(sketch).unwrap().kind else {
|
||||
panic!("the top face at z={z} must give a certified attachment");
|
||||
};
|
||||
sketch
|
||||
};
|
||||
pick_face(&mut h, 10.);
|
||||
h.step(&json!({"start":"sketch.rectangle.two_point"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[0,0,10]}})).unwrap();
|
||||
h.step(&json!({"click":{"world":[40,40,10]}})).unwrap();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
h.app.finish_sketch();
|
||||
h.app.run("solid.extrude", json!({"distance":10,"operation":"join","name":"Second"})).unwrap();
|
||||
assert_eq!(h.app.session.model.state().bodies.len(), 1);
|
||||
h.settle();
|
||||
let top = pick_face(&mut h, 20.);
|
||||
let FaceAttachmentStatus::Healthy { plane, .. } = h.app.session.placed_document().sketch_face_attachment(top, &h.app.session.model) else {
|
||||
panic!("attachment must be healthy");
|
||||
};
|
||||
assert!((plane.origin.z - 20.).abs() < 1e-9);
|
||||
}
|
||||
|
||||
@@ -191,8 +191,8 @@ impl Tokens {
|
||||
subheading_bg: Color32::from_rgb(234, 237, 242),
|
||||
profile_fill: Color32::from_rgba_unmultiplied_const(255, 196, 120, 70),
|
||||
glyph_bg: Color32::WHITE,
|
||||
glyph_edge: Color32::from_rgb(90, 150, 90),
|
||||
glyph_text: Color32::from_rgb(40, 110, 40),
|
||||
glyph_edge: Color32::from_rgb(190, 110, 100),
|
||||
glyph_text: Color32::from_rgb(150, 40, 35),
|
||||
overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235),
|
||||
rubber_band: Color32::from_rgb(30, 120, 230),
|
||||
app_bar_text: Color32::from_rgb(230, 234, 242),
|
||||
@@ -247,8 +247,8 @@ impl Tokens {
|
||||
t.heading_bg = Color32::from_rgb(38, 44, 55);
|
||||
t.subheading_bg = Color32::from_rgb(48, 55, 68);
|
||||
t.glyph_bg = Color32::from_rgb(44, 50, 60);
|
||||
t.glyph_edge = Color32::from_rgb(96, 170, 110);
|
||||
t.glyph_text = Color32::from_rgb(150, 220, 160);
|
||||
t.glyph_edge = Color32::from_rgb(200, 130, 120);
|
||||
t.glyph_text = Color32::from_rgb(240, 165, 155);
|
||||
t.overlay = Color32::from_rgba_unmultiplied_const(34, 38, 46, 240);
|
||||
t.rubber_band = Color32::from_rgb(92, 160, 255);
|
||||
t.timeline_item = Color32::from_rgb(52, 58, 70);
|
||||
|
||||
@@ -8,6 +8,19 @@ use crate::{SolveApp, icons};
|
||||
|
||||
pub const TABS: &[&str] = &["SOLID", "SURFACE", "MESH", "SHEET METAL", "PLASTIC", "UTILITIES"];
|
||||
|
||||
/// The accent stroke colour for a panel's icons: constraint commands use the same red as the
|
||||
/// constraint glyphs they draw in the sketch (`glyph_text`), sketch tools otherwise keep
|
||||
/// `sketch_accent`, and every other tab keeps the plain `accent`.
|
||||
fn panel_accent(t: Tokens, tab: &str, panel: &str) -> Color32 {
|
||||
if panel == "CONSTRAINTS" {
|
||||
t.glyph_text
|
||||
} else if tab == "SKETCH" {
|
||||
t.sketch_accent
|
||||
} else {
|
||||
t.accent
|
||||
}
|
||||
}
|
||||
|
||||
/// Panels shown on each tab (in order). Commands come from the registry by (tab, panel).
|
||||
pub fn panels(tab: &str) -> &'static [&'static str] {
|
||||
if let Some(p) = crate::workspace::panels(tab) {
|
||||
@@ -30,7 +43,6 @@ pub fn promoted(tab: &str, panel: &str) -> usize {
|
||||
match (tab, panel) {
|
||||
("SOLID", "CREATE") => 4,
|
||||
("SOLID", "MODIFY") => 4,
|
||||
("SKETCH", "CREATE") => 6,
|
||||
("SKETCH", "CONSTRAINTS") => 6,
|
||||
(_, "FINISH SKETCH") => 1,
|
||||
_ => 2,
|
||||
@@ -573,12 +585,6 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let free_x = bx + drawn as f32 * 40.0;
|
||||
let free = Rect::from_min_size(pos2(free_x, top + 2.0), vec2((px + width - 4.0 - free_x).max(8.0), 38.0));
|
||||
let fresp = ui.interact(free, ui.id().with(("panel_free", panel)), if collapsed { Sense::click() } else { Sense::hover() });
|
||||
if fresp.dnd_hover_payload::<ToolbarDrag>().is_some() {
|
||||
painter.line_segment([pos2(free.left(), free.top() + 2.0), pos2(free.left(), free.bottom() - 2.0)], Stroke::new(2.0, t.accent));
|
||||
}
|
||||
if let Some(d) = fresp.dnd_release_payload::<ToolbarDrag>() {
|
||||
drop = Some((d.id, *panel, None));
|
||||
}
|
||||
let mut opened = false;
|
||||
if collapsed {
|
||||
// One button: the panel's first command's icon over a caret; it opens the
|
||||
@@ -589,7 +595,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
painter.rect_filled(br, 4.0, t.hover);
|
||||
}
|
||||
if let Some(c) = shown.first().or(cmds.first()) {
|
||||
let acc = if tab == "SKETCH" { t.sketch_accent } else { t.accent };
|
||||
let acc = panel_accent(t, &tab, panel);
|
||||
icons::paint(&painter, Rect::from_center_size(br.center() - vec2(0.0, 3.0), vec2(26.0, 26.0)), c.icon, t.icon, t.icon_fill, acc);
|
||||
}
|
||||
caret(&painter, pos2(br.center().x, br.bottom() - 3.0), t.text_dim);
|
||||
@@ -628,11 +634,8 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
} else if resp.hovered() && info.enabled {
|
||||
painter.rect_filled(br, 4.0, t.hover);
|
||||
}
|
||||
let (ink, fill, acc) = if info.enabled {
|
||||
(t.icon, t.icon_fill, if tab == "SKETCH" { t.sketch_accent } else { t.accent })
|
||||
} else {
|
||||
(t.border, t.panel_header, t.border)
|
||||
};
|
||||
let (ink, fill, acc) =
|
||||
if info.enabled { (t.icon, t.icon_fill, panel_accent(t, &tab, panel)) } else { (t.border, t.panel_header, t.border) };
|
||||
let icon = match c.id {
|
||||
"sketch.finish" => "finish",
|
||||
"config.table" => "configuration_table",
|
||||
@@ -692,6 +695,20 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if lresp.clicked() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_panel_open"), popup_id));
|
||||
}
|
||||
// Labels, gaps and margins append; actual buttons keep their insert-before target.
|
||||
// Respect the current layer so a dropdown covering the panel is not a drop target.
|
||||
let panel_rect = Rect::from_min_max(pos2(px, top + 2.0), pos2(px + width, r.bottom()));
|
||||
let over_button = ui
|
||||
.input(|input| input.pointer.hover_pos())
|
||||
.is_some_and(|p| (0..drawn).any(|i| Rect::from_min_size(pos2(bx + i as f32 * 40.0, top + 2.0), vec2(38.0, 38.0)).contains(p)));
|
||||
if !over_button && ui.rect_contains_pointer(panel_rect) && egui::DragAndDrop::payload::<ToolbarDrag>(ui.ctx()).is_some() {
|
||||
painter.line_segment([pos2(px + width - 4.0, top + 4.0), pos2(px + width - 4.0, top + 38.0)], Stroke::new(2.0, t.accent));
|
||||
if ui.input(|input| input.pointer.any_released())
|
||||
&& let Some(d) = egui::DragAndDrop::take_payload::<ToolbarDrag>(ui.ctx())
|
||||
{
|
||||
drop = Some((d.id, *panel, None));
|
||||
}
|
||||
}
|
||||
let open = ui.ctx().data(|d| d.get_temp::<egui::Id>(egui::Id::new("sc_panel_open"))) == Some(popup_id);
|
||||
if open {
|
||||
let cmds = if collapsed { menu_commands(&cmds, &shown) } else { cmds.clone() };
|
||||
@@ -869,8 +886,14 @@ fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solidcraft_engine
|
||||
p.rect_filled(rect, 3.0, t.hover);
|
||||
crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center());
|
||||
}
|
||||
let (ink, fill, acc, text) =
|
||||
if info.enabled { (t.icon, t.icon_fill, t.accent, t.text) } else { (t.border, t.panel_header, t.border, t.text_dim) };
|
||||
// Every other row keeps the plain accent it always has; only a constraint command's row
|
||||
// switches to the constraint red, to match its button on the ribbon.
|
||||
let (ink, fill, acc, text) = if info.enabled {
|
||||
let acc = if c.panel == "CONSTRAINTS" { t.glyph_text } else { t.accent };
|
||||
(t.icon, t.icon_fill, acc, t.text)
|
||||
} else {
|
||||
(t.border, t.panel_header, t.border, t.text_dim)
|
||||
};
|
||||
let ir = Rect::from_center_size(pos2(rect.left() + 6.0 + 9.0, rect.center().y), vec2(18.0, 18.0));
|
||||
icons::paint(p, ir, c.icon, ink, fill, acc);
|
||||
p.text(pos2(ir.right() + 8.0, rect.center().y), Align2::LEFT_CENTER, &label, FontId::proportional(13.0), text);
|
||||
@@ -1022,7 +1045,9 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
match k {
|
||||
Key::Escape => {
|
||||
app.esc_handled = true;
|
||||
if app.tool.is_some() {
|
||||
if crate::select_other::is_open(app) {
|
||||
crate::select_other::close(app);
|
||||
} else if app.tool.is_some() {
|
||||
crate::tools::finish(app);
|
||||
} else if app.dialog.is_some() {
|
||||
crate::dialogs::cancel(app);
|
||||
@@ -1320,3 +1345,44 @@ mod dropdown_tests {
|
||||
assert!(menu.contains(*at), "the focused last row is out of view: {at:?} in {menu:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod panel_drop_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn panel_label_gap_and_margins_append_while_buttons_insert_before() {
|
||||
for target in ["label", "gap", "left margin", "right margin", "button"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.step(&json!({"click":{"handle":"panel:SOLID:CREATE"}})).unwrap();
|
||||
let label = crate::scenario::handle_at("panel:SOLID:CREATE").unwrap();
|
||||
let destination = match target {
|
||||
"gap" => label - vec2(0.0, 10.0),
|
||||
"left margin" => pos2(label.x - panel_width(4) / 2.0 + 1.0, label.y - 30.0),
|
||||
"right margin" => pos2(label.x + panel_width(4) / 2.0 - 1.0, label.y - 30.0),
|
||||
"button" => crate::scenario::handle_at("button:solid.extrude").unwrap(),
|
||||
_ => label,
|
||||
};
|
||||
h.step(&json!({"drag":[{"handle":"dropdown:solid.cylinder"}, [destination.x,destination.y]],"steps":12})).unwrap();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
let order = if target == "button" {
|
||||
json!(["button:sketch.create", "button:solid.cylinder", "button:solid.extrude", "button:solid.revolve", "button:solid.sweep"])
|
||||
} else {
|
||||
json!(["button:sketch.create", "button:solid.extrude", "button:solid.revolve", "button:solid.sweep", "button:solid.cylinder"])
|
||||
};
|
||||
h.step(&json!({"expect":{"handle_order":order,"tool":null,"dialog":null,"errors":0}})).unwrap();
|
||||
assert!(h.app.session.doc.features.is_empty(), "{target}: pinning must not run the command");
|
||||
h.step(&json!({"restart":"close"})).unwrap();
|
||||
h.step(&json!({"expect":{"handle_order":order}})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_label_drop_moves_a_button_between_panels() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.step(&json!({"drag":[{"handle":"button:solid.extrude"},{"handle":"panel:SOLID:MODIFY"}],"steps":12})).unwrap();
|
||||
h.step(&json!({"expect":{"handle_order":["button:solid.fillet","button:solid.chamfer","button:solid.scale","button:solid.extrude"],"tool":null,"dialog":null,"errors":0}})).unwrap();
|
||||
h.step(&json!({"restart":"close"})).unwrap();
|
||||
h.step(&json!({"expect":{"handle_order":["button:solid.scale","button:solid.extrude"]}})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+255
-84
@@ -82,6 +82,8 @@ pub struct ViewportState {
|
||||
pub build_ms: f64,
|
||||
/// The right-click menu, open at this screen position.
|
||||
pub context_menu: Option<Pos2>,
|
||||
/// Select Other: its list, a press being held, and click cycling.
|
||||
pub select_other: crate::select_other::State,
|
||||
/// A sketch point or curve being dragged (its id, the undo depth when the drag began: the
|
||||
/// whole drag is one undo step, and for a curve where it was last under the cursor).
|
||||
pub point_drag: Option<(String, usize, Option<Vec2>)>,
|
||||
@@ -720,7 +722,68 @@ pub fn pick_3d(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||||
pick_with_sketch_points(app, proj, pos, true)
|
||||
}
|
||||
|
||||
/// Everything under `pos` (Select Other): like [`pick`], but every sketch point, sketch curve,
|
||||
/// vertex, edge, axis and profile near the cursor, and the nearest face of every body along the
|
||||
/// ray, instead of the nearest of each kind. Each kind is nearest first.
|
||||
pub fn pick_all(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||||
pick_hits(app, proj, pos, false, true)
|
||||
}
|
||||
|
||||
fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool) -> Vec<Hit> {
|
||||
pick_hits(app, proj, pos, other_sketch_points, false)
|
||||
}
|
||||
|
||||
/// The same thing picked twice (one row per target in [`pick_all`]).
|
||||
pub fn same_target(a: &Hit, b: &Hit) -> bool {
|
||||
match (a, b) {
|
||||
(Hit::SketchPoint { sketch: s, id: i, .. }, Hit::SketchPoint { sketch: t, id: j, .. })
|
||||
| (Hit::SketchCurve { sketch: s, id: i, .. }, Hit::SketchCurve { sketch: t, id: j, .. }) => s == t && i == j,
|
||||
(Hit::Vertex { body: a, point: p }, Hit::Vertex { body: b, point: q }) => a == b && p.dist(*q) < 1e-9,
|
||||
(Hit::Edge { body: a, index: i, .. }, Hit::Edge { body: b, index: j, .. })
|
||||
| (Hit::Face { body: a, index: i, .. }, Hit::Face { body: b, index: j, .. }) => a == b && i == j,
|
||||
(Hit::Profile { sketch: s, index: i }, Hit::Profile { sketch: t, index: j }) => s == t && i == j,
|
||||
(Hit::Plane { name: a, .. }, Hit::Plane { name: b, .. }) | (Hit::Axis { name: a }, Hit::Axis { name: b }) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The candidates of one kind: the nearest only, or with `all` every target (nearest first).
|
||||
struct Near<T> {
|
||||
all: bool,
|
||||
v: Vec<(T, Hit)>,
|
||||
}
|
||||
|
||||
impl<T: PartialOrd + Copy> Near<T> {
|
||||
fn new(all: bool) -> Self {
|
||||
Near { all, v: Vec::new() }
|
||||
}
|
||||
/// Whether a candidate at `d` would be kept.
|
||||
fn wants(&self, d: T) -> bool {
|
||||
self.all || self.v.first().is_none_or(|(b, _)| d < *b)
|
||||
}
|
||||
fn offer(&mut self, d: T, h: Hit) {
|
||||
if !self.all {
|
||||
if self.wants(d) {
|
||||
self.v = vec![(d, h)];
|
||||
}
|
||||
return;
|
||||
}
|
||||
match self.v.iter_mut().find(|(_, x)| same_target(x, &h)) {
|
||||
Some(e) if d < e.0 => *e = (d, h),
|
||||
Some(_) => {}
|
||||
None => self.v.push((d, h)),
|
||||
}
|
||||
}
|
||||
fn sorted(mut self) -> Vec<(T, Hit)> {
|
||||
self.v.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
self.v
|
||||
}
|
||||
fn hits(self) -> impl Iterator<Item = Hit> {
|
||||
self.sorted().into_iter().map(|x| x.1)
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, all: bool) -> Vec<Hit> {
|
||||
let s = &app.session;
|
||||
let st = s.world_state();
|
||||
let bounds = body_bounds(app, &st);
|
||||
@@ -733,23 +796,21 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
|
||||
let external_points =
|
||||
wants_points || other_sketch_points || (app.dialog.is_none() && app.tool.as_ref().is_none_or(|tool| tool.cmd == "sketch.project"));
|
||||
let point_sketches: Vec<u64> = if external_points { visible_sketches(app) } else { s.active_sketch.into_iter().collect() };
|
||||
let mut best: Option<(f32, Hit)> = None;
|
||||
let mut best = Near::new(all);
|
||||
for ss in point_sketches.iter().filter_map(|sid| st.sketch(*sid)) {
|
||||
let sid = ss.feature;
|
||||
for p in &ss.sketch.points {
|
||||
if let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) {
|
||||
let dd = sp.distance(pos) - if s.active_sketch == Some(sid) { 2.0 } else { 0.0 };
|
||||
if dd < 8.0 && best.as_ref().is_none_or(|(b, _)| dd < *b) {
|
||||
best = Some((dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos }));
|
||||
if dd < 8.0 && best.wants(dd) {
|
||||
best.offer(dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, h)) = best {
|
||||
hits.push(h);
|
||||
}
|
||||
hits.extend(best.hits());
|
||||
// Sketch curves (the active sketch wins ties).
|
||||
let mut bestc: Option<(f32, Hit)> = None;
|
||||
let mut bestc = Near::new(all);
|
||||
for sid in visible_sketches(app) {
|
||||
let Some(ss) = st.sketch(sid) else { continue };
|
||||
let bonus = if s.active_sketch == Some(sid) { 2.0 } else { 0.0 };
|
||||
@@ -762,8 +823,8 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
|
||||
let pts: Vec<Pos2> = part.iter().filter_map(|q| proj.to_screen(*q)).collect();
|
||||
for w in pts.windows(2) {
|
||||
let dd = seg_dist(pos, w[0], w[1]) - bonus;
|
||||
if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) {
|
||||
bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: c.id.clone(), straight }));
|
||||
if dd < 6.0 && bestc.wants(dd) {
|
||||
bestc.offer(dd, Hit::SketchCurve { sketch: sid, id: c.id.clone(), straight });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -773,17 +834,16 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
|
||||
let pts: Vec<Pos2> = w.pts.iter().filter_map(|q| proj.to_screen(*q)).collect();
|
||||
for seg in pts.windows(2) {
|
||||
let dd = seg_dist(pos, seg[0], seg[1]) - bonus;
|
||||
if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) {
|
||||
bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: w.id.clone(), straight: false }));
|
||||
if dd < 6.0 && bestc.wants(dd) {
|
||||
bestc.offer(dd, Hit::SketchCurve { sketch: sid, id: w.id.clone(), straight: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, h)) = bestc {
|
||||
hits.push(h);
|
||||
}
|
||||
// Nearest face along the ray (hides what is behind it).
|
||||
hits.extend(bestc.hits());
|
||||
// Nearest face along the ray (hides what is behind it); with `all`, every body's nearest.
|
||||
let mut bestf: Option<(f64, Hit)> = None;
|
||||
let mut faces = Near::new(all);
|
||||
for b in &st.bodies {
|
||||
if !app.session.body_visible(&b.name) {
|
||||
continue;
|
||||
@@ -793,23 +853,27 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
|
||||
let pad = bb.diagonal() * 1e-6 + 1e-9;
|
||||
match ray_box(o, d, &bb, pad) {
|
||||
None => continue,
|
||||
Some(t0) if bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => continue,
|
||||
Some(t0) if !all && bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => continue,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let m = b.mesh();
|
||||
if let Some((t, ti)) = m.raycast(o, d)
|
||||
&& bestf.as_ref().is_none_or(|(bt, _)| t < *bt)
|
||||
{
|
||||
if let Some((t, ti)) = m.raycast(o, d) {
|
||||
let fi = m.tri_face.get(ti).copied().unwrap_or(0) as usize;
|
||||
bestf = Some((t, Hit::Face { body: b.name.clone(), index: fi, point: o + d * t }));
|
||||
let hit = Hit::Face { body: b.name.clone(), index: fi, point: o + d * t };
|
||||
if bestf.as_ref().is_none_or(|(bt, _)| t < *bt) {
|
||||
bestf = Some((t, hit.clone()));
|
||||
}
|
||||
if all {
|
||||
faces.offer(t, hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
let slack = scene_radius(app) * 0.02;
|
||||
let visible = |p: Vec3| bestf.as_ref().is_none_or(|(t, _)| (p - o).dot(d) <= *t + slack);
|
||||
// Vertices (edge end points) and edges.
|
||||
let mut bestv: Option<(f32, Hit)> = None;
|
||||
let mut beste: Option<(f32, Hit)> = None;
|
||||
let mut bestv = Near::new(all);
|
||||
let mut beste = Near::new(all);
|
||||
for b in &st.bodies {
|
||||
if !app.session.body_visible(&b.name) {
|
||||
continue;
|
||||
@@ -828,35 +892,35 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
|
||||
for v in [e.first(), e.last()].into_iter().flatten() {
|
||||
if let Some(sp) = proj.to_screen(*v) {
|
||||
let dd = sp.distance(pos);
|
||||
if dd < 7.0 && visible(*v) && bestv.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||||
bestv = Some((dd, Hit::Vertex { body: b.name.clone(), point: *v }));
|
||||
if dd < 7.0 && visible(*v) && bestv.wants(dd) {
|
||||
bestv.offer(dd, Hit::Vertex { body: b.name.clone(), point: *v });
|
||||
}
|
||||
}
|
||||
}
|
||||
let pts: Vec<Pos2> = e.iter().filter_map(|q| proj.to_screen(*q)).collect();
|
||||
for (k, w) in pts.windows(2).enumerate() {
|
||||
let dd = seg_dist(pos, w[0], w[1]);
|
||||
if dd < 6.0 && beste.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||||
if dd < 6.0 && beste.wants(dd) {
|
||||
let mid = polyline_mid(e);
|
||||
// Visible where the cursor is (not just at the middle).
|
||||
let at = e.get(k).copied().unwrap_or(mid);
|
||||
if visible(at) || visible(mid) {
|
||||
beste = Some((dd, Hit::Edge { body: b.name.clone(), index: ei, mid }));
|
||||
beste.offer(dd, Hit::Edge { body: b.name.clone(), index: ei, mid });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.extend(bestv.map(|x| x.1));
|
||||
hits.extend(beste.map(|x| x.1));
|
||||
hits.extend(bestv.hits());
|
||||
hits.extend(beste.hits());
|
||||
// Origin axes.
|
||||
let size = origin_size(app.cam.half_height());
|
||||
let mut besta: Option<(f32, Hit)> = None;
|
||||
let mut besta = Near::new(all);
|
||||
for (name, dir) in origin_axes(app) {
|
||||
if let (Some(a), Some(b)) = (proj.to_screen(Vec3::ZERO), proj.to_screen(dir * (size * AXIS_LEN))) {
|
||||
let dd = seg_dist(pos, a, b);
|
||||
if dd < 5.0 && besta.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||||
besta = Some((dd, Hit::Axis { name: name.into() }));
|
||||
if dd < 5.0 && besta.wants(dd) {
|
||||
besta.offer(dd, Hit::Axis { name: name.into() });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -864,15 +928,16 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
|
||||
for (name, a, b) in construction_axis_segments(app) {
|
||||
if let (Some(a), Some(b)) = (proj.to_screen(a), proj.to_screen(b)) {
|
||||
let dd = seg_dist(pos, a, b);
|
||||
if dd < 5.0 && besta.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||||
besta = Some((dd, Hit::Axis { name }));
|
||||
if dd < 5.0 && besta.wants(dd) {
|
||||
besta.offer(dd, Hit::Axis { name });
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.extend(besta.map(|x| x.1));
|
||||
hits.extend(besta.hits());
|
||||
// Surfaces by depth: profiles (slightly preferred over a face they lie on), faces, planes.
|
||||
let mut surf: Vec<(f64, Hit)> = Vec::new();
|
||||
let mut bestp: Option<(f64, f64, Hit)> = None;
|
||||
let mut profiles_all: Vec<(f64, f64, Hit)> = Vec::new();
|
||||
for sid in visible_sketches(app) {
|
||||
let Some(ss) = st.sketch(sid) else { continue };
|
||||
let n = ss.plane.normal();
|
||||
@@ -888,15 +953,24 @@ fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_
|
||||
// Show Profile off: the sketch being edited offers no profiles.
|
||||
let profiles = if ss.sketch.view.hide_profile && s.active_sketch == Some(sid) { &[][..] } else { &ss.profiles[..] };
|
||||
for (pi, p) in profiles.iter().enumerate() {
|
||||
if p.region.contains(lp) && bestp.as_ref().is_none_or(|(_, a, _)| p.area < *a) {
|
||||
if !p.region.contains(lp) {
|
||||
continue;
|
||||
}
|
||||
if all {
|
||||
profiles_all.push((t, p.area, Hit::Profile { sketch: sid, index: pi }));
|
||||
} else if bestp.as_ref().is_none_or(|(_, a, _)| p.area < *a) {
|
||||
bestp = Some((t, p.area, Hit::Profile { sketch: sid, index: pi }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((t, _, h)) = bestp {
|
||||
// Smallest profile first.
|
||||
profiles_all.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
for (t, _, h) in bestp.into_iter().chain(profiles_all) {
|
||||
surf.push((t - slack, h));
|
||||
}
|
||||
if let Some(f) = bestf {
|
||||
if all {
|
||||
surf.extend(faces.sorted());
|
||||
} else if let Some(f) = bestf {
|
||||
surf.push(f);
|
||||
}
|
||||
// Planes give way to a face or profile at the same depth (a floor on the XZ plane).
|
||||
@@ -1103,27 +1177,57 @@ pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> {
|
||||
if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) {
|
||||
return hits.iter().find_map(|h| d.candidate(&app.session, h).map(|s| (h.clone(), s)));
|
||||
}
|
||||
hits.iter().filter(|h| pick_allowed(app, h)).find_map(|h| hit_candidate(app, h))
|
||||
}
|
||||
|
||||
/// Every hit a click could pick, in order, each with its selection (Select Other).
|
||||
pub fn candidates(app: &SolveApp, hits: &[Hit]) -> Vec<(Hit, Sel)> {
|
||||
if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) {
|
||||
return hits.iter().filter_map(|h| d.candidate(&app.session, h).map(|s| (h.clone(), s))).collect();
|
||||
}
|
||||
hits.iter().filter(|h| pick_allowed(app, h)).filter_map(|h| hit_candidate(app, h)).collect()
|
||||
}
|
||||
|
||||
/// What the selection filter lets a click pick with no dialog.
|
||||
fn pick_allowed(app: &SolveApp, h: &Hit) -> bool {
|
||||
let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k);
|
||||
hits.iter()
|
||||
.filter(|h| match h {
|
||||
Hit::SketchCurve { .. } => !off("sketch") && !off("sketch_curves"),
|
||||
Hit::SketchPoint { .. } => !off("sketch") && !off("sketch_points"),
|
||||
Hit::Profile { .. } => !off("sketch"),
|
||||
Hit::Vertex { .. } => !off("vertices") && !app.ui.pick_bodies,
|
||||
Hit::Edge { .. } => !off("edges") && !app.ui.pick_bodies,
|
||||
Hit::Face { .. } => !off("faces") || app.ui.pick_bodies,
|
||||
Hit::Plane { .. } | Hit::Axis { .. } => true,
|
||||
})
|
||||
.find_map(|h| match h {
|
||||
Hit::SketchCurve { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
|
||||
Some((h.clone(), Sel::ExternalSketchCurve { sketch: *sketch, id: id.clone() }))
|
||||
}
|
||||
Hit::SketchPoint { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
|
||||
Some((h.clone(), Sel::ExternalSketchPoint { sketch: *sketch, id: id.clone() }))
|
||||
}
|
||||
Hit::Face { body, .. } if app.ui.pick_bodies => Some((h.clone(), Sel::Body { name: body.clone() })),
|
||||
_ => hit_sel(h).map(|s| (h.clone(), s)),
|
||||
})
|
||||
match h {
|
||||
Hit::SketchCurve { .. } => !off("sketch") && !off("sketch_curves"),
|
||||
Hit::SketchPoint { .. } => !off("sketch") && !off("sketch_points"),
|
||||
Hit::Profile { .. } => !off("sketch"),
|
||||
Hit::Vertex { .. } => !off("vertices") && !app.ui.pick_bodies,
|
||||
Hit::Edge { .. } => !off("edges") && !app.ui.pick_bodies,
|
||||
Hit::Face { .. } => !off("faces") || app.ui.pick_bodies,
|
||||
Hit::Plane { .. } | Hit::Axis { .. } => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The selection a click on `h` makes with no dialog (sketch entities only belong to the active sketch).
|
||||
fn hit_candidate(app: &SolveApp, h: &Hit) -> Option<(Hit, Sel)> {
|
||||
match h {
|
||||
Hit::SketchCurve { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
|
||||
Some((h.clone(), Sel::ExternalSketchCurve { sketch: *sketch, id: id.clone() }))
|
||||
}
|
||||
Hit::SketchPoint { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => {
|
||||
Some((h.clone(), Sel::ExternalSketchPoint { sketch: *sketch, id: id.clone() }))
|
||||
}
|
||||
Hit::Face { body, .. } if app.ui.pick_bodies => Some((h.clone(), Sel::Body { name: body.clone() })),
|
||||
_ => hit_sel(h).map(|s| (h.clone(), s)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A pick for the open dialog's active input (a click, or a Select Other row).
|
||||
pub(crate) fn dialog_pick(app: &mut SolveApp, hit: &Hit, sel: Sel) {
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
d.pick(&app.session, sel);
|
||||
if let crate::dialogs::Kind::PathPlane { sketch, .. } = &mut d.kind {
|
||||
*sketch = match hit {
|
||||
Hit::SketchCurve { sketch, .. } => Some(*sketch),
|
||||
_ => None,
|
||||
};
|
||||
d.extra.remove("path");
|
||||
}
|
||||
app.dialog = Some(d);
|
||||
}
|
||||
|
||||
/// The edge a selection refers to now: by index if it still passes through the stored point,
|
||||
@@ -1726,20 +1830,24 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
// A left press with the preset's keys (Alias: Shift+Alt) navigates until the button is let
|
||||
// go (kept through the release frame, so the end of the drag doesn't select either).
|
||||
if primary_pressed && inside {
|
||||
let openscad_orbit = app.preferences.nav == crate::prefs::OPENSCAD_NAV
|
||||
&& app.tool.is_none()
|
||||
&& app.dialog.is_none()
|
||||
&& app.viewport.context_menu.is_none()
|
||||
&& !mods.shift
|
||||
&& hover.is_some_and(|p| {
|
||||
let proj = projection(app, rect);
|
||||
pick(app, &proj, p).is_empty() && crate::dim_view::hit(p).is_none() && crate::sketch_tools::glyph_at(p).is_none()
|
||||
});
|
||||
app.viewport.primary_nav = if app.preferences.nav != crate::prefs::OPENSCAD_NAV || openscad_orbit {
|
||||
let empty_space = |app: &SolveApp| {
|
||||
app.viewport.context_menu.is_none()
|
||||
&& hover.is_some_and(|p| {
|
||||
let proj = projection(app, rect);
|
||||
pick(app, &proj, p).is_empty() && crate::dim_view::hit(p).is_none() && crate::sketch_tools::glyph_at(p).is_none()
|
||||
})
|
||||
};
|
||||
let openscad_orbit =
|
||||
app.preferences.nav == crate::prefs::OPENSCAD_NAV && app.tool.is_none() && app.dialog.is_none() && !mods.shift && empty_space(app);
|
||||
let preset = if app.preferences.nav != crate::prefs::OPENSCAD_NAV || openscad_orbit {
|
||||
crate::prefs::nav_mode(app, crate::prefs::NavButton::Left, mods)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Ctrl+left drag from empty space orbits in every preset (Cmd on macOS). A Ctrl+click
|
||||
// still selects, and Ctrl on geometry still drags it without snapping.
|
||||
let ctrl_orbit = (mods.ctrl || mods.command) && !mods.shift && !mods.alt && empty_space(app);
|
||||
app.viewport.primary_nav = preset.or(ctrl_orbit.then_some(NavMode::Orbit));
|
||||
} else if !primary_down && !primary_released {
|
||||
app.viewport.primary_nav = None;
|
||||
}
|
||||
@@ -1851,6 +1959,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default();
|
||||
let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) };
|
||||
app.viewport.hover = cand.as_ref().map(|c| c.0.clone());
|
||||
// Select Other: a press held still opens the list of everything under it; while the list is
|
||||
// open its row under the pointer is what lights up.
|
||||
let held =
|
||||
crate::select_other::track_hold(app, ui.ctx(), &proj, inside, primary_pressed, primary_down, hover, primary_nav.is_some() || mode.is_some());
|
||||
crate::select_other::hover_override(app);
|
||||
let painter = ui.painter_at(rect);
|
||||
// Background gradient.
|
||||
let (top, bot) = (t.viewport_top, t.viewport_bottom);
|
||||
@@ -1926,22 +2039,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if app.tool.is_some() {
|
||||
crate::tools::on_hover(app, &proj, p);
|
||||
}
|
||||
if resp.clicked() {
|
||||
if resp.clicked() && !held {
|
||||
if app.tool.is_some() {
|
||||
crate::tools::on_click(app, &proj, p);
|
||||
} else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) {
|
||||
if let Some((hit, Some(sel))) = cand
|
||||
&& let Some(mut d) = app.dialog.take()
|
||||
{
|
||||
d.pick(&app.session, sel);
|
||||
if let crate::dialogs::Kind::PathPlane { sketch, .. } = &mut d.kind {
|
||||
*sketch = match hit {
|
||||
Hit::SketchCurve { sketch, .. } => Some(sketch),
|
||||
_ => None,
|
||||
};
|
||||
d.extra.remove("path");
|
||||
}
|
||||
app.dialog = Some(d);
|
||||
if let Some((hit, Some(sel))) = cand {
|
||||
dialog_pick(app, &hit, sel);
|
||||
}
|
||||
} else {
|
||||
// Dimensions and constraint glyphs sit on top of the geometry.
|
||||
@@ -1957,15 +2060,23 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if !crate::sketch3d::click(app, &proj, p) {
|
||||
match on_dim.or(on_glyph) {
|
||||
Some(id) => select(app, Some(Sel::SketchConstraint { id }), add),
|
||||
None => select(app, cand.and_then(|c| c.1), add),
|
||||
// Clicking again on the same spot selects the next thing under it (not
|
||||
// the second click of a double-click, which has its own actions).
|
||||
None if !add && !resp.double_clicked() && crate::select_other::cycle_click(app, &proj, p) => {}
|
||||
None => {
|
||||
let sel = cand.and_then(|c| c.1);
|
||||
select(app, sel.clone(), add);
|
||||
crate::select_other::note_click(app, p, if add { None } else { sel });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Double-click a dimension: edit its value in place.
|
||||
let dim_edit = resp.double_clicked() && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p);
|
||||
let dim_edit = resp.double_clicked() && !held && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p);
|
||||
// Double-click a sketch curve: select the chain joined to it end to end.
|
||||
if resp.double_clicked()
|
||||
&& !held
|
||||
&& !dim_edit
|
||||
&& app.tool.is_none()
|
||||
&& app.dialog.is_none()
|
||||
@@ -1979,6 +2090,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
// Double-click an edge: select the edges that continue it smoothly (the loop).
|
||||
if resp.double_clicked()
|
||||
&& !held
|
||||
&& !dim_edit
|
||||
&& app.tool.is_none()
|
||||
&& app.dialog.is_none()
|
||||
@@ -2002,7 +2114,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
// Dragging a sketch point of the active sketch moves it; the solver keeps the constraints.
|
||||
if app.tool.is_none() && app.dialog.is_none() && primary_nav.is_none() && mode.is_none() && app.session.active_sketch.is_some() {
|
||||
if app.tool.is_none() && app.dialog.is_none() && primary_nav.is_none() && mode.is_none() && app.session.active_sketch.is_some() && !held {
|
||||
let on_plane = |app: &SolveApp, p: Pos2| {
|
||||
let ss = app.session.active_sketch.and_then(|sid| app.session.world_state().sketch(sid).cloned())?;
|
||||
let (o, d) = proj.ray(p);
|
||||
@@ -2114,6 +2226,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
// Box selection: a primary drag on the model when no navigation mode is on.
|
||||
if app.tool.is_none()
|
||||
&& !held
|
||||
&& primary_nav.is_none()
|
||||
&& mode.is_none()
|
||||
&& app.viewport.point_drag.is_none()
|
||||
@@ -2169,6 +2282,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::canvas::show(app, ui, &painter, &proj);
|
||||
crate::sketch_tools::show(app, ui, &painter, &proj);
|
||||
crate::sketch_dims::show(app, ui, &proj);
|
||||
crate::select_other::show(app, ui.ctx());
|
||||
axis_view(app, ui, rect);
|
||||
nav_bar(app, ui, rect);
|
||||
// Status chip.
|
||||
@@ -2188,7 +2302,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
|
||||
/// A click without a command: select the candidate (replacing the selection, or toggling it
|
||||
/// in with Shift/Ctrl); a click on nothing clears.
|
||||
fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
|
||||
pub(crate) fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
|
||||
match sel {
|
||||
Some(x) => {
|
||||
let items = grouped_selection(&app.session, &x);
|
||||
@@ -3094,6 +3208,63 @@ mod tests {
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_left_drag_from_empty_space_orbits_in_every_preset() {
|
||||
for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) {
|
||||
for action in ["orbit", "tool", "point", "click"] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = hover_app(false);
|
||||
app.preferences.nav = preset.into();
|
||||
app.ui.show_origin = false;
|
||||
hover_frame(&mut app, &ctx, vec![]);
|
||||
let point = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
|
||||
let (start, end) = match action {
|
||||
"point" => (point, point + vec2(30.0, 20.0)),
|
||||
"click" => (point, point),
|
||||
_ => (point - vec2(60.0, 60.0), point - vec2(20.0, 30.0)),
|
||||
};
|
||||
if action == "tool" {
|
||||
app.start("sketch.line");
|
||||
}
|
||||
let before = app.cam;
|
||||
let undo = app.session.undo.len();
|
||||
let modifiers = egui::Modifiers { ctrl: true, command: !cfg!(target_os = "macos"), ..Default::default() };
|
||||
let frame = |app: &mut SolveApp, mut events: Vec<egui::Event>| {
|
||||
events.insert(0, egui::Event::ModifiersChanged(modifiers));
|
||||
hover_frame(app, &ctx, events);
|
||||
};
|
||||
frame(
|
||||
&mut app,
|
||||
vec![
|
||||
egui::Event::PointerMoved(start),
|
||||
egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers },
|
||||
],
|
||||
);
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(end)]);
|
||||
frame(&mut app, vec![]);
|
||||
match action {
|
||||
"orbit" | "tool" => {
|
||||
assert_ne!(app.cam, before, "{preset}: Ctrl+left drag on empty space orbits ({action})");
|
||||
assert!(app.viewport.boxsel.is_none(), "{preset}: no box selection while orbiting");
|
||||
}
|
||||
"point" => {
|
||||
assert_eq!(app.cam, before, "{preset}: Ctrl+drag on sketch geometry is not an orbit");
|
||||
assert!(app.viewport.point_drag.is_some(), "{preset}: Ctrl+drag still drags the point");
|
||||
}
|
||||
_ => assert_eq!(app.cam, before, "{preset}: a Ctrl+click does not orbit"),
|
||||
}
|
||||
frame(&mut app, vec![egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers }]);
|
||||
frame(&mut app, vec![]);
|
||||
match action {
|
||||
"click" => assert!(!app.session.selection.is_empty(), "{preset}: Ctrl+click still selects"),
|
||||
"tool" => assert_eq!(app.session.undo.len(), undo, "{preset}: the orbit drag adds no geometry"),
|
||||
"orbit" => assert!(app.session.selection.is_empty(), "{preset}: the end of the orbit selects nothing"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openscad_left_drag_preserves_editing_and_selection() {
|
||||
for action in ["point", "box", "tool"] {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Toolbar layout of the tabs that share commands with others (Fusion's layout): the SOLID
|
||||
//! tab's ASSEMBLE panel, SHEET METAL and PLASTIC. A command is registered on one tab and panel;
|
||||
//! Explicit toolbar layouts: Sketch CREATE, the SOLID tab's ASSEMBLE panel, SHEET METAL and
|
||||
//! PLASTIC. A command is registered on one tab and panel;
|
||||
//! these panels list the commands they show in order (the first few get big buttons), so Extrude
|
||||
//! or Joint appear on several tabs. Commands registered on such a panel but not listed follow at
|
||||
//! the end.
|
||||
@@ -100,6 +100,19 @@ pub fn panels(tab: &str) -> Option<&'static [&'static str]> {
|
||||
/// to the registry.
|
||||
pub fn layout<'a>(tab: &str, panel: &str, specs: &[&'a CommandSpec]) -> Option<(Vec<&'a CommandSpec>, usize)> {
|
||||
let (ids, promoted, own_tab): (&[&str], usize, &str) = match (tab, panel) {
|
||||
("SKETCH", "CREATE") => (
|
||||
&[
|
||||
"sketch.line",
|
||||
"sketch.rectangle.two_point",
|
||||
"sketch.rectangle.three_point",
|
||||
"sketch.rectangle.center",
|
||||
"sketch.circle.center",
|
||||
"sketch.circle.two_point",
|
||||
"sketch.point",
|
||||
],
|
||||
7,
|
||||
tab,
|
||||
),
|
||||
("SOLID", "CONFIGURE") => (&["parameters.change", "config.table"], 2, tab),
|
||||
("SOLID" | "SHEET METAL" | "PLASTIC", "ASSEMBLE") => (ASSEMBLE, 3, tab),
|
||||
("SHEET METAL", "CREATE") => (SHEET_CREATE, 3, tab),
|
||||
|
||||
@@ -81,16 +81,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "The sheet flange face is uncertified; choose the displayed fixed-plane option explicitly before drawing the fold line."
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"sketching": false,
|
||||
"dialog": "this face has no exact planar source"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widget": "Use fixed plane"
|
||||
"note": "A flat face of a sheet attaches the sketch to its panel (by name), in the flat pattern's coordinates."
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
[
|
||||
{
|
||||
"note": "Two edge flanges in one feature; the second gets an angle of its own in its row of the Flange dialog. Default rule: T 2.5, R 2.5, gap 2.5; height 10. By hand: s0 = (R + T)·tan(θ/2) is 5 at 90° and 2.886751 at 60°, so the base keeps 57.113249 x 35 = 1998.963703 mm². Each bend holds len·θ·(R + T/2): 57.113249·(π/2)·3.75 + 35·(π/3)·3.75 = 473.869483. The 90° panel ends x(w) = ((g/2)·√2 + T − w·cos 60°) / sin 60° from the corner, 2.041241 at w = 5 and −0.845510 at w = 10: 5·(60 − 0.597866) = 297.010671. The 60° panel ends (g/2)·√2 + T = 4.267767 from the corner and is 7.113249 high: 254.172259. Volume: 2.5·3024.016116 = 7560.040 mm³."
|
||||
},
|
||||
{
|
||||
"call": "ui.set",
|
||||
"params": {
|
||||
"tab": "SHEET METAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "sketch.create"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"plane": "XY"
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "sketch.rectangle.two_point"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"sketch": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"sketch": [
|
||||
60,
|
||||
40
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Escape"
|
||||
},
|
||||
{
|
||||
"start": "sketch.finish"
|
||||
},
|
||||
{
|
||||
"call": "ui.view",
|
||||
"params": {
|
||||
"view": "iso"
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "sheet.flange"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"world": [
|
||||
30,
|
||||
20,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"bodies": 1,
|
||||
"volume": 6000
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "sheet.flange"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"world": [
|
||||
30,
|
||||
0,
|
||||
2.5
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"world": [
|
||||
60,
|
||||
20,
|
||||
2.5
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "dialog:Edge 2 angle"
|
||||
}
|
||||
},
|
||||
{
|
||||
"text": "60 deg"
|
||||
},
|
||||
{
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"bodies": 1,
|
||||
"errors": 0,
|
||||
"dialog": null,
|
||||
"volume": [
|
||||
7560.04,
|
||||
0.0001
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"shot": "s49_flange_per_edge"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{"note":"#191: Point is a default Sketch CREATE button; the existing six drawing buttons remain available."},
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"expect":{"shown_handles":["button:sketch.line","button:sketch.rectangle.two_point","button:sketch.rectangle.three_point","button:sketch.rectangle.center","button:sketch.circle.center","button:sketch.circle.two_point","button:sketch.point"]}},
|
||||
{"click":{"handle":"button:sketch.point"}},
|
||||
{"expect":{"tool":"sketch.point","dialog":null}},
|
||||
{"click":{"sketch":[20,15]}},
|
||||
{"key":"Escape"},
|
||||
{"expect":{"errors":0,"sketch_world":{"point":"p1","at":[20,15,0]}}},
|
||||
{"call":"ui.menu","params":{"x":400,"y":80,"target":{"type":"toolbar_command","id":"sketch.point"}}},
|
||||
{"call":"ui.menuPick","params":{"item":"Remove from Toolbar"}},
|
||||
{"expect":{"hidden_handles":["button:sketch.point"]}},
|
||||
{"restart":"close"},
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"expect":{"hidden_handles":["button:sketch.point"]}},
|
||||
{"click":{"handle":"panel:SKETCH:CREATE"}},
|
||||
{"expect":{"shown_handles":["dropdown:sketch.point"]}},
|
||||
{"click":{"handle":"dropdown:sketch.point"}},
|
||||
{"key":"Escape"},
|
||||
{"call":"ui.menu","params":{"x":400,"y":80,"target":{"type":"toolbar"}}},
|
||||
{"call":"ui.menuPick","params":{"item":"Reset Toolbar"}},
|
||||
{"expect":{"shown_handles":["button:sketch.point","button:sketch.line"],"errors":0}},
|
||||
{"call":"ui.resize","params":{"width":640,"height":720}},
|
||||
{"click":{"handle":"panel:SKETCH:CREATE"}},
|
||||
{"click":{"handle":"dropdown:sketch.point"}},
|
||||
{"expect":{"tool":"sketch.point","errors":0}}
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
[
|
||||
{"note":"#333: dropping a dropdown command on a panel label appends it; moving between labels keeps the chosen order after restart."},
|
||||
{"click":{"handle":"panel:SOLID:CREATE"}},
|
||||
{"drag":[{"handle":"dropdown:solid.cylinder"},{"handle":"panel:SOLID:CREATE"}],"steps":12},
|
||||
{"key":"Escape"},
|
||||
{"expect":{"handle_order":["button:solid.sweep","button:solid.cylinder"],"dialog":null,"tool":null,"errors":0}},
|
||||
{"click":{"handle":"panel:SOLID:CREATE"}},
|
||||
{"drag":[{"handle":"dropdown:solid.torus"},{"handle":"panel:SOLID:MODIFY"}],"steps":12},
|
||||
{"key":"Escape"},
|
||||
{"expect":{"handle_order":["button:solid.scale","button:solid.torus"],"dialog":null,"tool":null,"errors":0}},
|
||||
{"drag":[{"handle":"button:solid.torus"},{"handle":"panel:SOLID:CREATE"}],"steps":12},
|
||||
{"expect":{"handle_order":["button:solid.cylinder","button:solid.torus"]}},
|
||||
{"restart":"close"},
|
||||
{"expect":{"handle_order":["button:solid.sweep","button:solid.cylinder","button:solid.torus"],"errors":0}},
|
||||
{"call":"ui.resize","params":{"width":640,"height":720}},
|
||||
{"click":{"handle":"panel:SOLID:CREATE"}},
|
||||
{"drag":[{"handle":"dropdown:solid.box"},{"handle":"panel:SOLID:CREATE"}],"steps":12},
|
||||
{"key":"Escape"},
|
||||
{"call":"ui.resize","params":{"width":1600,"height":1000}},
|
||||
{"expect":{"handle_order":["button:solid.torus","button:solid.box"],"tool":null,"dialog":null,"errors":0}}
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{"note": "Two lines drawn on top of each other: holding the button still lists both, and the one underneath can be picked."},
|
||||
{"start": "sketch.create"},
|
||||
{"click": {"plane": "XY"}},
|
||||
{"call": "ui.set", "params": {"showOrigin": false}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[0, 10], [20, 10]], "infer": false, "connect": false}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[0, 10], [20, 10]], "infer": false, "connect": false}}},
|
||||
{"hold": {"sketch": [10, 10]}},
|
||||
{"expect": {"selection": 0, "sketching": true}},
|
||||
{"move": {"handle": "select_other:1"}},
|
||||
{"click": {"handle": "select_other:1"}},
|
||||
{"expect": {"selection": 1}},
|
||||
{"call": "ui.selectOther", "params": {"pick": 0}, "fail": true},
|
||||
{"note": "Click cycling: a second deliberate click on the same spot selects the next line."},
|
||||
{"click": {"sketch": [10, 10]}},
|
||||
{"expect": {"selection": 1}}
|
||||
]
|
||||
@@ -51,8 +51,9 @@ keeps the current design. Previews cannot be edited or measured. This experiment
|
||||
requires `preview: true` in an engine Save/Save As call; ordinary saves omit display caches.
|
||||
The repository's `docs/design-container.md` describes limits and encoding details.
|
||||
|
||||
Current designs use format 14, including persistent names from exact face history,
|
||||
exact planar shell-face anchors, persistent analytic offset boundary roles and
|
||||
Current designs use format 15, including sketch face anchors on cylinder caps, sketch axis
|
||||
links, persistent
|
||||
names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and
|
||||
association profile references, durable sketch face anchors, periodic fit splines and versioned
|
||||
revolve conventions. It also retains recorded body transfers, explicit 3D curve coordinate
|
||||
spaces and certified face bindings, alongside semantic configuration styles, associative
|
||||
|
||||
@@ -24,6 +24,24 @@ opposite ways, ends that would overlap in the flat pattern) stays open, and the
|
||||
reports a warning saying where. A flange added by a later feature leaves the corner as it was:
|
||||
the earlier flange keeps its length and the new one ends at its bend.
|
||||
|
||||
The flanges of one edge flange feature can each have an angle and a height of their own. An
|
||||
entry of `edges` is a point `[x, y, z]`, which takes the feature's `height` and `angle`, or an
|
||||
object `{"point": [x, y, z], "angle": "105 deg", "height": "30 mm"}` whose `angle` and `height`
|
||||
(either one, or both) replace them for that edge. They are expressions like the feature's own,
|
||||
so parameters work, and each is a feature input of its own (`Edge angle`, `Edge height`) after
|
||||
`Height` and `Angle`. The corners between such flanges close like those between equal ones:
|
||||
the ends run toward each other the rule's gap apart, either side of the plane halfway between
|
||||
the two flanges, and a taller flange's end keeps that line up to its own tip. This is how to
|
||||
close a corner between flanges at different angles, because flanges of separate features
|
||||
don't close toward each other. In the Flange dialog, picking more than one edge shows a row
|
||||
per edge with an angle and a height box; an empty box takes the value above.
|
||||
|
||||
```json
|
||||
{"command":"sheet.flange","params":{"edges":[[60,0,2],{"point":[120,45,2],"angle":"90 deg"},
|
||||
{"point":[60,90,2],"angle":"105 deg","height":"30 mm"},{"point":[0,45,2],"angle":"120 deg","height":"25 mm"}],
|
||||
"height":38.6,"angle":"75 deg"}}
|
||||
```
|
||||
|
||||
For a shop drawing or spreadsheet, `sheet.export_bend_table` returns CSV by default; choose
|
||||
`format: "json"` for structured data. It includes the sheet rule, thickness, bend angles,
|
||||
radii, allowances and flat centre-line coordinates. Lengths use millimetres and angles use
|
||||
@@ -71,3 +89,15 @@ tool with varying depth is refused before changing sheet geometry. Pocket profil
|
||||
but a pocket crossing a bend zone cannot yet be refolded. Machining folded bend material,
|
||||
tapered sheet cuts and other unsupported 3D sheet Cut/Intersect operations report an error before changing
|
||||
the design; they cannot silently restore removed material during a later sheet rebuild.
|
||||
|
||||
A sketch can be drawn on a flat face of the sheet: the base or a flange, on either side. Pick
|
||||
the face (or give `sketch.create` the point `plane: {face: [x, y, z]}`) and the sketch attaches
|
||||
to that panel instead of to a certified source face. It keeps the face's name,
|
||||
`sheet:<body>:<panel>:<top|bottom>` (the panel is `base` or the flange's feature tag), and finds
|
||||
the face again whenever the sheet is rebuilt: after a parameter change such as the thickness or
|
||||
a flange angle, through Unfold / Refold, after the file is reopened and when the body is
|
||||
renamed. Sketch coordinates are the flat pattern's, so a point drawn on a flange lies where the
|
||||
flat pattern and its DXF have it, and on the bottom face y runs the other way, as seen from
|
||||
below. When the face is gone (its flange is suppressed or deleted) the sketch reports a lost
|
||||
attachment instead of moving to another face; **Redefine Sketch Plane** attaches it to another
|
||||
face. `fixed: true` still freezes the picked plane in space.
|
||||
|
||||
@@ -26,4 +26,13 @@ Keys handled by the window itself:
|
||||
| F6 | fit the view to the model |
|
||||
| Esc | cancel the running command, or clear the selection |
|
||||
|
||||
Mouse and touch:
|
||||
|
||||
| Action | Result |
|
||||
|---|---|
|
||||
| Click | select (Shift or Ctrl adds) |
|
||||
| Click again on the same spot | select the next thing under the cursor (click cycling) |
|
||||
| Hold the left button still (long-press on touch), or right-click › Select Other… | list everything under the cursor to pick from |
|
||||
| Double-click a sketch curve or edge | select its chain or tangent loop |
|
||||
|
||||
Help › Keyboard Shortcuts in the app lists the current bindings, including any you changed.
|
||||
|
||||
@@ -21,6 +21,10 @@ constraint status and **Finish Sketch** button remain visible.
|
||||
|
||||
## Geometry
|
||||
|
||||
**Point** is available directly on the default Sketch **CREATE** toolbar. Click it, then
|
||||
click in the sketch to place a point. Existing toolbar customizations are kept; use
|
||||
**Reset Toolbar** from the toolbar's context menu to restore the default buttons.
|
||||
|
||||
Lines and polylines (L), rectangles (two-point R, three-point, centre), circles (centre C,
|
||||
two-point, three-point, tangent), arcs (three-point, centre, tangent), polygons, slots, ellipses,
|
||||
fit-point and control-point splines, conics, points and text. **Construction** (X) turns curves
|
||||
@@ -35,6 +39,10 @@ is projected in one undo step. A rejected projection keeps the selection for cor
|
||||
an empty or mixed selection starts the ordinary picking tool. External sketch selections
|
||||
retain their source sketch, even when the active sketch uses the same point or curve names.
|
||||
|
||||
A sketch on a construction plane built from the model (through a sketch line, through three
|
||||
points, midway between two faces…) projects, intersects, includes and holds text like a sketch
|
||||
on any other plane, and its links follow when a parameter moves that plane.
|
||||
|
||||
Run from a script or the control channel, every command that adds sketch geometry returns the
|
||||
ids of what it added (`created: {points, curves, constraints, dimensions}`). Ids are stable and
|
||||
never reused within a sketch, even after a delete, so a script can dimension or constrain the new
|
||||
|
||||
@@ -7,18 +7,28 @@
|
||||
undo history, selection and camera).
|
||||
- **Toolbar.** The workspace switcher (DESIGN) sits on the left. The tabs (SOLID, SURFACE,
|
||||
SHEET METAL, PLASTIC, UTILITIES, and SKETCH while you sketch) hold panels of commands. Click a
|
||||
panel's name for all its commands.
|
||||
panel's name for all its commands. Drag a command from the dropdown onto a panel's label
|
||||
or empty space to append it; the insertion line shows its destination. Drop onto an existing
|
||||
button to insert before it. Buttons can also move between panels, and the layout is saved.
|
||||
- **Browser** (left). The design as a tree: document settings and units, named views, the
|
||||
origin, bodies, sketches, construction planes, canvases, joints and components. The eye shows or
|
||||
hides an item, a component's radio button makes it the active component, and right-clicking any
|
||||
row gives that item's menu. Rows drag into groups and other components.
|
||||
- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. The
|
||||
- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. To
|
||||
reach something under something else (two lines drawn on top of each other, coincident points,
|
||||
an edge in front of a face, overlapping bodies), hold the left button still for half a second
|
||||
(long-press on a touch screen) or right-click › **Select Other…**: a list of everything under
|
||||
the cursor opens there, the sketch being edited first. Hovering a row lights it up; clicking it
|
||||
selects it (Shift or Ctrl adds), and in a command's dialog it goes to the active selection box.
|
||||
Esc or a click elsewhere closes the list. Clicking again on the same spot, with nothing changed
|
||||
in between, selects the next thing in that list. The
|
||||
wheel zooms at the cursor, the middle button pans and right-drag orbits freely (the mouse
|
||||
< preset in Preferences switches to SolidWorks, Inventor, Alias or Tinkercad style). On a
|
||||
trackpad, pinch zooms and two-finger scroll pans (Navigation › Two-finger scroll pans;
|
||||
off: scroll zooms like the wheel); mouse wheels zoom and the thumb wheel pans sideways.
|
||||
Shift+middle orbits freely
|
||||
in the default and Inventor presets. Shift+right-drag snaps to the nearest standard or
|
||||
in the default and Inventor presets. In every preset, Ctrl+left drag (Cmd on macOS) that starts
|
||||
on empty space orbits; a Ctrl+click still adds to the selection. Shift+right-drag snaps to the nearest standard or
|
||||
diagonal view; Shift does not request snapping when the preset already requires it to orbit.
|
||||
The axis view shows the world's X/Y/Z directions in SolidCraft's colors. Click a signed
|
||||
axis tip to look along that axis; click the aligned tip again to look from the opposite side.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user