Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29258767ce | ||
|
|
392fae1af4 | ||
|
|
8be0f64f5a | ||
|
|
9f6fb735dd | ||
|
|
46e21127dd | ||
|
|
90869732a3 | ||
|
|
24cff445ad | ||
|
|
bb60a95289 | ||
|
|
8d58e3b9f9 | ||
|
|
f0d80a1d7e | ||
|
|
0f0bb09858 | ||
|
|
6d5df4b9b3 | ||
|
|
27f20f97cb | ||
|
|
e67cc0c747 | ||
|
|
bcbfa50eac | ||
|
|
24974e6f37 | ||
|
|
bf962a9989 | ||
|
|
04cfb74c5e | ||
|
|
14635e5d3a | ||
|
|
a2f31f683d | ||
|
|
9089edfb71 | ||
|
|
a4ca1b68db | ||
|
|
94ab54d1f5 | ||
|
|
9e2a1eb993 | ||
|
|
abff7a49bb | ||
|
|
651e8e0853 | ||
|
|
4a86ad5a7c | ||
|
|
5ad8b3966f | ||
|
|
dc060f8b86 | ||
|
|
9c94e1b96d | ||
|
|
2aff1c5303 | ||
|
|
135a63f549 | ||
|
|
2a434d59c3 | ||
|
|
083ddf7722 | ||
|
|
db02a18627 | ||
|
|
c727e2d0c2 | ||
|
|
f9c19ade16 | ||
|
|
711e5508b5 | ||
|
|
913583bfee | ||
|
|
1abd989a67 | ||
|
|
b1740aeee5 | ||
|
|
736d9277b8 | ||
|
|
9283d2b5bb | ||
|
|
c1cd4cf194 | ||
|
|
1a6ff0954e | ||
|
|
88e9661457 | ||
|
|
7a8754bef7 | ||
|
|
6441621b8b | ||
|
|
d7797c3d38 | ||
|
|
b11841b0bc | ||
|
|
a8121215bc | ||
|
|
a69c2928f0 | ||
|
|
7d00df0a3f | ||
|
|
69e54f85d2 | ||
|
|
bf2a9f5541 | ||
|
|
d6af554c6d | ||
|
|
c2a9c71436 | ||
|
|
f93577ad70 | ||
|
|
9f88537927 | ||
|
|
3159fc0dd0 | ||
|
|
340ac0313a | ||
|
|
bb431f7ac2 | ||
|
|
2f7046efac | ||
|
|
aaeb7ceea7 | ||
|
|
10c6b865c7 |
No files matched your search
@@ -73,10 +73,13 @@ installing SolidCraft; keep both remotes in the same user installation:
|
||||
```sh
|
||||
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak remote-add --user --if-not-exists solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo
|
||||
flatpak install --user solidcraft ai.storyteller.solvecraft
|
||||
flatpak run ai.storyteller.solvecraft
|
||||
flatpak install --user solidcraft ai.storyteller.solidcraft
|
||||
flatpak run ai.storyteller.solidcraft
|
||||
```
|
||||
|
||||
Flatpak installs from 0.7 or earlier use the old ID `ai.storyteller.solvecraft`: `flatpak update --user`
|
||||
offers to replace it with `ai.storyteller.solidcraft`, and settings move along.
|
||||
|
||||
Single-file `.flatpak` bundles include the runtime repository address; missing runtimes
|
||||
still need an internet connection. [Linux installation details](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
|
||||
|
||||
|
||||
@@ -29,6 +29,19 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
dependency-advisories:
|
||||
name: Dependency advisories
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install advisory scanner
|
||||
run: cargo install cargo-deny --version 0.20.2 --locked
|
||||
- name: Check locked dependencies
|
||||
run: cargo deny --locked check --warn unmaintained advisories bans sources
|
||||
|
||||
changes:
|
||||
name: Select checks
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -172,6 +185,34 @@ jobs:
|
||||
path: target/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
action-matrix:
|
||||
# Every ordinary action on every face, edge, vertex and body of the design corpus, against
|
||||
# tests/action_matrix/baseline.json (docs/action-matrix.md). Every test run checks a sample;
|
||||
# the whole matrix runs nightly (or by hand), a quarter per job.
|
||||
name: Action matrix (${{ matrix.part }}/4)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
part: [1, 2, 3, 4]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
- name: Run a quarter of the matrix
|
||||
env:
|
||||
PART: ${{ matrix.part }}
|
||||
run: |
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --run-ignored only -E "binary(action_matrix) & test(=action_matrix_full_$PART)"
|
||||
|
||||
doc-tests:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
@@ -315,4 +356,4 @@ jobs:
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts"
|
||||
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
|
||||
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
|
||||
@@ -53,9 +53,13 @@ jobs:
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/solidcraft.wxs packaging/windows/solidcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
desktop-file-validate packaging/linux/ai.storyteller.solvecraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solvecraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solidcraft'"
|
||||
desktop-file-validate packaging/linux/ai.storyteller.solidcraft.desktop
|
||||
# The window's X11 class is its app id; the old id stays only as the rename shims.
|
||||
grep -qx 'StartupWMClass=ai.storyteller.solidcraft' packaging/linux/ai.storyteller.solidcraft.desktop
|
||||
grep -q '<id>ai.storyteller.solvecraft</id>' packaging/linux/ai.storyteller.solidcraft.metainfo.xml.in
|
||||
grep -qF -e '--end-of-life-rebase=' packaging/flatpak-site.sh
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solidcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solidcraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solidcraft' and m['command'] == 'solidcraft'"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/snap/snapcraft.yaml')); assert m['name'] == 'solidcraft' and m['confinement'] == 'strict' and m['apps']['solidcraft']['command'] == 'usr/bin/solidcraft'"
|
||||
python3 -m py_compile packaging/linux/snap-smoke.py
|
||||
+1
-1
@@ -94,7 +94,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
|
||||
| Directory | Source | Licence | Changes |
|
||||
|---|---|---|---|
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, subdivision and provisional fixed-grid previews (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
@@ -100,6 +100,8 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
not.) Permissive
|
||||
dependencies are fine. Note which one you're adding and why in the PR, and run
|
||||
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
|
||||
Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see
|
||||
[the local command and coverage limits](docs/book/src/dev/testing.md#dependency-advisories).
|
||||
3. **Every asset is original or openly licensed and has a row in
|
||||
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
|
||||
`cargo xtask assets` enforces this.
|
||||
@@ -166,6 +168,11 @@ Pull requests skip a few heavy tests (one to four minutes each), which run on ev
|
||||
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`
|
||||
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
|
||||
|
||||
**The action matrix** tries every ordinary action on every face, edge, vertex and body of a
|
||||
corpus of designs against a checked-in ratchet ([docs/action-matrix.md](docs/action-matrix.md)).
|
||||
A pull request that makes an action pass should record it with `cargo xtask action-matrix --bless`;
|
||||
one that makes a passing action fail fails the sample every CI run checks.
|
||||
|
||||
**The reference tests** are 76 parts in [`tests/oracle/`](tests/oracle/), each a `recipe.json`
|
||||
in our own format with an `expected.json` beside it. SolidCraft rebuilds the recipe and compares
|
||||
body count, volume, area, centre of mass and bounding box (and, where there is a `reference.stl`,
|
||||
|
||||
Generated
+4
-12
@@ -2765,15 +2765,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
@@ -3297,7 +3288,7 @@ dependencies = [
|
||||
"embed-resource",
|
||||
"hidapi",
|
||||
"log",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -3358,7 +3349,7 @@ dependencies = [
|
||||
name = "solidcraft-io"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -3375,6 +3366,7 @@ version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-geom",
|
||||
"thiserror 2.0.21",
|
||||
"truck-meshalgo",
|
||||
@@ -4181,7 +4173,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.41.0",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ truck-topology = "0.6"
|
||||
|
||||
# packages (3MF)
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.38"
|
||||
quick-xml = "0.41"
|
||||
|
||||
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
|
||||
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
|
||||
|
||||
@@ -112,7 +112,8 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
separately from through-hole outlines in the flat preview and DXF.
|
||||
- **Files**: design containers (`.solidcraft`) with lossless binary mesh payloads;
|
||||
plain JSON remains supported for opening, Save As and export. Desktop Open can show a saved
|
||||
experimental preview (explicit `preview: true` engine saves) while checking geometry before editing. Export to STEP (AP242 with body names, colours and
|
||||
provisional picture from warm geometry caches while rebuilding before editing; caches never
|
||||
replace the parametric design. Export to STEP (AP242 with body names, colours and
|
||||
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
|
||||
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ;
|
||||
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
|
||||
|
||||
+41
-7
@@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature | in progress | 36 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular) chosen by the rule | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
@@ -61,6 +61,20 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Dimensioning and error reporting
|
||||
|
||||
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
|
||||
`batch`: body changes and created sketch IDs stay, while repeated lists are omitted.
|
||||
Both tools retain full replies by default.
|
||||
|
||||
- Sketch Create groups drawing variants under Line, Rectangle, Circle, Arc, Slot,
|
||||
Polygon and Spline. Each open part remembers its last interactive choice per family
|
||||
across sketch sessions and document-tab switches; closing the part resets these UI choices.
|
||||
- Inscribed, circumscribed and edge polygon tools preview their actual outline before
|
||||
placement, including orientation and the selected side count.
|
||||
- Construction (X) preserves the active sketch drawing tool, picked anchors and inline
|
||||
dimension values, so line type can change while a shape is in progress.
|
||||
- Planar sketch construction previews and hover feedback keep the same dashed pattern as
|
||||
committed geometry; centerlines retain their dash-dot pattern.
|
||||
|
||||
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
|
||||
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
|
||||
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
|
||||
@@ -147,6 +161,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## View navigation
|
||||
|
||||
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
|
||||
its layout stable during a drag and applies the selected scale on release.
|
||||
|
||||
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
|
||||
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
|
||||
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
|
||||
@@ -156,6 +173,11 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Scheduled and manual CI scan locked dependency advisories, bans and sources with
|
||||
cargo-deny. Vulnerabilities fail the scan; unmaintained crates warn. Local vendored
|
||||
patches require separate upstream review because advisory databases cannot version-match
|
||||
their path dependencies. Pull requests retain the existing licence checks.
|
||||
|
||||
- Associative shell-face sketches require a unique certified planar source. Nested shell
|
||||
attachments and ambiguous coincident sources remain unsupported.
|
||||
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
|
||||
@@ -323,6 +345,10 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
tapered or bend-crossing machining refuses atomically instead of restoring material later.
|
||||
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
|
||||
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
|
||||
A flange on another flange's far edge (a return) follows that flange's closed end and keeps
|
||||
clear of the flange beside it, and the returns of one feature are mitred toward each other
|
||||
with the rule's gap where their flanges meet at a corner; returns of separate features, and
|
||||
flanges on returns, are not closed toward each other.
|
||||
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
|
||||
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
|
||||
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
|
||||
@@ -356,13 +382,21 @@ and reference-object placement remain separate work, as do loft rails and center
|
||||
- Design containers (#303, phase one) separate and deduplicate STEP, raw images and
|
||||
lossless binary mesh arrays while keeping plain JSON as a supported Save As/export
|
||||
format. Generated-mesh storage measurements reduce serialization and decoding work;
|
||||
evaluated geometry caching and instant cold display remain separate follow-up work.
|
||||
a seekable reader defers original blobs until after provisional display.
|
||||
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
|
||||
- Desktop Open can show an experimental, opt-in bounded saved display preview while rebuilding in the existing
|
||||
file worker. Ordinary saves omit display caches; explicit `preview: true` saves cache
|
||||
only already warm meshes. Document hashes and application versions reject
|
||||
stale previews. Preview geometry cannot be edited or measured, and cancelled or stale
|
||||
completions cannot replace a newer document. Evaluated B-rep restoration remains open.
|
||||
- Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology
|
||||
and geometry records, with a separate truck adapter and bounded binary reader. Exact
|
||||
face-source certificates and operation history survive codec round trips. A provisional
|
||||
picture can be generated before original blobs are read; editing and measurement wait
|
||||
for authoritative reconstruction. Cached metadata binds preview and reconstruction to
|
||||
the same document despite concurrent file writes. Default saves omit this cache and never force cold
|
||||
evaluation or meshing; explicit `preview: true` remains a separate warm-mesh option.
|
||||
Keys, resource budgets and document/revision guards reject incompatible, hostile or
|
||||
stale results. A second modelling kernel still requires its own tested adapter.
|
||||
- Warm saves with explicit `cache: true` can also carry a bounded PNG thumbnail from already cached meshes.
|
||||
Home reads it without rebuilding the design when its local picture is missing;
|
||||
mismatched or malformed thumbnails fall back to normal generation. The image is
|
||||
kernel-independent and saving never forces cold meshing to produce it.
|
||||
|
||||
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
|
||||
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# Security policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
SolidCraft is young and moves quickly, so only the latest release gets security fixes. Please
|
||||
update to the [latest release](https://github.com/bherbruck/solidcraft/releases/latest) before
|
||||
reporting, and check whether the problem is still on `main`.
|
||||
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| Latest release | ✅ |
|
||||
| Nightly builds | ✅ (fixes land here first) |
|
||||
| Older releases | ❌ |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please **don't open a public issue** for a security problem. Report it privately through GitHub:
|
||||
open the repository's [Security tab](https://github.com/bherbruck/solidcraft/security) and choose
|
||||
**Report a vulnerability**
|
||||
([direct link](https://github.com/bherbruck/solidcraft/security/advisories/new)).
|
||||
|
||||
Please include what you found, how to reproduce it (a design file, command or MCP request), the
|
||||
version and platform, and the impact you expect. I'll acknowledge the report as soon as I can,
|
||||
keep you updated in the advisory, and credit you when the fix is published unless you'd rather
|
||||
stay anonymous.
|
||||
|
||||
Areas where reports are especially useful:
|
||||
|
||||
- opening untrusted design, STEP, STL or 3MF files (crashes, excessive memory or CPU, path tricks)
|
||||
- the MCP server and the control API (file access outside the allowed folders, command injection)
|
||||
- the web app (anything reaching beyond the page's own storage)
|
||||
- release packages and the update paths (AppImage, Flatpak, MSI, deb, rpm, snap)
|
||||
@@ -1,6 +1,6 @@
|
||||
Third-party software in SolidCraft
|
||||
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 454 crates below.
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 453 crates below.
|
||||
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
|
||||
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
|
||||
|
||||
@@ -262,7 +262,6 @@ proc-macro2 1.0.107 (MIT OR Apache-2.0)
|
||||
profiling 1.0.18 (MIT OR Apache-2.0)
|
||||
pxfm 0.1.30 (BSD-3-Clause OR Apache-2.0)
|
||||
quick-error 2.0.1 (MIT/Apache-2.0)
|
||||
quick-xml 0.38.4 (MIT)
|
||||
quick-xml 0.41.0 (MIT)
|
||||
quote 1.0.47 (MIT OR Apache-2.0)
|
||||
r-efi 5.3.0 (MIT OR Apache-2.0 OR LGPL-2.1-or-later)
|
||||
@@ -8709,7 +8708,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
quick-xml 0.38.4, quick-xml 0.41.0
|
||||
quick-xml 0.41.0
|
||||
================================================================================
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -16,11 +16,13 @@ hostile variants of it never panic.
|
||||
| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) |
|
||||
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
|
||||
| `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) |
|
||||
| `move-bodies` | 00baf1d | `solid.move` before it could copy: two bodies, one translated in place, one translated and turned 90 deg about Z (no `copy` key anywhere in the file) |
|
||||
| `split-body-pieces` | v0.3.2 (07e263d) | a slot along X cuts a block into two bodies; a fillet, a chamfer and an appearance refer to the pieces by body name (`Body1`, `Body1 (1)`) |
|
||||
| `issue-489-stacked-join` | v0.6.0 (809ecf7), desktop | a 70 × 84 extrude, then a 70 × 62 strip joined on its top face, sketched partly over the face's projected edges (issue #489; expected values from main at 2880525) |
|
||||
| `issue-616-lid-ribs` | v0.7.0 (a9879fa), CLI | a rounded lid with a skirt ring, a window and a slot cut through, then four ribs joined on the plate over the skirt, flush with the window's sides; v0.7.0 displaced the ribs and left a slit through the skirt and plate (issue #616; script `examples/regressions/lid_ribs_flush_with_window.json`, expected values from the fix) |
|
||||
|
||||
The scripts that made them are in `scripts/` (`issue-489-stacked-join` was drawn in the desktop
|
||||
app and has none). Every flat face of every final body must also take an attached sketch and an
|
||||
app and has none; `issue-616-lid-ribs` is made by its regression script). Every flat face of every final body must also take an attached sketch and an
|
||||
offset plane; the faces that can't yet are listed with their issues in `design_corpus.rs`.
|
||||
|
||||
Never regenerate a file with a newer build: the point is that it was written by the old one. Add new files for new eras instead.
|
||||
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 8430.29544246263,
|
||||
"bbox": {
|
||||
"max": [
|
||||
176.8000000000002,
|
||||
89.30000000000001,
|
||||
14.100000000000001
|
||||
],
|
||||
"min": [
|
||||
99.99999999999991,
|
||||
-1.820765760385257e-14,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
138.3999999716193,
|
||||
51.04392591016203,
|
||||
2.0990634391586322
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 156,
|
||||
"face_types": {
|
||||
"cylinder": 4,
|
||||
"plane": 50
|
||||
},
|
||||
"faces": 54,
|
||||
"kernel": {
|
||||
"edges": 156,
|
||||
"faces": 54,
|
||||
"shells": 1,
|
||||
"vertices": 104
|
||||
},
|
||||
"mass_g": 9.101726874183207,
|
||||
"material": "Default",
|
||||
"name": "Lid",
|
||||
"vertices": 104,
|
||||
"volume_mm3": 7055.602228048997
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 8,
|
||||
"dof": 8,
|
||||
"id": 3,
|
||||
"name": "Skirt",
|
||||
"profiles": 2,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 1.178957,
|
||||
"name": "Box1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 83.24342399999999,
|
||||
"name": "Fillet1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "FilletFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.150889,
|
||||
"name": "Skirt",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 94.465481,
|
||||
"name": "Extrude1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 50.426736,
|
||||
"name": "Box2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 40.383005,
|
||||
"name": "Box3",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 7,
|
||||
"ms": 59.041461000000005,
|
||||
"name": "Box4",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [
|
||||
{
|
||||
"face_shift_mm": 0.0,
|
||||
"rotation_rad": 0.0,
|
||||
"strategy": "planar_fallback",
|
||||
"tolerance_mm": 0.0,
|
||||
"translation_mm": 0.0
|
||||
}
|
||||
],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 8,
|
||||
"ms": 562.5477970000001,
|
||||
"name": "Rib1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": "Boolean retry: exact planar fallback (no displacement)"
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 9,
|
||||
"ms": 89.56076,
|
||||
"name": "Rib2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 10,
|
||||
"ms": 65.330634,
|
||||
"name": "Rib3",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 11,
|
||||
"ms": 97.229403,
|
||||
"name": "Rib4",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 8430.29544246263,
|
||||
"edges": 156,
|
||||
"faces": 54,
|
||||
"mass_g": 9.101726874183207,
|
||||
"vertices": 104,
|
||||
"volume_mm3": 7055.602228048997
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"body_names": [
|
||||
"Lid"
|
||||
],
|
||||
"corner": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"height": "2.4",
|
||||
"id": 1,
|
||||
"length": "76.8",
|
||||
"name": "Box1",
|
||||
"operation": "new_body",
|
||||
"param_names": [
|
||||
"d1",
|
||||
"d2",
|
||||
"d3"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "89.3"
|
||||
},
|
||||
{
|
||||
"edge_names": [
|
||||
"F1:-x|F1:-y",
|
||||
"F1:+x|F1:-y",
|
||||
"F1:+y|F1:-x",
|
||||
"F1:+x|F1:+y"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
-1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
[
|
||||
100.0,
|
||||
0.0,
|
||||
1.2
|
||||
],
|
||||
[
|
||||
176.8,
|
||||
0.0,
|
||||
1.2
|
||||
],
|
||||
[
|
||||
100.0,
|
||||
89.3,
|
||||
1.2
|
||||
],
|
||||
[
|
||||
176.8,
|
||||
89.3,
|
||||
1.2
|
||||
]
|
||||
],
|
||||
"id": 2,
|
||||
"name": "Fillet1",
|
||||
"param_names": [
|
||||
"d4"
|
||||
],
|
||||
"radius": "3",
|
||||
"suppressed": false,
|
||||
"type": "fillet"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Skirt",
|
||||
"param_names": [
|
||||
"d5"
|
||||
],
|
||||
"plane": {
|
||||
"base": {
|
||||
"name": "XY",
|
||||
"type": "origin"
|
||||
},
|
||||
"distance": "2.4",
|
||||
"type": "offset"
|
||||
},
|
||||
"sketch": {
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"l": 0,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"l": 2,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"l": 1,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"l": 3,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"l": 4,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k6",
|
||||
"l": 6,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k7",
|
||||
"l": 5,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k8",
|
||||
"l": 7,
|
||||
"type": "vertical"
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 8,
|
||||
"l": 8,
|
||||
"p": 8
|
||||
},
|
||||
"curves": [
|
||||
{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false,
|
||||
"id": "l1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false,
|
||||
"id": "l2",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false,
|
||||
"id": "l3",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false,
|
||||
"id": "l4",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 5,
|
||||
"b": 6,
|
||||
"construction": false,
|
||||
"id": "l5",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 6,
|
||||
"b": 7,
|
||||
"construction": false,
|
||||
"id": "l6",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 7,
|
||||
"b": 8,
|
||||
"construction": false,
|
||||
"id": "l7",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 8,
|
||||
"b": 5,
|
||||
"construction": false,
|
||||
"id": "l8",
|
||||
"type": "line"
|
||||
}
|
||||
],
|
||||
"points": [
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
102.65,
|
||||
2.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
174.15,
|
||||
2.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
174.15,
|
||||
86.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
102.65,
|
||||
86.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p5",
|
||||
"pos": [
|
||||
104.15,
|
||||
4.15
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p6",
|
||||
"pos": [
|
||||
172.65,
|
||||
4.15
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p7",
|
||||
"pos": [
|
||||
172.65,
|
||||
85.15
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p8",
|
||||
"pos": [
|
||||
104.15,
|
||||
85.15
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"suppressed": false,
|
||||
"type": "sketch"
|
||||
},
|
||||
{
|
||||
"extent": {
|
||||
"direction": "positive",
|
||||
"distance": "3"
|
||||
},
|
||||
"id": 4,
|
||||
"name": "Extrude1",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d6"
|
||||
],
|
||||
"profiles": {
|
||||
"loops": [
|
||||
[
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
],
|
||||
"type": "curves"
|
||||
},
|
||||
"sketch": 3,
|
||||
"suppressed": false,
|
||||
"targets": [
|
||||
"Lid"
|
||||
],
|
||||
"type": "extrude"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
104.4,
|
||||
5.4,
|
||||
-1.0
|
||||
],
|
||||
"height": "8",
|
||||
"id": 5,
|
||||
"length": "68",
|
||||
"name": "Box2",
|
||||
"operation": "cut",
|
||||
"param_names": [
|
||||
"d7",
|
||||
"d8",
|
||||
"d9"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "61"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
116.4,
|
||||
72.0,
|
||||
-1.0
|
||||
],
|
||||
"height": "8",
|
||||
"id": 6,
|
||||
"length": "44",
|
||||
"name": "Box3",
|
||||
"operation": "cut",
|
||||
"param_names": [
|
||||
"d10",
|
||||
"d11",
|
||||
"d12"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "10"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
127.4,
|
||||
84.0,
|
||||
2.4
|
||||
],
|
||||
"height": "4",
|
||||
"id": 7,
|
||||
"length": "22",
|
||||
"name": "Box4",
|
||||
"operation": "cut",
|
||||
"param_names": [
|
||||
"d13",
|
||||
"d14",
|
||||
"d15"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
102.8,
|
||||
14.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 8,
|
||||
"length": "1.6",
|
||||
"name": "Rib1",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d16",
|
||||
"d17",
|
||||
"d18"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
102.8,
|
||||
48.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 9,
|
||||
"length": "1.6",
|
||||
"name": "Rib2",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d19",
|
||||
"d20",
|
||||
"d21"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
172.4,
|
||||
14.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 10,
|
||||
"length": "1.6",
|
||||
"name": "Rib3",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d22",
|
||||
"d23",
|
||||
"d24"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
172.4,
|
||||
48.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 11,
|
||||
"length": "1.6",
|
||||
"name": "Rib4",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d25",
|
||||
"d26",
|
||||
"d27"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
}
|
||||
],
|
||||
"format": "solidcraft/15",
|
||||
"name": "issue-616-lid-ribs",
|
||||
"next_id": 12,
|
||||
"params": [],
|
||||
"units": "mm"
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 2400.0,
|
||||
"bbox": {
|
||||
"max": [
|
||||
50.0,
|
||||
20.0,
|
||||
20.0
|
||||
],
|
||||
"min": [
|
||||
30.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
40.0,
|
||||
10.0,
|
||||
10.0
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 12,
|
||||
"face_types": {
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 6,
|
||||
"kernel": {
|
||||
"edges": 12,
|
||||
"faces": 6,
|
||||
"shells": 1,
|
||||
"vertices": 8
|
||||
},
|
||||
"mass_g": 10.32,
|
||||
"material": "Default",
|
||||
"name": "Block",
|
||||
"vertices": 8,
|
||||
"volume_mm3": 8000.0
|
||||
},
|
||||
{
|
||||
"area_mm2": 600.0,
|
||||
"bbox": {
|
||||
"max": [
|
||||
3.552713678800501e-15,
|
||||
90.0,
|
||||
10.0
|
||||
],
|
||||
"min": [
|
||||
-9.999999999999998,
|
||||
80.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
-4.999999999999999,
|
||||
85.0,
|
||||
5.000000000000001
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 12,
|
||||
"face_types": {
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 6,
|
||||
"kernel": {
|
||||
"edges": 12,
|
||||
"faces": 6,
|
||||
"shells": 1,
|
||||
"vertices": 8
|
||||
},
|
||||
"mass_g": 1.29,
|
||||
"material": "Default",
|
||||
"name": "Peg",
|
||||
"vertices": 8,
|
||||
"volume_mm3": 1000.0
|
||||
}
|
||||
],
|
||||
"body_count": 2,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 2,
|
||||
"id": 1,
|
||||
"name": "Base",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 4,
|
||||
"id": 3,
|
||||
"name": "Peg",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.204585,
|
||||
"name": "Base",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 2.318662,
|
||||
"name": "Extrude1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.109196,
|
||||
"name": "Peg",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 0.338859,
|
||||
"name": "Extrude2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 0.312259,
|
||||
"name": "Move1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "MoveFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 0.23144699999999999,
|
||||
"name": "Move2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "MoveFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 3000.0,
|
||||
"edges": 24,
|
||||
"faces": 12,
|
||||
"mass_g": 11.61,
|
||||
"vertices": 16,
|
||||
"volume_mm3": 9000.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"features":[{"id":1,"name":"Base","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"},{"id":"k5","p":0,"q":1,"type":"coincident"}],"counters":{"k":5,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[0.0,0.0]},{"fixed":false,"id":"p2","pos":[20.0,0.0]},{"fixed":false,"id":"p3","pos":[20.0,20.0]},{"fixed":false,"id":"p4","pos":[0.0,20.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Block"],"extent":{"direction":"positive","distance":"20"},"id":2,"name":"Extrude1","operation":"new_body","param_names":["d1"],"profiles":{"type":"all"},"sketch":1,"suppressed":false,"type":"extrude"},{"id":3,"name":"Peg","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"}],"counters":{"k":4,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[40.0,0.0]},{"fixed":false,"id":"p2","pos":[50.0,0.0]},{"fixed":false,"id":"p3","pos":[50.0,10.0]},{"fixed":false,"id":"p4","pos":[40.0,10.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Peg"],"extent":{"direction":"positive","distance":"10"},"id":4,"name":"Extrude2","operation":"new_body","param_names":["d2"],"profiles":{"type":"all"},"sketch":3,"suppressed":false,"type":"extrude"},{"bodies":["Block"],"id":5,"name":"Move1","param_names":["d3","d4","d5"],"suppressed":false,"translate":["30","0","0"],"type":"move"},{"angle":"90 deg","bodies":["Peg"],"id":6,"name":"Move2","param_names":["d6","d7","d8","d9"],"rotate_axis":[0.0,0.0,1.0],"suppressed":false,"translate":["0","40","0"],"type":"move"}],"format":"solidcraft/15","name":"old-move-bodies","next_id":7,"params":[],"units":"mm"}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"commands": [
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [20, 20]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 20, "body_name": "Block"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Peg"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [40, 0], "p1": [50, 10]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 10, "operation": "new", "body_name": "Peg"}},
|
||||
{"command": "solid.move", "params": {"bodies": ["Block"], "translate": [30, 0, 0]}},
|
||||
{"command": "solid.move", "params": {"bodies": ["Peg"], "translate": [0, 40, 0], "axis": [0, 0, 1], "angle": "90 deg"}}
|
||||
]
|
||||
}
|
||||
@@ -137,18 +137,13 @@ fn body_names_saved_before_exact_history_keep_their_pieces() {
|
||||
/// ignored test below that checks its faces; a listed plane whose face attaches fails the main
|
||||
/// check until it is removed from here.
|
||||
type Planes = &'static [([f64; 3], f64)];
|
||||
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 9] = [
|
||||
("issue-489-stacked-join", "Body1", Some(&[([1., 0., 0.], 35.), ([-1., 0., 0.], 35.)]), 491),
|
||||
("issue-489-stacked-join", "Body1", Some(&[([0., 0., 1.], 30.)]), 492),
|
||||
("m0-revolve-primitives", "Body2", Some(&[([-FRAC_1_SQRT_2, 0., FRAC_1_SQRT_2], -21. * FRAC_1_SQRT_2)]), 493),
|
||||
("split-body-pieces", "Body1", Some(&[([0., FRAC_1_SQRT_2, FRAC_1_SQRT_2], 38.5 * FRAC_1_SQRT_2), ([0., -1., 0.], -18.)]), 493),
|
||||
("split-body-pieces", "Body1 (1)", Some(&[([0., 1., 0.], 12.)]), 493),
|
||||
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 5] = [
|
||||
("m0-revolve-primitives", "Body1", None, 494),
|
||||
("naming-split-pieces", "Body1", None, 494),
|
||||
("modern-plastic", "Body1", None, 494),
|
||||
("modern-sheet-metal", "Body1", None, 495),
|
||||
("issue-616-lid-ribs", "Lid", None, 628),
|
||||
];
|
||||
use std::f64::consts::FRAC_1_SQRT_2;
|
||||
|
||||
/// The issue a face is listed under, and whether it is listed by its plane (not as every face).
|
||||
fn known_without_attachment(design: &str, body: &str, normal: [f64; 3], distance: f64) -> Option<(u32, bool)> {
|
||||
@@ -267,24 +262,6 @@ fn known_faces_attach(issue: u32) {
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #491 is fixed"]
|
||||
fn a_side_face_join_merged_from_two_extrudes_takes_an_attached_sketch() {
|
||||
known_faces_attach(491);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #492 is fixed"]
|
||||
fn a_face_split_into_pieces_by_a_later_feature_takes_an_attached_sketch() {
|
||||
known_faces_attach(492);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #493 is fixed"]
|
||||
fn cut_walls_and_chamfer_faces_take_an_attached_sketch() {
|
||||
known_faces_attach(493);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #494 is fixed"]
|
||||
fn faces_after_revolve_offset_face_and_boss_take_an_attached_sketch() {
|
||||
@@ -297,6 +274,12 @@ fn sheet_metal_thickness_faces_take_an_attached_sketch() {
|
||||
known_faces_attach(495);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #628 is fixed"]
|
||||
fn faces_of_a_lid_with_joined_ribs_take_an_attached_sketch() {
|
||||
known_faces_attach(628);
|
||||
}
|
||||
|
||||
/// A small deterministic generator (xorshift).
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
|
||||
@@ -110,7 +110,7 @@ mod web {
|
||||
let changed = s.is_dirty() && self.autosaved != Some(s.revision);
|
||||
let wait = AUTOSAVE_S - (now - self.autosaved_at);
|
||||
if changed && wait <= 0.0 {
|
||||
files::autosave(&self.files, Some((&s.doc, s.path.as_deref())));
|
||||
files::autosave(&self.files, Some((&s.design(), s.path.as_deref())));
|
||||
self.autosaved = Some(s.revision);
|
||||
self.autosaved_at = now;
|
||||
} else if changed {
|
||||
@@ -251,7 +251,10 @@ mod web {
|
||||
},
|
||||
"download_open" => {
|
||||
let d = &self.app.session.doc;
|
||||
let result = solidcraft_engine::io::write_design_container(d)
|
||||
let result = self
|
||||
.app
|
||||
.session
|
||||
.design_container(solidcraft_engine::io::DesignCompression::Fast, false, false)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|bytes| files::download(&format!("{}.{}", d.name, solidcraft_engine::io::DESIGN_EXT), &bytes));
|
||||
if let Err(e) = result {
|
||||
|
||||
@@ -141,17 +141,28 @@ fn graphics_path() -> Option<std::path::PathBuf> {
|
||||
/// Compatibility shim (docs/compatibility.md): on the first launch after the rename, copy the
|
||||
/// settings of earlier releases into the new folder. They kept `graphics.json` (and, on Linux
|
||||
/// and macOS, the recovery folder) under the old product name, and eframe's `app.ron` under the
|
||||
/// window's app id. The old folders are never changed.
|
||||
/// window's old app id. In the Flatpak sandbox, releases before 0.8 kept everything under the
|
||||
/// old app id's `~/.var/app` folder; that copy comes first, as it is the newest. The old folders
|
||||
/// are never changed.
|
||||
fn adopt_legacy_settings() {
|
||||
let (Some(current), Some(legacy)) = (settings_dir(), eframe::storage_dir("SolveCraft")) else { return };
|
||||
let legacy_eframe = eframe::storage_dir(APP_ID);
|
||||
if let Err(e) = adopt_settings(¤t, &legacy, legacy_eframe.as_deref()) {
|
||||
let legacy_eframe = eframe::storage_dir(solidcraft_engine::io::LEGACY_APP_ID);
|
||||
let old_flatpak = solidcraft_engine::io::legacy_flatpak_path(¤t);
|
||||
if let Err(e) = adopt_settings(¤t, old_flatpak.as_deref(), &legacy, legacy_eframe.as_deref()) {
|
||||
log::warn!("settings: could not copy the previous release's settings into {}: {e}", current.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn adopt_settings(current: &std::path::Path, legacy: &std::path::Path, legacy_eframe: Option<&std::path::Path>) -> std::io::Result<()> {
|
||||
fn adopt_settings(
|
||||
current: &std::path::Path,
|
||||
old_flatpak: Option<&std::path::Path>,
|
||||
legacy: &std::path::Path,
|
||||
legacy_eframe: Option<&std::path::Path>,
|
||||
) -> std::io::Result<()> {
|
||||
let fresh = !current.exists();
|
||||
if let Some(old) = old_flatpak {
|
||||
solidcraft_engine::io::adopt_legacy_dir(current, old)?;
|
||||
}
|
||||
solidcraft_engine::io::adopt_legacy_dir(current, legacy)?;
|
||||
if let Some(old) = legacy_eframe.map(|d| d.join("app.ron")).filter(|p| fresh && p.is_file()) {
|
||||
std::fs::create_dir_all(current)?;
|
||||
@@ -203,9 +214,8 @@ fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
|
||||
.with_app_id(APP_ID))
|
||||
}
|
||||
|
||||
/// The window's app id: it names the desktop entry, so it stays the packages' reverse-DNS id,
|
||||
/// which is kept from before the rename (compatibility shim, docs/compatibility.md).
|
||||
const APP_ID: &str = "ai.storyteller.solvecraft";
|
||||
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
|
||||
const APP_ID: &str = solidcraft_engine::io::APP_ID;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
adopt_legacy_settings();
|
||||
@@ -408,14 +418,14 @@ mod msaa_tests {
|
||||
std::fs::create_dir_all(&legacy_eframe).unwrap();
|
||||
std::fs::write(legacy.join("graphics.json"), r#"{"msaa":8}"#).unwrap();
|
||||
std::fs::write(legacy_eframe.join("app.ron"), "(prefs)").unwrap();
|
||||
super::adopt_settings(¤t, &legacy, Some(&legacy_eframe)).unwrap();
|
||||
super::adopt_settings(¤t, None, &legacy, Some(&legacy_eframe)).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(current.join("graphics.json")).unwrap(), r#"{"msaa":8}"#);
|
||||
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(prefs)");
|
||||
assert!(legacy.join("graphics.json").is_file() && legacy_eframe.join("app.ron").is_file());
|
||||
// Later launches leave the current settings alone.
|
||||
std::fs::write(current.join("app.ron"), "(mine)").unwrap();
|
||||
std::fs::remove_file(current.join("graphics.json")).unwrap();
|
||||
super::adopt_settings(¤t, &legacy, Some(&legacy_eframe)).unwrap();
|
||||
super::adopt_settings(¤t, None, &legacy, Some(&legacy_eframe)).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(mine)");
|
||||
assert!(!current.join("graphics.json").exists());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
|
||||
@@ -147,7 +147,7 @@ impl Document {
|
||||
.get(name)
|
||||
.cloned()
|
||||
.or_else(|| self.appearances.components.get(&self.model_body_component(body)).cloned())
|
||||
.or_else(|| self.materials.get(name).and_then(|material| material_look(material)))
|
||||
.or_else(|| self.materials.get(name).and_then(|material| self.material_look(material)))
|
||||
}
|
||||
/// Effective body opacity, including paint carried from an imported body.
|
||||
pub fn model_body_opacity(&self, body: &crate::ModelBody) -> f64 {
|
||||
@@ -168,7 +168,7 @@ impl Document {
|
||||
let c = self.body_component(name, feature);
|
||||
(c != 0).then(|| self.appearances.components.get(&c).cloned()).flatten()
|
||||
})
|
||||
.or_else(|| self.materials.get(name).and_then(|m| material_look(m)))
|
||||
.or_else(|| self.materials.get(name).and_then(|m| self.material_look(m)))
|
||||
}
|
||||
|
||||
/// Face appearances on a body: (point on the face, look).
|
||||
|
||||
@@ -91,17 +91,14 @@ pub fn appearance_cell(value: &str) -> Result<Option<crate::appearance::Look>> {
|
||||
Ok(Some(look))
|
||||
}
|
||||
|
||||
/// Parse a physical material cell, returning None for the default material.
|
||||
pub fn material_cell(value: &str) -> Result<Option<String>> {
|
||||
let name = crate::MATERIALS
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(value.trim()))
|
||||
.map(|(n, _)| *n)
|
||||
.ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
|
||||
Ok((name != "Default").then(|| name.to_string()))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Parse a physical material cell (a built-in material or one defined in this design),
|
||||
/// returning None for the default material.
|
||||
pub fn material_cell(&self, value: &str) -> Result<Option<String>> {
|
||||
let name = self.material_name(value).ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
|
||||
Ok((name != "Default").then_some(name))
|
||||
}
|
||||
|
||||
/// Freeze design-unit-sensitive configuration literals when changing display units.
|
||||
/// Explicit units and nonliteral expressions retain their source text.
|
||||
pub fn preserve_configuration_units(&mut self, previous_unit: &str, kept_parameters: &[String]) {
|
||||
@@ -199,7 +196,7 @@ impl Document {
|
||||
let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?;
|
||||
f.suppressed = on;
|
||||
}
|
||||
Column::Material(body) => match material_cell(v)? {
|
||||
Column::Material(body) => match self.material_cell(v)? {
|
||||
Some(material) => {
|
||||
next.materials.insert(body.clone(), material);
|
||||
}
|
||||
@@ -274,7 +271,7 @@ impl Document {
|
||||
self.config_current(c)
|
||||
.is_some_and(|current| current.chars().filter(|c| !c.is_whitespace()).eq(expected.chars().filter(|c| !c.is_whitespace())))
|
||||
}
|
||||
Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
|
||||
Column::Material(body) => self.material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
|
||||
Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()),
|
||||
Column::ComponentAppearance(id) => appearance_cell(v).is_ok_and(|look| self.appearances.components.get(id) == look.as_ref()),
|
||||
Column::FaceAppearance(target) => appearance_cell(v).is_ok_and(|look| self.config_face_look(target) == look),
|
||||
|
||||
@@ -179,6 +179,13 @@ pub enum FaceCertificate {
|
||||
source: Box<FaceCertificate>,
|
||||
inner: bool,
|
||||
},
|
||||
/// A flat face known by its exact history name (the target's `name`, without piece
|
||||
/// numbers): what the kernel's face history says it came from, followed through every
|
||||
/// feature since (a cut wall `F5:side:l1`, a chamfer `F7:blend:…`, a pattern copy
|
||||
/// `F4:[email protected]`, a face a join merged from two features). Used where no construction
|
||||
/// certificate above names the face uniquely. Its plane is the face's exact current plane in
|
||||
/// the frame [`lineage_frame`] gives it. See docs/face-attachments.md.
|
||||
Lineage,
|
||||
}
|
||||
|
||||
impl FaceCertificate {
|
||||
@@ -210,8 +217,9 @@ impl FaceCertificate {
|
||||
&& payload_sha256.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}
|
||||
Self::ShellPlanar { source_producer, source, .. } => {
|
||||
*source_producer != 0 && !matches!(source.as_ref(), Self::ShellPlanar { .. }) && source.validate().is_ok()
|
||||
*source_producer != 0 && !matches!(source.as_ref(), Self::ShellPlanar { .. } | Self::Lineage) && source.validate().is_ok()
|
||||
}
|
||||
Self::Lineage => true,
|
||||
};
|
||||
if valid { Ok(()) } else { Err(DocError::Invalid("invalid face source certificate".into())) }
|
||||
}
|
||||
@@ -258,13 +266,16 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()>
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
|
||||
}
|
||||
Some("lineage") => fields.len() == 1,
|
||||
Some("shell_planar") => {
|
||||
fields.len() == 4
|
||||
&& fields.keys().all(|key| ["kind", "source_producer", "source", "inner"].contains(&key.as_str()))
|
||||
&& nonzero("source_producer")
|
||||
&& fields.get("inner").and_then(serde_json::Value::as_bool).is_some()
|
||||
&& fields.get("source").is_some_and(|source| {
|
||||
source.as_object().is_some_and(|source| source.get("kind").and_then(serde_json::Value::as_str) != Some("shell_planar"))
|
||||
source
|
||||
.as_object()
|
||||
.is_some_and(|source| !matches!(source.get("kind").and_then(serde_json::Value::as_str), Some("shell_planar" | "lineage")))
|
||||
&& validate_certificate_json(source).is_ok()
|
||||
})
|
||||
}
|
||||
@@ -286,6 +297,7 @@ pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> R
|
||||
FaceCertificate::ExtrudeCap { high, outer, holes, .. } => format!("cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes)),
|
||||
FaceCertificate::Imported { payload_sha256, item, face } => format!("import:{payload_sha256}:{item}:{face}"),
|
||||
FaceCertificate::ShellPlanar { .. } => return Err(DocError::Invalid("nested shell source certificates are unsupported".into())),
|
||||
FaceCertificate::Lineage => return Err(DocError::Invalid("a shell's source needs a construction certificate".into())),
|
||||
};
|
||||
Ok(format!("shell:{}:{role}", if inner { "inner" } else { "outer" }))
|
||||
}
|
||||
@@ -551,6 +563,153 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame a [`FaceCertificate::Lineage`] face's plane is given, from the plane alone (never
|
||||
/// from how a kernel parametrizes the surface, so it survives rebuilds and kernel changes): its
|
||||
/// origin where the body's definition origin projects onto the plane, its x axis the first of
|
||||
/// the definition's X, Y and Z axes that is not steep to the plane (`|cos| < 0.6`; one always
|
||||
/// is), projected onto it. A face that moves along its normal keeps the frame where it was;
|
||||
/// one that moves within its plane (a Move feature) keeps it too, as a sketch on a face does
|
||||
/// in the definition's coordinates.
|
||||
pub fn lineage_frame(body: &ModelBody, plane: &solidcraft_geom::Plane) -> Option<solidcraft_geom::Plane> {
|
||||
use solidcraft_geom::Vec3;
|
||||
let n = plane.normal();
|
||||
let m = &body.source_transform;
|
||||
let origin = crate::apply_point(m, Vec3::ZERO);
|
||||
let axis = [Vec3::X, Vec3::Y, Vec3::Z].into_iter().filter_map(|a| crate::apply_vector(m, a).normalized()).find(|a| a.dot(n).abs() < 0.6)?;
|
||||
let x = axis - n * axis.dot(n);
|
||||
let origin = origin - n * (origin - plane.origin).dot(n);
|
||||
solidcraft_geom::Plane::new(origin, x, n.cross(x))
|
||||
}
|
||||
|
||||
/// Whether two exact planes are one oriented plane (pieces of one face).
|
||||
fn same_oriented_plane(a: &solidcraft_geom::Plane, b: &solidcraft_geom::Plane, scale: f64) -> bool {
|
||||
a.normal().dot(b.normal()) >= 1. - 1e-8 && (b.origin - a.origin).dot(a.normal()).abs() <= 1e-7 * (1. + scale)
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The exact plane an anchor on face `index` of `body` is measured in: the construction
|
||||
/// certificate's plane, or for a [`FaceCertificate::Lineage`] face its current exact plane in
|
||||
/// the [`lineage_frame`].
|
||||
pub fn anchor_face_plane(&self, body: &ModelBody, index: usize, certificate: Option<&FaceCertificate>) -> Option<solidcraft_geom::Plane> {
|
||||
match certificate {
|
||||
Some(FaceCertificate::Lineage) => body.body.planar_face(index).and_then(|plane| lineage_frame(body, &plane)),
|
||||
_ => body.body.face_plane(index),
|
||||
}
|
||||
}
|
||||
|
||||
/// A target for flat face `index` of `body` by its exact history name
|
||||
/// ([`FaceCertificate::Lineage`]), when that name follows exact history, names a feature of
|
||||
/// the design and finds this face (with its other pieces, all on one oriented plane).
|
||||
pub(crate) fn lineage_face_target(
|
||||
&self,
|
||||
body: &ModelBody,
|
||||
index: usize,
|
||||
point: solidcraft_geom::Vec3,
|
||||
) -> Option<crate::config::ConfigFaceTarget> {
|
||||
let names = crate::naming::names(body);
|
||||
if !names.verified(index) {
|
||||
return None;
|
||||
}
|
||||
let name = crate::naming::strip_piece(names.faces.get(index)?).to_string();
|
||||
let origin = name.strip_prefix('F')?.split_once(':')?.0.parse::<u64>().ok()?;
|
||||
let inverse = crate::mat_inverse(&body.source_transform)?;
|
||||
let target = crate::config::ConfigFaceTarget {
|
||||
body: body.definition_name().into(),
|
||||
origin_feature: origin,
|
||||
name,
|
||||
point: crate::apply_point(&inverse, point),
|
||||
certificate: Some(FaceCertificate::Lineage),
|
||||
};
|
||||
self.configuration_face_indices(body, &target).ok()?.contains(&index).then_some(target)
|
||||
}
|
||||
|
||||
/// The faces of `body` an exact history name finds: those named `name` or a piece of it, else
|
||||
/// (a later join merged it into another face) the face that answers to it. Every one must
|
||||
/// follow exact history and lie on one oriented plane.
|
||||
pub(crate) fn lineage_face_indices(&self, body: &ModelBody, name: &str) -> Result<Vec<usize>> {
|
||||
let names = crate::naming::names(body);
|
||||
let mut indices: Vec<usize> =
|
||||
names.faces.iter().enumerate().filter(|(_, face)| crate::naming::strip_piece(face) == name).map(|(index, _)| index).collect();
|
||||
if indices.is_empty() {
|
||||
indices =
|
||||
names.info.iter().enumerate().filter(|(_, info)| info.aliases.iter().any(|alias| alias == name)).map(|(index, _)| index).collect();
|
||||
}
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown(format!("face `{name}` on `{}` no longer exists", body.name)));
|
||||
}
|
||||
self.coplanar_pieces(body, &indices, |index| names.verified(index))?;
|
||||
Ok(indices)
|
||||
}
|
||||
|
||||
/// Check that `indices` are pieces of one flat face: each `exact` and planar, all on one
|
||||
/// oriented plane, and not split on purpose (Split Face: its pieces are separate faces, so
|
||||
/// a pick on one never stands for the whole; docs/face-attachments.md).
|
||||
pub(crate) fn coplanar_pieces(&self, body: &ModelBody, indices: &[usize], exact: impl Fn(usize) -> bool) -> Result<()> {
|
||||
let scale = body.body.size();
|
||||
let mut first = None;
|
||||
for index in indices {
|
||||
if !exact(*index) {
|
||||
return Err(DocError::Invalid("this face's history is not exact".into()));
|
||||
}
|
||||
let plane = body.body.planar_face(*index).ok_or_else(|| DocError::Invalid("attached face is not flat".into()))?;
|
||||
match &first {
|
||||
None => first = Some(plane),
|
||||
Some(first) if same_oriented_plane(first, &plane, scale) => {}
|
||||
Some(_) => return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into())),
|
||||
}
|
||||
}
|
||||
if let (Some(plane), true) = (first, indices.len() > 1) {
|
||||
let tolerance = 1e-6 * (1. + scale);
|
||||
let on_plane = |line: &Vec<solidcraft_geom::Vec3>| {
|
||||
!line.is_empty() && line.iter().all(|p| (*p - plane.origin).dot(plane.normal()).abs() <= tolerance)
|
||||
};
|
||||
if body.body.split_lines().is_some_and(|lines| lines.iter().any(on_plane)) {
|
||||
return Err(DocError::Invalid(
|
||||
"this face was split on purpose (Split Face): pick an exact face piece's own plane with fixed:true".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Of several faces carrying one construction certificate of feature `origin`, the pieces of
|
||||
/// that feature's own face: copies a pattern or mirror made carry their seed's certificate
|
||||
/// but are named as copies (`…@F<pattern>.<instance>`, docs/face-attachments.md), and pieces
|
||||
/// a later feature split the face into share its exact name and plane.
|
||||
pub(crate) fn certified_pieces(&self, body: &ModelBody, indices: Vec<usize>, origin: u64) -> Result<Vec<usize>> {
|
||||
if indices.len() < 2 {
|
||||
return Ok(indices);
|
||||
}
|
||||
let names = crate::naming::names(body);
|
||||
let own = format!("F{origin}:");
|
||||
let stem = |index: usize| names.faces.get(index).map(|name| crate::naming::strip_piece(name));
|
||||
let seed: Vec<usize> =
|
||||
indices.into_iter().filter(|index| stem(*index).is_some_and(|name| name.starts_with(&own) && !name.contains('@'))).collect();
|
||||
let ambiguous = || DocError::Unknown("attached planar face certificate is missing or ambiguous".into());
|
||||
let first = seed.first().and_then(|index| stem(*index)).ok_or_else(ambiguous)?;
|
||||
if seed.iter().any(|index| stem(*index) != Some(first)) {
|
||||
return Err(ambiguous());
|
||||
}
|
||||
// The certificate is exact; the names only tell the seed from its copies here.
|
||||
self.coplanar_pieces(body, &seed, |_| true).map_err(|_| ambiguous())?;
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
/// Of several faces carrying one certificate of feature `origin`, those that are not a
|
||||
/// pattern's or mirror's copies (named `…@F<pattern>.<instance>`), when any is not.
|
||||
pub(crate) fn without_copies(&self, body: &ModelBody, indices: Vec<usize>) -> Vec<usize> {
|
||||
if indices.len() < 2 {
|
||||
return indices;
|
||||
}
|
||||
let names = crate::naming::names(body);
|
||||
let copy = |index: &usize| names.faces.get(*index).is_some_and(|name| crate::naming::strip_piece(name).contains('@'));
|
||||
if indices.iter().all(copy) {
|
||||
return indices;
|
||||
}
|
||||
indices.into_iter().filter(|index| !copy(index)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn payload_digest(payload: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(payload.as_bytes()))
|
||||
|
||||
@@ -253,6 +253,7 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
sheets: vec![],
|
||||
})));
|
||||
let captured = crate::naming::face_name_at_body(&old_body, Vec3::new(20., 6., 1.)).unwrap();
|
||||
assert_eq!(crate::naming::parent_piece(&captured), Some(stem.as_str()), "actual strip capture records a piece name");
|
||||
@@ -376,6 +377,7 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
sheets: vec![],
|
||||
})));
|
||||
let t = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let at = Vec3::new(34. + 6. * t, 24. + 6. * t, 1.);
|
||||
@@ -515,6 +517,7 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
sheets: vec![],
|
||||
})));
|
||||
let at = Vec3::new(0., 15., 1.);
|
||||
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
|
||||
|
||||
@@ -145,6 +145,7 @@ impl Document {
|
||||
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Circle { .. } | solidcraft_sketch::CurveKind::Arc { .. }))))
|
||||
}
|
||||
(super::FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => role.strip_prefix("face:").is_some_and(|index| index.parse::<usize>().is_ok()),
|
||||
(super::FaceCertificate::Lineage, _) => !role.is_empty(),
|
||||
(super::FaceCertificate::ShellPlanar { source_producer, source, inner }, FeatureKind::Shell { .. }) => {
|
||||
*source_producer != target.origin_feature
|
||||
&& self.flat_certificate_producer_matches(*source_producer, source)
|
||||
@@ -185,6 +186,9 @@ impl Document {
|
||||
if body.body.is_mesh() || body.definition_name() != target.body {
|
||||
return Err(DocError::Invalid("configured face definition does not match".into()));
|
||||
}
|
||||
if let Some(super::FaceCertificate::Lineage) = &target.certificate {
|
||||
return self.lineage_face_indices(body, &target.name);
|
||||
}
|
||||
if let Some(super::FaceCertificate::ShellPlanar { source_producer, source, inner }) = &target.certificate {
|
||||
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, current)| {
|
||||
matches!(current, Some(solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), source: current_source, inner: current_inner, .. }) if *producer == target.origin_feature && inner == current_inner && super::flat_source_matches(source, current_source, *source_producer)).then_some(index)
|
||||
@@ -238,10 +242,10 @@ impl Document {
|
||||
matched.then_some(index)
|
||||
})
|
||||
.collect();
|
||||
if indices.len() != 1 {
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown("attached planar face certificate is missing or ambiguous".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
return self.certified_pieces(body, indices, target.origin_feature);
|
||||
}
|
||||
if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate {
|
||||
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
|
||||
@@ -250,7 +254,7 @@ impl Document {
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown("constructed side certificate no longer exists".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
return Ok(self.without_copies(body, indices));
|
||||
}
|
||||
if let Some(super::FaceCertificate::Imported { payload_sha256, item, face }) = &target.certificate {
|
||||
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
|
||||
@@ -259,7 +263,7 @@ impl Document {
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown("immutable import face certificate no longer exists".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
return Ok(self.without_copies(body, indices));
|
||||
}
|
||||
let names = crate::naming::face_names(body);
|
||||
let indices: Vec<usize> =
|
||||
|
||||
+126
-3
@@ -942,6 +942,10 @@ pub enum FeatureKind {
|
||||
rotate_axis: Option<Vec3>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
angle: Option<String>,
|
||||
/// When set, the original bodies are kept and the transform is applied to a new copy
|
||||
/// of each instead of moving them in place.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
copy: bool,
|
||||
},
|
||||
/// A non-parametric base feature: bodies imported from a STEP file. The STEP text is kept
|
||||
/// in the design (it is the B-rep), so the design reopens without the original file.
|
||||
@@ -1694,6 +1698,18 @@ pub struct StandardPart {
|
||||
pub length: Option<f64>,
|
||||
}
|
||||
|
||||
/// A component whose contents come from another design file (Insert Design): where that file
|
||||
/// is, and the Import feature in the component that holds its bodies (Update Links replaces
|
||||
/// them; Break Link keeps them and forgets the file).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DesignLink {
|
||||
/// The design file: relative to this design's folder once this design is saved, else as
|
||||
/// given (`/` separates folders).
|
||||
pub path: String,
|
||||
/// The Import feature holding the linked design's bodies.
|
||||
pub feature: u64,
|
||||
}
|
||||
|
||||
/// The document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Document {
|
||||
@@ -1727,6 +1743,9 @@ pub struct Document {
|
||||
/// Components that are standard parts (by component id): what to rebuild them from.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub parts: std::collections::BTreeMap<u64, StandardPart>,
|
||||
/// Components linked to another design file (by component id).
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub links: std::collections::BTreeMap<u64, DesignLink>,
|
||||
/// Configurations (rows of parameter values and suppressions).
|
||||
#[serde(default, skip_serializing_if = "crate::config::ConfigTable::is_empty")]
|
||||
pub configs: crate::config::ConfigTable,
|
||||
@@ -1742,6 +1761,9 @@ pub struct Document {
|
||||
/// Physical material per body (by body name); others use the default.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub materials: std::collections::BTreeMap<String, String>,
|
||||
/// Physical materials defined in this design (`material.create`), next to the built-in ones.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub user_materials: Vec<UserMaterial>,
|
||||
/// Appearance colour per body (by body name, sRGB); it overrides the material's look.
|
||||
#[serde(default, skip_serializing_if = "crate::appearance::Appearances::is_empty")]
|
||||
pub appearances: crate::appearance::Appearances,
|
||||
@@ -1761,6 +1783,38 @@ pub struct Document {
|
||||
/// Named views: saved cameras.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub named_views: Vec<NamedView>,
|
||||
/// What was hidden when the design was saved. A session takes it from here when the design
|
||||
/// opens and keeps the live state itself (`browser.visibility`: saved, not an undo step), so
|
||||
/// a document in a session leaves this empty; the session puts it back when it saves.
|
||||
#[serde(default, skip_serializing_if = "Visibility::is_empty")]
|
||||
pub visibility: Visibility,
|
||||
}
|
||||
|
||||
/// Items hidden or shown one by one. Saved with the design, but not an undo step.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Visibility {
|
||||
/// Bodies by name.
|
||||
pub hidden_bodies: Vec<String>,
|
||||
/// Exact occurrence placements hidden with all descendants.
|
||||
pub hidden_occurrences: Vec<Vec<u64>>,
|
||||
/// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction geometry, by name.
|
||||
pub hidden_origin: Vec<String>,
|
||||
/// Sketches by feature id.
|
||||
pub hidden_sketches: Vec<u64>,
|
||||
/// Finished sketches shown although a feature uses them.
|
||||
pub shown_sketches: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Visibility {
|
||||
/// Nothing hidden or shown one by one (the default view).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.hidden_bodies.is_empty()
|
||||
&& self.hidden_occurrences.is_empty()
|
||||
&& self.hidden_origin.is_empty()
|
||||
&& self.hidden_sketches.is_empty()
|
||||
&& self.shown_sketches.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved camera (orbit camera: target, yaw, pitch, distance; `fov` 0 for orthographic).
|
||||
@@ -1819,9 +1873,11 @@ impl Document {
|
||||
plastic: Default::default(),
|
||||
configs: Default::default(),
|
||||
parts: Default::default(),
|
||||
links: Default::default(),
|
||||
body_components: Default::default(),
|
||||
body_offsets: Default::default(),
|
||||
materials: Default::default(),
|
||||
user_materials: Vec::new(),
|
||||
appearances: Default::default(),
|
||||
favorites: Default::default(),
|
||||
canvases: Vec::new(),
|
||||
@@ -1829,6 +1885,7 @@ impl Document {
|
||||
browser_groups: Vec::new(),
|
||||
browser_order: Default::default(),
|
||||
named_views: Vec::new(),
|
||||
visibility: Visibility::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1861,7 +1918,46 @@ impl Document {
|
||||
|
||||
/// Density of a body's material (g/cm³).
|
||||
pub fn density(&self, body: &str) -> f64 {
|
||||
self.materials.get(body).and_then(|m| material_density(m)).unwrap_or(DEFAULT_DENSITY)
|
||||
self.materials.get(body).and_then(|m| self.material_density(m)).unwrap_or(DEFAULT_DENSITY)
|
||||
}
|
||||
|
||||
/// A material defined in this design, by name (case-insensitive).
|
||||
pub fn user_material(&self, name: &str) -> Option<&UserMaterial> {
|
||||
let name = name.trim();
|
||||
self.user_materials.iter().find(|m| m.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// A physical material's name as the library or this design spells it (case-insensitive
|
||||
/// lookup): built-in materials first, then the design's own.
|
||||
pub fn material_name(&self, name: &str) -> Option<String> {
|
||||
MATERIALS
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(name.trim()))
|
||||
.map(|(n, _)| n.to_string())
|
||||
.or_else(|| self.user_material(name).map(|m| m.name.clone()))
|
||||
}
|
||||
|
||||
/// Density of a physical material (g/cm³): built-in or defined in this design. A design
|
||||
/// material with an unusable density (a damaged file) has none.
|
||||
pub fn material_density(&self, name: &str) -> Option<f64> {
|
||||
material_density(name).or_else(|| self.user_material(name).map(|m| m.density).filter(|d| valid_density(*d)))
|
||||
}
|
||||
|
||||
/// The look a physical material gives a body without an appearance of its own.
|
||||
pub fn material_look(&self, name: &str) -> Option<crate::appearance::Look> {
|
||||
crate::appearance::material_look(name).or_else(|| {
|
||||
let mut look = self.user_material(name)?.appearance.clone()?;
|
||||
// A damaged file's opacity is shown opaque rather than trusted.
|
||||
if !(look.opacity.is_finite() && (0.0..=1.0).contains(&look.opacity)) {
|
||||
look.opacity = 1.0;
|
||||
}
|
||||
Some(look)
|
||||
})
|
||||
}
|
||||
|
||||
/// Every physical material's name: the built-in library, then the design's own.
|
||||
pub fn material_names(&self) -> Vec<String> {
|
||||
MATERIALS.iter().map(|(n, _)| n.to_string()).chain(self.user_materials.iter().map(|m| m.name.clone())).collect()
|
||||
}
|
||||
|
||||
/// The component a body belongs to: where it was moved, else its feature's.
|
||||
@@ -2281,18 +2377,25 @@ impl Document {
|
||||
/// the sketch over is refused. Offsets whose sources moved are refreshed and solved again:
|
||||
/// from the stored state again after a refused or failed first solve (the geometry is still
|
||||
/// the stored one), from the solved one otherwise.
|
||||
///
|
||||
/// `moved_from`: the stored sketch, when `sk` is that sketch with its links refreshed. A
|
||||
/// link's points are where their source is now; the sides and turns the solve keeps are
|
||||
/// those of the stored state ([`Sketch::positions_in`]), not the ones read beside a line
|
||||
/// that has moved past a point.
|
||||
pub fn solve_sketch(
|
||||
&self,
|
||||
vals: &std::collections::BTreeMap<String, expr::Value>,
|
||||
sk: &mut Sketch,
|
||||
stored: &solidcraft_sketch::DrivingValues,
|
||||
moved_from: Option<&Sketch>,
|
||||
) -> Result<solidcraft_sketch::SolveReport> {
|
||||
self.apply_dimension_values(vals, sk)?;
|
||||
let mut report = solidcraft_sketch::solve_after_value_edit(sk, stored);
|
||||
let was = |sk: &Sketch| moved_from.and_then(|from| sk.positions_in(from));
|
||||
let mut report = solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref());
|
||||
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
|
||||
// Source dimensions may have moved curves during the solve.
|
||||
sk.refresh_offsets()?;
|
||||
report = if report.ok() { solidcraft_sketch::solve(sk) } else { solidcraft_sketch::solve_after_value_edit(sk, stored) };
|
||||
report = if report.ok() { solidcraft_sketch::solve(sk) } else { solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref()) };
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
@@ -2339,6 +2442,26 @@ pub fn material_color(name: &str) -> Option<[u8; 3]> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A physical material defined in a design (`material.create`): its name, its density and,
|
||||
/// optionally, the look it gives bodies without an appearance of their own.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UserMaterial {
|
||||
pub name: String,
|
||||
/// Density (g/cm³).
|
||||
pub density: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub appearance: Option<crate::appearance::Look>,
|
||||
}
|
||||
|
||||
/// Largest density a material may have (g/cm³); the densest elements are about 22.6.
|
||||
pub const MAX_DENSITY: f64 = 1000.0;
|
||||
|
||||
/// A density a material can have: finite, above zero and at most [`MAX_DENSITY`] g/cm³.
|
||||
pub fn valid_density(d: f64) -> bool {
|
||||
d.is_finite() && d > 0.0 && d <= MAX_DENSITY
|
||||
}
|
||||
|
||||
/// Density of a built-in physical material (g/cm³).
|
||||
pub fn material_density(name: &str) -> Option<f64> {
|
||||
MATERIALS.iter().find(|(n, _)| n.eq_ignore_ascii_case(name.trim())).map(|(_, d)| *d)
|
||||
}
|
||||
+208
-63
@@ -62,12 +62,14 @@ impl ModelBody {
|
||||
pub fn definition_name(&self) -> &str {
|
||||
self.source_name.as_deref().unwrap_or(&self.name)
|
||||
}
|
||||
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
|
||||
/// Display mesh, computed once: chord tolerance 1/1000 of the body size, and no facet of a
|
||||
/// curved face or edge turning more than [`kernel::DISPLAY_ANGLE`], so that a bend or a
|
||||
/// hole that is small beside the body is still drawn round.
|
||||
pub fn mesh(&self) -> Arc<Mesh> {
|
||||
self.mesh
|
||||
.get_or_init(|| {
|
||||
let tol = (self.body.size() * 1e-3).max(1e-3);
|
||||
Arc::new(self.body.display_mesh(tol).unwrap_or_default())
|
||||
Arc::new(self.body.display_mesh_angular(tol, kernel::DISPLAY_ANGLE).unwrap_or_default())
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
@@ -262,23 +264,38 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
|
||||
}
|
||||
|
||||
/// A constant fillet of `edges`; with [`FilletFit::Reduce`](crate::FilletFit::Reduce), a radius too
|
||||
/// large for the geometry around them is reduced to the largest that works for all of them, which
|
||||
/// is returned.
|
||||
/// large for the geometry around them is reduced where it doesn't fit, to the largest that works
|
||||
/// for each group of edges sharing a face; the radius per edge is returned.
|
||||
fn fillet_fitted(
|
||||
body: &kernel::Body,
|
||||
edges: &[Vec3],
|
||||
r: f64,
|
||||
fit: crate::FilletFit,
|
||||
) -> std::result::Result<(kernel::Body, Option<f64>), kernel::KernelError> {
|
||||
) -> std::result::Result<(kernel::Body, Option<Vec<f64>>), kernel::KernelError> {
|
||||
match kernel::fillet(body, edges, r) {
|
||||
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max(body, edges, r) {
|
||||
Some((used, b)) => Ok((b, Some(used))),
|
||||
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max_each(body, edges, r) {
|
||||
// Built at the radius asked for after all (the groups apart): nothing reduced.
|
||||
Some((used, b)) => Ok((b, used.iter().any(|u| *u < r).then_some(used))),
|
||||
None => Err(kernel::KernelError::TooLarge(why)),
|
||||
},
|
||||
other => other.map(|b| (b, None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The warning of a fillet whose radius `asked` was reduced to `used` (per edge).
|
||||
fn reduced_note(used: &[f64], asked: f64) -> String {
|
||||
let mut values: Vec<f64> = used.iter().copied().filter(|r| *r < asked).collect();
|
||||
values.sort_by(f64::total_cmp);
|
||||
values.dedup();
|
||||
let shown = values.iter().map(|v| show_mm(*v)).collect::<Vec<_>>().join(", ");
|
||||
let n = used.iter().filter(|r| **r < asked).count();
|
||||
if n == used.len() && values.len() == 1 {
|
||||
format!("the radius is reduced to {shown} mm, the largest that fits these edges")
|
||||
} else {
|
||||
format!("the radius is reduced to {shown} mm on {n} of {} edges, the largest that fits them", used.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// Add `note` to a feature's warning.
|
||||
fn append_warning(warning: &mut Option<String>, note: String) {
|
||||
*warning = Some(match warning.take() {
|
||||
@@ -294,9 +311,23 @@ fn show_mm(v: f64) -> String {
|
||||
}
|
||||
|
||||
impl ModelState {
|
||||
/// The radius fillet `feature` used when it reduced a radius too large for its edges.
|
||||
/// The smallest radius fillet `feature` used when it reduced a radius too large for its edges.
|
||||
pub fn fitted_radius(&self, feature: u64) -> Option<f64> {
|
||||
self.fitted.get(&feature).copied()
|
||||
self.fitted_range(feature).map(|(lo, _)| lo)
|
||||
}
|
||||
|
||||
/// The smallest and largest radius fillet `feature` used when it reduced a radius too large
|
||||
/// for some of its edges.
|
||||
pub fn fitted_range(&self, feature: u64) -> Option<(f64, f64)> {
|
||||
self.fitted.get(&feature)?.iter().fold(None, |acc, r| Some(acc.map_or((*r, *r), |(lo, hi): (f64, f64)| (lo.min(*r), hi.max(*r)))))
|
||||
}
|
||||
|
||||
/// Each edge fillet `feature` blended (a point on it) with the radius it used, when it
|
||||
/// reduced a radius too large for some of its edges.
|
||||
pub fn fitted_edges(&self, feature: u64) -> Option<Vec<(Vec3, f64)>> {
|
||||
let radii = self.fitted.get(&feature)?;
|
||||
let edges = self.blend_edges.get(&feature)?;
|
||||
Some(edges.iter().copied().zip(radii.iter().copied()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,9 +350,9 @@ pub struct ModelState {
|
||||
/// a pattern of the feature repeats these, not the points picked when it was made, which a
|
||||
/// parameter change may have moved off the edges.
|
||||
blend_edges: BTreeMap<u64, Vec<Vec3>>,
|
||||
/// The radius a fillet used when it reduced a radius too large for its edges (by feature id,
|
||||
/// see `FilletFit::Reduce`).
|
||||
fitted: BTreeMap<u64, f64>,
|
||||
/// The radius per edge a fillet used when it reduced a radius too large for some of its edges
|
||||
/// (by feature id, see `FilletFit::Reduce`).
|
||||
fitted: BTreeMap<u64, Vec<f64>>,
|
||||
}
|
||||
|
||||
impl ModelState {
|
||||
@@ -347,9 +378,11 @@ impl ModelState {
|
||||
[p.x, p.y, p.z].map(f64::to_bits).hash(&mut h);
|
||||
}
|
||||
}
|
||||
for (feature, r) in &self.fitted {
|
||||
for (feature, radii) in &self.fitted {
|
||||
feature.hash(&mut h);
|
||||
r.to_bits().hash(&mut h);
|
||||
for r in radii {
|
||||
r.to_bits().hash(&mut h);
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
|
||||
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
|
||||
@@ -362,7 +395,8 @@ impl ModelState {
|
||||
fn stamp(&mut self, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
|
||||
let feature = f.id;
|
||||
let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1;
|
||||
let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats });
|
||||
let sheets = if f.kind.is_sheet() { self.sheets.clone() } else { Vec::new() };
|
||||
let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats, sheets });
|
||||
// Pieces a cut left are named after their faces, not the kernel's shell order (unless
|
||||
// the feature was saved when they were named in that order).
|
||||
if !f.shell_piece_order {
|
||||
@@ -854,8 +888,11 @@ impl Model {
|
||||
let mut restored = 0;
|
||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||
let ctx = context_digest(doc, &vals);
|
||||
let watched = crate::progress::begin(doc.features.len());
|
||||
for (i, f) in doc.features.iter().enumerate() {
|
||||
let rolled_back = i >= marker;
|
||||
// Opening was cancelled: the rest is skipped and the result discarded.
|
||||
let cancelled = watched.step(i);
|
||||
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back);
|
||||
prev_fp = fp;
|
||||
if reuse
|
||||
@@ -871,7 +908,7 @@ impl Model {
|
||||
continue;
|
||||
}
|
||||
reuse = false;
|
||||
if f.suppressed || rolled_back {
|
||||
if f.suppressed || rolled_back || cancelled {
|
||||
out.push(FeatureResult {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
@@ -924,7 +961,14 @@ impl Model {
|
||||
let f = if !explicit_emboss && f.face_names.iter().any(|n| !n.is_empty()) {
|
||||
let mut g = f.clone();
|
||||
let mut notes = Vec::new();
|
||||
for (p, n) in g.kind.face_points_mut().into_iter().zip(&f.face_names) {
|
||||
// A hole's position is a place on its face, not a pick of the face: it follows
|
||||
// the face along the hole's axis and never slides sideways to the face's nearest
|
||||
// point when the face no longer reaches it.
|
||||
let hole_axis = match &f.kind {
|
||||
FeatureKind::Hole { direction, points: None, .. } => direction.normalized(),
|
||||
_ => None,
|
||||
};
|
||||
for (k, (p, n)) in g.kind.face_points_mut().into_iter().zip(&f.face_names).enumerate() {
|
||||
if n.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -933,7 +977,18 @@ impl Model {
|
||||
if let Some(note) = hit.note(n) {
|
||||
notes.push(note);
|
||||
}
|
||||
*p = hit.point;
|
||||
match (k, hole_axis) {
|
||||
(0, Some(axis)) => match hit.along(&state, *p, axis) {
|
||||
Some(q) => *p = q,
|
||||
// The place still lies on the face (the axis may run along it).
|
||||
None if hit.on_point => {}
|
||||
None => notes.push(format!(
|
||||
"the hole's position [{}, {}, {}] is no longer on face `{n}`: it stays where it was placed",
|
||||
p.x, p.y, p.z
|
||||
)),
|
||||
},
|
||||
_ => *p = hit.point,
|
||||
}
|
||||
}
|
||||
None => notes.push(format!("face `{n}` no longer exists; using the face at the picked point")),
|
||||
}
|
||||
@@ -1045,10 +1100,12 @@ pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region
|
||||
}
|
||||
let out: Vec<Region2> = match sel {
|
||||
// "All" means what was drawn: projected reference geometry (a face's auto-projected
|
||||
// edges) only counts when the drawn curves alone make no profile.
|
||||
// edges) only counts when the drawn curves alone make no profile. Text is drawn, and
|
||||
// its letters keep their counters as holes (the counters are not filled in).
|
||||
ProfileSel::All => {
|
||||
let drawn = solidcraft_sketch::find_drawn_profiles(&ss.sketch);
|
||||
if drawn.is_empty() { ps.iter().map(|p| p.region.clone()).collect() } else { drawn.into_iter().map(|p| p.region).collect() }
|
||||
let chosen = if drawn.is_empty() { ps.clone() } else { drawn };
|
||||
solidcraft_sketch::without_text_counters(&ss.sketch, chosen).into_iter().map(|p| p.region).collect()
|
||||
}
|
||||
ProfileSel::Indices { indices } => {
|
||||
let mut v = Vec::new();
|
||||
@@ -1121,6 +1178,25 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The k-th body of a body pattern: its stored name, else a copy of `source`. Once `body.rename`
|
||||
/// has stored this feature's names, a copy beyond them (the count was raised) is numbered after
|
||||
/// the highest stored `source (n)`, so it never takes a number freed by a rename (#372).
|
||||
/// Features without stored names keep the first free `source (n)`.
|
||||
fn pattern_copy_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> String {
|
||||
if f.body_names.get(k).is_some_and(|name| !name.trim().is_empty()) {
|
||||
return copy_body_name(state, f, k, source);
|
||||
}
|
||||
let base = if source.trim().is_empty() { "Body" } else { source.trim() };
|
||||
let prefix = format!("{base} (");
|
||||
let highest = f.body_names.iter().filter_map(|n| n.trim().strip_prefix(prefix.as_str())?.strip_suffix(')')?.parse::<u32>().ok()).max();
|
||||
match highest {
|
||||
Some(h) => {
|
||||
(u64::from(h) + 1..).map(|i| format!("{base} ({i})")).find(|n| state.body(n).is_none()).unwrap_or_else(|| unique_body_name(state, base))
|
||||
}
|
||||
None => unique_body_name(state, base),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_body_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> String {
|
||||
let base = f.body_names.get(k).filter(|name| !name.trim().is_empty()).map(String::as_str).unwrap_or(source);
|
||||
unique_body_name(state, base)
|
||||
@@ -1176,6 +1252,25 @@ fn intersection_tool_union(tools: Vec<Traced>) -> Result<Vec<Traced>> {
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
/// After a cut handled some sheet bodies as flat machining, apply it to the other targets.
|
||||
fn apply_beside_sheet_cut(st: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Operation, targets: &[String], handled: &[String]) -> Result<()> {
|
||||
let effective = if targets.is_empty() { &f.participants } else { targets };
|
||||
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
|
||||
let rest: Vec<String> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
!handled.contains(&b.name)
|
||||
&& (all || if effective.is_empty() { b.component == f.component || b.feature == f.id } else { effective.contains(&b.name) })
|
||||
})
|
||||
.map(|b| b.name.clone())
|
||||
.collect();
|
||||
if rest.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
apply_traced(st, f, tools, op, &rest)
|
||||
}
|
||||
|
||||
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
|
||||
apply_traced(state, f, tools.into_iter().map(Traced::unnamed).collect(), op, targets)
|
||||
}
|
||||
@@ -1305,7 +1400,9 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
});
|
||||
let live: std::collections::BTreeSet<&str> = state.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
state.threads.retain(|t| live.contains(t.body.as_str()));
|
||||
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)").
|
||||
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)"),
|
||||
// numbered on past names already taken (a body cut in pieces before).
|
||||
let mut taken: std::collections::BTreeSet<String> = state.bodies.iter().map(|b| b.name.clone()).collect();
|
||||
let mut split: Vec<ModelBody> = Vec::new();
|
||||
for mb in std::mem::take(&mut state.bodies) {
|
||||
if !affected.contains(&mb.name) || state.sheets.iter().any(|s| s.body == mb.name) {
|
||||
@@ -1321,7 +1418,12 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
// (so pieces of one face in different bodies are told apart).
|
||||
let lineages = crate::lineage::Lineage::lumps(&mb.body, mb.lineage().as_deref(), &pieces);
|
||||
for (k, (p, lineage)) in pieces.into_iter().zip(lineages).enumerate() {
|
||||
let name = if k == 0 { mb.name.clone() } else { format!("{} ({k})", mb.name) };
|
||||
let name = if k == 0 {
|
||||
mb.name.clone()
|
||||
} else {
|
||||
(k..=k + taken.len()).map(|j| format!("{} ({j})", mb.name)).find(|n| !taken.contains(n)).unwrap_or_default()
|
||||
};
|
||||
taken.insert(name.clone());
|
||||
split.push(ModelBody::new(name, p, mb.feature).with_lineage(lineage));
|
||||
}
|
||||
}
|
||||
@@ -1446,7 +1548,10 @@ fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, target
|
||||
|
||||
/// Booleans struggle with coincident faces (a cut starting on the face it cuts, a boss
|
||||
/// starting on the face it joins). Extending the tool past such an end changes nothing when the
|
||||
/// space beyond is empty (cut) or already material (join), so do exactly that.
|
||||
/// space beyond is empty (cut) or already material (join), so do exactly that. Only an end
|
||||
/// that lies on a target face parallel to it moves, and by less than half the way to the next
|
||||
/// such face level: the samples can miss material beyond the end (a thin floor between a recess
|
||||
/// above and a ring pocket below), which a longer tool would cut through.
|
||||
fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi: f64, op: Operation, targets: &[String]) -> (f64, f64) {
|
||||
if !matches!(op, Operation::Cut | Operation::Join) || st.bodies.is_empty() {
|
||||
return (lo, hi);
|
||||
@@ -1461,18 +1566,40 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi:
|
||||
let samples = region_samples(r);
|
||||
let n = plane.normal();
|
||||
let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p));
|
||||
let ok_beyond = |h: f64, dir: f64| {
|
||||
// Heights of the targets' faces parallel to the profile plane.
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
let levels: Vec<f64> = meshes
|
||||
.iter()
|
||||
.flat_map(|m| {
|
||||
m.triangles.iter().filter_map(|t| {
|
||||
let [a, b, c] = m.tri(t)?;
|
||||
let normal = (b - a).cross(c - a).normalized()?;
|
||||
(normal.dot(n).abs() > 1.0 - 1e-9).then(|| (a - plane.origin).dot(n))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// How far the end at `h` may move in `dir`: zero unless a face lies on it, then short of
|
||||
// half the gap to the next face level beyond it.
|
||||
let room = |h: f64, dir: f64| {
|
||||
if !levels.iter().any(|z| (z - h).abs() < tol) {
|
||||
return 0.0;
|
||||
}
|
||||
let gap = levels.iter().map(|z| (z - h) * dir).filter(|g| *g > tol).fold(f64::INFINITY, f64::min);
|
||||
delta.min(gap * 0.5)
|
||||
};
|
||||
let ok_beyond = |h: f64, dir: f64, step: f64| {
|
||||
samples.iter().all(|s| {
|
||||
let base = plane.to_world(*s);
|
||||
[0.5, 1.0].iter().all(|k| {
|
||||
let p = base + n * (h + dir * delta * k);
|
||||
let p = base + n * (h + dir * step * k);
|
||||
let inside = inside_any(p);
|
||||
if op == Operation::Cut { !inside } else { inside }
|
||||
})
|
||||
})
|
||||
};
|
||||
let lo2 = if ok_beyond(lo, -1.0) { lo - delta } else { lo };
|
||||
let hi2 = if ok_beyond(hi, 1.0) { hi + delta } else { hi };
|
||||
let (down, up) = (room(lo, -1.0), room(hi, 1.0));
|
||||
let lo2 = if down > tol && ok_beyond(lo, -1.0, down) { lo - down } else { lo };
|
||||
let hi2 = if up > tol && ok_beyond(hi, 1.0, up) { hi + up } else { hi };
|
||||
(lo2, hi2)
|
||||
}
|
||||
|
||||
@@ -2862,8 +2989,10 @@ fn replay(
|
||||
let nb = match &src.kind {
|
||||
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &moved, r, distance2, angle, *flip)?,
|
||||
// A copy is congruent to the original: the radius it was reduced to first.
|
||||
FeatureKind::Fillet { fit, .. } => match st.fitted.get(&src.id).copied() {
|
||||
Some(used) => kernel::fillet(&mb.body, &moved, used).or_else(|_| fillet_fitted(&mb.body, &moved, r, *fit).map(|x| x.0))?,
|
||||
FeatureKind::Fillet { fit, .. } => match st.fitted.get(&src.id) {
|
||||
Some(used) => {
|
||||
kernel::fillet_each(&mb.body, &moved, used).or_else(|_| fillet_fitted(&mb.body, &moved, r, *fit).map(|x| x.0))?
|
||||
}
|
||||
None => fillet_fitted(&mb.body, &moved, r, *fit)?.0,
|
||||
},
|
||||
_ => kernel::fillet(&mb.body, &moved, r)?,
|
||||
@@ -3254,9 +3383,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
if let Some(w) = face_warning {
|
||||
warns.insert(0, w);
|
||||
}
|
||||
// The stored sketch is solved for its stored values.
|
||||
// The stored sketch is solved for its stored values, with its links where they
|
||||
// were stored: a link that moved must not change the side a dimension from it is on.
|
||||
let stored = sk.driving_values();
|
||||
let report = doc.solve_sketch(vals, &mut sk, &stored)?;
|
||||
let report = doc.solve_sketch(vals, &mut sk, &stored, Some(sketch))?;
|
||||
if !report.keeps_orientation() {
|
||||
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
|
||||
}
|
||||
@@ -3282,26 +3412,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
|
||||
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref())?;
|
||||
if !handled.is_empty() {
|
||||
let effective = if targets.is_empty() { &f.participants } else { targets };
|
||||
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
|
||||
let rest: Vec<String> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
!handled.contains(&b.name)
|
||||
&& (all
|
||||
|| if effective.is_empty() {
|
||||
b.component == f.component || b.feature == f.id
|
||||
} else {
|
||||
effective.contains(&b.name)
|
||||
})
|
||||
})
|
||||
.map(|b| b.name.clone())
|
||||
.collect();
|
||||
if rest.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
return apply_traced(st, f, tools, *operation, &rest);
|
||||
return apply_beside_sheet_cut(st, f, tools, *operation, targets, &handled);
|
||||
}
|
||||
}
|
||||
apply_traced(st, f, tools, *operation, targets)
|
||||
@@ -3405,13 +3516,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let fst = crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?;
|
||||
doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?
|
||||
}
|
||||
None => doc.resolve_plane_in(vals, plane, 0, Some(st))?,
|
||||
// Picks on bodies of other (moved) components are where those bodies are shown.
|
||||
None => doc.resolve_plane_in(vals, plane, 0, Some(&construction_state(doc, st, f.component)))?,
|
||||
};
|
||||
st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Plane(pl) });
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::ConstructionAxis { def } => {
|
||||
let (origin, dir) = crate::construct::resolve_axis(&crate::construct::Ctx { doc, vals, st, depth: 0 }, def)?;
|
||||
let (origin, dir) =
|
||||
crate::construct::resolve_axis(&crate::construct::Ctx { doc, vals, st: &construction_state(doc, st, f.component), depth: 0 }, def)?;
|
||||
st.construct.push(crate::construct::Construct {
|
||||
feature: f.id,
|
||||
name: f.name.clone(),
|
||||
@@ -3420,7 +3533,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::ConstructionPoint { def } => {
|
||||
let p = crate::construct::resolve_point(&crate::construct::Ctx { doc, vals, st, depth: 0 }, def)?;
|
||||
let p =
|
||||
crate::construct::resolve_point(&crate::construct::Ctx { doc, vals, st: &construction_state(doc, st, f.component), depth: 0 }, def)?;
|
||||
st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Point(p) });
|
||||
Ok(())
|
||||
}
|
||||
@@ -3541,7 +3655,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let mb = st.body(n).cloned().ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
|
||||
for (k, m) in mats.iter().enumerate() {
|
||||
let copy = moved_copy(&mb, kernel::transform_matrix(&mb.body, *m)?, k + 1);
|
||||
let name = copy_body_name(st, f, output, &mb.name);
|
||||
let name = pattern_copy_name(st, f, output, &mb.name);
|
||||
st.bodies.push(ModelBody::traced_new(name, copy, f.id));
|
||||
output += 1;
|
||||
}
|
||||
@@ -3653,8 +3767,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
FeatureKind::Fillet { fit, .. } => {
|
||||
let (b, used) = fillet_fitted(&mb.body, &edges, r, *fit)?;
|
||||
if let Some(used) = used {
|
||||
append_warning(warning, reduced_note(&used, r));
|
||||
st.fitted.insert(f.id, used);
|
||||
append_warning(warning, format!("the radius is reduced to {} mm, the largest that fits these edges", show_mm(used)));
|
||||
}
|
||||
(b, input)
|
||||
}
|
||||
@@ -3675,12 +3789,24 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let corner = point(vals, corner)?;
|
||||
let b = kernel::box_solid(corner, corner + s)?;
|
||||
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b };
|
||||
apply_traced(st, f, vec![crate::lineage::box_tool(b)], *operation, &[])
|
||||
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation)?;
|
||||
let tools = vec![crate::lineage::box_tool(b)];
|
||||
if !handled.is_empty() {
|
||||
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
|
||||
}
|
||||
apply_traced(st, f, tools, *operation, &[])
|
||||
}
|
||||
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
|
||||
let b = kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
|
||||
let (base, radius, height) = (point(vals, base)?, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?);
|
||||
let b = kernel::cylinder(base, *axis, radius, height)?;
|
||||
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_cylinder_certificate(f.id)? } else { b };
|
||||
apply_traced(st, f, vec![crate::lineage::cylinder_tool(b)], *operation, &[])
|
||||
let prism = more::PrimitivePrism::Cylinder { base, axis: *axis, radius, height };
|
||||
let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation)?;
|
||||
let tools = vec![crate::lineage::cylinder_tool(b)];
|
||||
if !handled.is_empty() {
|
||||
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
|
||||
}
|
||||
apply_traced(st, f, tools, *operation, &[])
|
||||
}
|
||||
FeatureKind::Sphere { center, radius, operation } => {
|
||||
let b = kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?;
|
||||
@@ -3779,7 +3905,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
st.bodies.push(ModelBody::traced_new(name, b, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Move { bodies, translate, rotate_axis, angle } => {
|
||||
FeatureKind::Move { bodies, translate, rotate_axis, angle, copy } => {
|
||||
let t =
|
||||
Vec3::new(val(vals, &translate[0], Kind::Length)?, val(vals, &translate[1], Kind::Length)?, val(vals, &translate[2], Kind::Length)?);
|
||||
let ang = match angle {
|
||||
@@ -3787,11 +3913,20 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
None => 0.0,
|
||||
};
|
||||
let axis = rotate_axis.unwrap_or(Vec3::Z);
|
||||
// With `copy` set, the source body is kept in place and the transform lands on a new
|
||||
// body pushed beside it instead of replacing it.
|
||||
let mut output = 0;
|
||||
for n in bodies {
|
||||
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
|
||||
if let Some(mb) = st.bodies.get_mut(i) {
|
||||
let nb = Traced::step(kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?, &[&mb.traced()]);
|
||||
*mb = ModelBody::traced_new(mb.name.clone(), nb, mb.feature);
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { continue };
|
||||
let nb = kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?;
|
||||
if *copy {
|
||||
let traced = moved_copy(&mb, nb, 1);
|
||||
let name = copy_body_name(st, f, output, &mb.name);
|
||||
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
|
||||
output += 1;
|
||||
} else if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::traced_new(mb.name.clone(), Traced::step(nb, &[&mb.traced()]), mb.feature);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -3868,6 +4003,16 @@ fn unique_body_name(st: &ModelState, base: &str) -> String {
|
||||
/// it are moved into its frame (through both placements), so a sketch on another component's
|
||||
/// face finds the face where it is shown and projects its edges there. The model itself when
|
||||
/// every placement agrees (the usual case).
|
||||
/// What construction geometry of component `comp` is built on: the model as that component sees
|
||||
/// it, other components' bodies where they are placed. In the root, every placement of a
|
||||
/// component (its pasted instances too) is there, as the viewport shows it.
|
||||
fn construction_state<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
|
||||
if comp == 0 && !doc.occurrences.is_empty() {
|
||||
return std::borrow::Cow::Owned(world_state(doc, st));
|
||||
}
|
||||
state_in_frame(doc, st, comp)
|
||||
}
|
||||
|
||||
pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
|
||||
state_in_frame_at(doc, st, comp, doc.component_transform(comp))
|
||||
}
|
||||
|
||||
+147
-31
@@ -325,6 +325,21 @@ fn sheet_for(st: &ModelState, body: &Option<String>, pick: Option<Vec3>) -> Resu
|
||||
.ok_or_else(|| DocError::Invalid("there is no sheet metal body".into()))
|
||||
}
|
||||
|
||||
/// Whether a point touches sheet `i`'s material: inside it, on one of its faces, or within its
|
||||
/// display tolerance of one (picks land on the display mesh). A sheet with no mesh to test
|
||||
/// against counts as touched.
|
||||
fn on_sheet_material(st: &ModelState, i: usize, p: Vec3) -> bool {
|
||||
let Some(mb) = st.sheets.get(i).and_then(|sh| st.body(&sh.body)) else { return true };
|
||||
let mesh = mb.mesh();
|
||||
if mesh.triangles.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// Twice the display mesh's chord tolerance (see `ModelBody::mesh`).
|
||||
let step = 2.0 * (mb.body.size() * 1e-3).max(1e-3);
|
||||
let offsets = [-step, 0.0, step];
|
||||
offsets.iter().any(|&dx| offsets.iter().any(|&dy| offsets.iter().any(|&dz| mesh.contains(p + Vec3::new(dx, dy, dz)))))
|
||||
}
|
||||
|
||||
/// A region mirrored in the sketch x axis (y → −y); curves other than lines and arcs as polylines.
|
||||
fn mirror_y(r: &Region2) -> Region2 {
|
||||
let f = |p: Vec2| Vec2::new(p.x, -p.y);
|
||||
@@ -500,12 +515,25 @@ pub(super) fn sheet_eval(
|
||||
doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius
|
||||
}
|
||||
};
|
||||
// A fixed point that touches no material at all (e.g. far above the sheet) still picks
|
||||
// the side it lies on, so a stored fold keeps recomputing; it says so (#371).
|
||||
if let Some(p) = fixed
|
||||
&& !on_sheet_material(st, i, *p)
|
||||
{
|
||||
*warning = Some(format!(
|
||||
"{} at ({:.3}, {:.3}, {:.3}); the side of the fold line it lies on stays",
|
||||
sheet::FOLD_FIXED_OFF_SHEET,
|
||||
p.x,
|
||||
p.y,
|
||||
p.z
|
||||
));
|
||||
}
|
||||
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
if sh.flat {
|
||||
return Err(DocError::Invalid("refold the sheet before folding it".into()));
|
||||
}
|
||||
let n0 = sh.flanges.len();
|
||||
sh.add_fold(a, b, ang, rr, position, *flip, *fixed)?;
|
||||
sh.add_fold(a, b, ang, rr, position, *flip, *fixed, warning)?;
|
||||
tag_flanges(sh, n0, f.id);
|
||||
rebuild_sheet(st, i)
|
||||
}
|
||||
@@ -640,24 +668,112 @@ pub(super) fn sheet_cut(
|
||||
constructor: Option<&ExtrudeConstructor>,
|
||||
) -> Result<Vec<String>> {
|
||||
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
|
||||
if extent.taper.is_some() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let targets = if targets.is_empty() { &f.participants } else { targets };
|
||||
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
|
||||
let eligible = |original: &crate::sheet::SheetBody| {
|
||||
all || if targets.is_empty() {
|
||||
st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id))
|
||||
} else {
|
||||
targets.contains(&original.body)
|
||||
}
|
||||
};
|
||||
if !st.sheets.iter().any(&eligible) {
|
||||
if extent.taper.is_some() || !any_eligible_sheet(f, st, targets) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let n = ss.plane.normal();
|
||||
sheet_prism_cut(f, st, &ss.plane, ®ions, *operation, tools, targets, constructor, extent.to.is_some())
|
||||
}
|
||||
|
||||
/// Whether a cut of `f` with these targets (else its participants, else its component's
|
||||
/// bodies) machines the sheet `original`.
|
||||
fn sheet_eligible(f: &Feature, st: &ModelState, targets: &[String], original: &crate::sheet::SheetBody) -> bool {
|
||||
let targets = if targets.is_empty() { &f.participants } else { targets };
|
||||
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
|
||||
all || if targets.is_empty() {
|
||||
st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id))
|
||||
} else {
|
||||
targets.contains(&original.body)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a cut of `f` with these targets machines any sheet metal body.
|
||||
fn any_eligible_sheet(f: &Feature, st: &ModelState, targets: &[String]) -> bool {
|
||||
st.sheets.iter().any(|original| sheet_eligible(f, st, targets, original))
|
||||
}
|
||||
|
||||
/// A Box or Cylinder cut (or intersect) whose tool is a straight prism along the normal of
|
||||
/// the sheet it meets: machined like the equivalent extrude of its footprint, so the sheet
|
||||
/// keeps it as a flat cut-out or pocket. Returns the sheet bodies it handled; none when the
|
||||
/// primitive is not such a prism (the generic preflight then refuses real 3D machining).
|
||||
pub(super) fn sheet_primitive_cut(f: &Feature, st: &mut ModelState, tool: &Body, prism: PrimitivePrism, operation: Operation) -> Result<Vec<String>> {
|
||||
if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// The first sheet the primitive meets decides the machining direction.
|
||||
let mut normal = None;
|
||||
for sh in st.sheets.iter().filter(|sh| sheet_eligible(f, st, &[], sh)) {
|
||||
if let Some(mb) = st.body(&sh.body)
|
||||
&& kernel::boolean(&mb.body, tool, kernel::BoolOp::Intersect)?.is_some()
|
||||
{
|
||||
normal = Some(sh.frame.normal());
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(normal) = normal else { return Ok(Vec::new()) };
|
||||
let Some((plane, region, interval)) = prism.along(normal) else { return Ok(Vec::new()) };
|
||||
let tools = kernel::extrude(&plane, std::slice::from_ref(®ion), interval.0, interval.1)?;
|
||||
sheet_prism_cut(f, st, &plane, std::slice::from_ref(®ion), operation, &tools, &[], None, false)
|
||||
}
|
||||
|
||||
/// A primitive tool's exact geometry, for [`sheet_primitive_cut`].
|
||||
pub(super) enum PrimitivePrism {
|
||||
/// An axis-aligned box between two corners.
|
||||
Box { lo: Vec3, hi: Vec3 },
|
||||
/// A cylinder: base centre, unit axis, radius and signed height.
|
||||
Cylinder { base: Vec3, axis: Vec3, radius: f64, height: f64 },
|
||||
}
|
||||
|
||||
impl PrimitivePrism {
|
||||
/// The sketch-like plane, footprint region and depth interval of this tool as a prism along
|
||||
/// `normal` (either sense), or None when it is not one.
|
||||
fn along(&self, normal: Vec3) -> Option<(Plane, Region2, (f64, f64))> {
|
||||
let n = normal.normalized()?;
|
||||
match *self {
|
||||
PrimitivePrism::Box { lo, hi } => {
|
||||
let (lo, hi) = (lo.min(hi), lo.max(hi));
|
||||
let s = hi - lo;
|
||||
// Plane axes (x, y) and the depth axis, as a right-handed frame.
|
||||
let (x, y, depth) = if n.cross(Vec3::Z).len() <= 1e-12 {
|
||||
(Vec3::X, Vec3::Y, s.z)
|
||||
} else if n.cross(Vec3::X).len() <= 1e-12 {
|
||||
(Vec3::Y, Vec3::Z, s.x)
|
||||
} else if n.cross(Vec3::Y).len() <= 1e-12 {
|
||||
(Vec3::Z, Vec3::X, s.y)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let plane = Plane::new(lo, x, y)?;
|
||||
let (w, h) = (s.dot(x), s.dot(y));
|
||||
let outer = Loop2::polygon(&[Vec2::ZERO, Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)]);
|
||||
Some((plane, Region2 { outer, holes: vec![] }, (0.0, depth)))
|
||||
}
|
||||
PrimitivePrism::Cylinder { base, axis, radius, height } => {
|
||||
let axis = axis.normalized()?;
|
||||
if axis.cross(n).len() > 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let plane = Plane::from_normal(base, axis)?;
|
||||
Some((plane, Region2 { outer: Loop2::circle(Vec2::ZERO, radius), holes: vec![] }, (height.min(0.0), height.max(0.0))))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sheet_prism_cut(
|
||||
f: &Feature,
|
||||
st: &mut ModelState,
|
||||
plane: &Plane,
|
||||
regions: &[Region2],
|
||||
operation: Operation,
|
||||
tools: &[Body],
|
||||
targets: &[String],
|
||||
constructor: Option<&ExtrudeConstructor>,
|
||||
footprints_bound: bool,
|
||||
) -> Result<Vec<String>> {
|
||||
let n = plane.normal();
|
||||
let mut work = sheet::FlatWork::new();
|
||||
let faces = tools.iter().fold(0usize, |count, body| count.saturating_add(body.face_count()));
|
||||
if tools.len() > 1000 || faces > 16_384 {
|
||||
@@ -668,7 +784,7 @@ pub(super) fn sheet_cut(
|
||||
let mut cap_roundoff: f64 = 0.0;
|
||||
let mut current_footprints = Vec::new();
|
||||
for tool in tools {
|
||||
let Some((lo, hi, roundoff, footprint)) = sheet_tool_span(tool, &ss.plane) else {
|
||||
let Some((lo, hi, roundoff, footprint)) = sheet_tool_span(tool, plane) else {
|
||||
// Modified tools need a current geometric prism certificate. The generic
|
||||
// sheet preflight permits actual no-change, otherwise refuses stale metadata.
|
||||
return Ok(Vec::new());
|
||||
@@ -688,7 +804,7 @@ pub(super) fn sheet_cut(
|
||||
let selected_edges =
|
||||
regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).fold(0usize, usize::saturating_add);
|
||||
work.charge(faces.saturating_mul(selected_edges).saturating_mul(16))?;
|
||||
let nominal = constructor.filter(|c| c.plane == ss.plane).and_then(|c| {
|
||||
let nominal = constructor.filter(|c| c.plane == *plane).and_then(|c| {
|
||||
let placed_basis = Plane::new(c.plane.origin, c.plane.x, c.plane.y)?;
|
||||
(current_footprints.len() == tools.len()
|
||||
&& current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r)))
|
||||
@@ -696,23 +812,23 @@ pub(super) fn sheet_cut(
|
||||
});
|
||||
let mut plans = Vec::new();
|
||||
'sheets: for original in &st.sheets {
|
||||
if !eligible(original) {
|
||||
if !sheet_eligible(f, st, targets, original) {
|
||||
continue;
|
||||
}
|
||||
let Some(mb) = st.body(&original.body) else {
|
||||
continue;
|
||||
};
|
||||
let mut sh = original.clone();
|
||||
if *operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
|
||||
if operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
continue;
|
||||
}
|
||||
if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, *operation)? {
|
||||
if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, operation)? {
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
continue;
|
||||
}
|
||||
for bends in sh.unmodeled_solids(&ss.plane)? {
|
||||
for bends in sh.unmodeled_solids(plane)? {
|
||||
for tool in tools {
|
||||
if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() {
|
||||
return Err(DocError::Invalid(
|
||||
@@ -724,21 +840,21 @@ pub(super) fn sheet_cut(
|
||||
let candidates = if sh.flat {
|
||||
if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|r| (None, r)).collect() }
|
||||
} else {
|
||||
sh.material_panels(Some(&ss.plane), &mut work)?
|
||||
sh.material_panels(Some(plane), &mut work)?
|
||||
};
|
||||
let mut modeled = false;
|
||||
for (pi, panel) in candidates {
|
||||
let w = if sh.flat { sh.world(None) } else { sh.world(pi) };
|
||||
let zorigin = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n);
|
||||
let zorigin = (apply_point(&w, Vec3::ZERO) - plane.origin).dot(n);
|
||||
let sign = crate::apply_vector(&w, Vec3::Z).dot(n);
|
||||
if sign.abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = ((span.0 - zorigin) / sign, (span.1 - zorigin) / sign);
|
||||
let roundoff = (cap_roundoff
|
||||
+ 128.0 * f64::EPSILON * (apply_point(&w, Vec3::ZERO).len() + ss.plane.origin.len() + span.0.abs() + span.1.abs() + sh.t).max(1.0))
|
||||
+ 128.0 * f64::EPSILON * (apply_point(&w, Vec3::ZERO).len() + plane.origin.len() + span.0.abs() + span.1.abs() + sh.t).max(1.0))
|
||||
/ sign.abs();
|
||||
let nominal_depth = if apply_point(&w, Vec3::ZERO) == ss.plane.origin
|
||||
let nominal_depth = if apply_point(&w, Vec3::ZERO) == plane.origin
|
||||
&& let Some((lo, hi)) = nominal
|
||||
&& (span.0 - lo).abs() <= roundoff
|
||||
&& (span.1 - hi).abs() <= roundoff
|
||||
@@ -758,7 +874,7 @@ pub(super) fn sheet_cut(
|
||||
None
|
||||
};
|
||||
if nominal_depth.is_none() && roundoff > 1e-7 {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
|
||||
plans.push((original.body.clone(), Some((original.clone(), mb.body.clone()))));
|
||||
continue 'sheets;
|
||||
}
|
||||
@@ -766,12 +882,12 @@ pub(super) fn sheet_cut(
|
||||
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
|
||||
// Sub-resolution layers must not be silently erased by endpoint snapping.
|
||||
let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6);
|
||||
let mapped = if extent.to.is_some() && current_footprints.len() == tools.len() {
|
||||
let mapped = if footprints_bound && current_footprints.len() == tools.len() {
|
||||
current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
|
||||
} else {
|
||||
regions.iter().map(|r| sh.region_to_flat(pi, &ss.plane, r)).collect::<Result<Vec<_>>>()?
|
||||
regions.iter().map(|r| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
|
||||
};
|
||||
if *operation == Operation::Intersect {
|
||||
if operation == Operation::Intersect {
|
||||
if z1 <= z0 {
|
||||
sh.holes.push(panel);
|
||||
modeled = true;
|
||||
@@ -827,7 +943,7 @@ pub(super) fn sheet_cut(
|
||||
}
|
||||
}
|
||||
if !modeled {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
} else if sh.flat_material()?.is_empty() {
|
||||
// Empty exact material removes geometry and metadata together. Budget or
|
||||
|
||||
@@ -144,15 +144,30 @@ impl Document {
|
||||
author: u64,
|
||||
author_path: &[u64],
|
||||
) -> Result<FaceAnchor> {
|
||||
let source = body
|
||||
// A construction certificate that names this face alone, else its exact history name.
|
||||
let certified = body
|
||||
.body
|
||||
.face_plane(index)
|
||||
.ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))
|
||||
.and_then(|source| {
|
||||
let target = self.strong_face_target(body, index, source.origin)?;
|
||||
if target.certificate.is_none() {
|
||||
return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into()));
|
||||
}
|
||||
let indices = self.configuration_face_indices(body, &target)?;
|
||||
self.coplanar_pieces(body, &indices, |_| true)?;
|
||||
Ok(target)
|
||||
});
|
||||
let target = match certified {
|
||||
Ok(target) => target,
|
||||
Err(error) => match body.body.planar_face(index).and_then(|plane| self.lineage_face_target(body, index, plane.origin)) {
|
||||
Some(target) => target,
|
||||
None => return Err(error),
|
||||
},
|
||||
};
|
||||
let source = self
|
||||
.anchor_face_plane(body, index, target.certificate.as_ref())
|
||||
.ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))?;
|
||||
let target = self.strong_face_target(body, index, source.origin)?;
|
||||
if target.certificate.is_none() {
|
||||
return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into()));
|
||||
}
|
||||
self.configuration_face_indices(body, &target)?;
|
||||
let mut anchor = FaceAnchor {
|
||||
target,
|
||||
component: self.model_body_component(body),
|
||||
@@ -178,9 +193,14 @@ impl Document {
|
||||
state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("attached body `{}` no longer exists", anchor.target.body)))?;
|
||||
let indices = self.configuration_face_indices(body, &anchor.target)?;
|
||||
let first = indices.first().copied().ok_or_else(|| DocError::Unknown("attached face no longer exists".into()))?;
|
||||
let plane = body.body.face_plane(first).ok_or_else(|| DocError::Invalid("attached face no longer has an exact planar source".into()))?;
|
||||
let certificate = anchor.target.certificate.as_ref();
|
||||
let plane = self
|
||||
.anchor_face_plane(body, first, certificate)
|
||||
.ok_or_else(|| DocError::Invalid("attached face no longer has an exact planar source".into()))?;
|
||||
for index in indices.iter().skip(1) {
|
||||
let other = body.body.face_plane(*index).ok_or_else(|| DocError::Invalid("attached face pieces are not exactly planar".into()))?;
|
||||
let other = self
|
||||
.anchor_face_plane(body, *index, certificate)
|
||||
.ok_or_else(|| DocError::Invalid("attached face pieces are not exactly planar".into()))?;
|
||||
if plane.normal().dot(other.normal()) < 1. - 1e-8 || (other.origin - plane.origin).dot(plane.normal()).abs() > 1e-7 {
|
||||
return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into()));
|
||||
}
|
||||
|
||||
@@ -51,13 +51,35 @@
|
||||
//! anchors can name a sheet metal panel side (`sheet:<body>:<panel>:<side>`). Version 14
|
||||
//! designs need no transformation; the version stamp lets older readers request an update
|
||||
//! instead of encountering an unknown certificate or link kind.
|
||||
//! - 16: sheet metal rules can choose a corner relief shape (`corner_relief_shape`: notch,
|
||||
//! round or rectangular). Version 15 designs need no transformation (an absent shape is the
|
||||
//! notch they always had); the version stamp lets older readers request an update instead of
|
||||
//! silently building notches where a design asks for another shape.
|
||||
//! Under the same version, sketch text can be set in a font other than the built-in one
|
||||
//! (`font` in the text link's source: an installed family or a font file); a text without
|
||||
//! `font` is in the built-in font, as it always was.
|
||||
//! A Move feature can also keep its source bodies and transform a copy (`copy: true`); a
|
||||
//! move without it saves as before.
|
||||
//! A component can link to another design file (`links`: component id → `{path, feature}`,
|
||||
//! the path relative to the design's folder); the linked bodies are kept in the component's
|
||||
//! Import feature, so an older build opens the design with them and only loses the link.
|
||||
//! A design can also define its own physical materials (`user_materials`: name, density in
|
||||
//! g/cm³, optional look), which bodies (`materials`) and configuration material cells name
|
||||
//! like built-in ones; a design without them saves as before.
|
||||
//! A design can also keep what was hidden when it was saved (`visibility`: hidden bodies,
|
||||
//! occurrence placements, sketches and origin items; `browser.visibility`). A design without
|
||||
//! it shows everything, as it always did; a reader that ignores it loses only the hiding.
|
||||
//! Sketch face anchors can name a flat face by its exact history name (a `lineage`
|
||||
//! certificate: the target's `name`, e.g. a cut wall `F5:side:l1` or a pattern copy
|
||||
//! `F4:[email protected]`), where no construction certificate names it alone. Anchors with
|
||||
//! construction certificates resolve as before.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 15;
|
||||
pub const FORMAT: u32 = 16;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solidcraft/{n}")
|
||||
@@ -74,7 +96,7 @@ pub fn version_of(s: &str) -> Option<u32> {
|
||||
|
||||
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
||||
type JsonStep = fn(&mut Value) -> Result<()>;
|
||||
const JSON_STEPS: [JsonStep; 14] = [
|
||||
const JSON_STEPS: [JsonStep; 15] = [
|
||||
|_| Ok(()),
|
||||
v2_to_v3,
|
||||
v3_to_v4,
|
||||
@@ -89,6 +111,7 @@ const JSON_STEPS: [JsonStep; 14] = [
|
||||
|_| Ok(()),
|
||||
v13_to_v14,
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
];
|
||||
|
||||
/// Stored face and edge names were worked out from geometry before format 14; they get the
|
||||
|
||||
@@ -32,6 +32,7 @@ pub mod naming;
|
||||
mod params;
|
||||
mod piece_names;
|
||||
mod profile_refs;
|
||||
pub mod progress;
|
||||
pub use profile_refs::{capture_profile_selection, profile_selection_indices};
|
||||
pub mod plastic;
|
||||
pub mod project;
|
||||
|
||||
@@ -211,6 +211,35 @@ impl FaceHit {
|
||||
}
|
||||
}
|
||||
|
||||
impl FaceHit {
|
||||
/// Where the line through `p` along `dir` meets the face found (the hit nearest `p`, on
|
||||
/// either side of it), `None` when the line misses the face or runs along it. A place on
|
||||
/// a face follows the face this way, along its own axis, instead of sliding sideways to
|
||||
/// [`point`](Self::point), the face's nearest point.
|
||||
pub fn along(&self, st: &ModelState, p: Vec3, dir: Vec3) -> Option<Vec3> {
|
||||
let b = st.bodies.get(self.body)?;
|
||||
let d = dir.normalized()?;
|
||||
let m = b.mesh();
|
||||
let slack = (b.body.size() * 1e-9).max(1e-9);
|
||||
let mut best: Option<f64> = None;
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
if *f as usize != self.face {
|
||||
continue;
|
||||
}
|
||||
let Some([a, bb, c]) = m.tri(t) else { continue };
|
||||
// A hair's width around each triangle, so a place on the seam of two is on the face.
|
||||
let centre = (a + bb + c) * (1.0 / 3.0);
|
||||
let grow = |v: Vec3| v + (v - centre).normalized().unwrap_or(Vec3::ZERO) * slack;
|
||||
if let Some(hit) = naming::ray_hit(p, d, grow(a), grow(bb), grow(c))
|
||||
&& best.is_none_or(|old| hit.abs() < old.abs())
|
||||
{
|
||||
best = Some(hit);
|
||||
}
|
||||
}
|
||||
best.map(|t| p + d * t)
|
||||
}
|
||||
}
|
||||
|
||||
impl EdgeHit {
|
||||
/// What a feature says about how it found an edge by its stored name `name` (`None`: exactly).
|
||||
pub fn note(&self, name: &str) -> Option<String> {
|
||||
|
||||
@@ -31,6 +31,8 @@ pub struct NamingOrigin {
|
||||
pub depth: usize,
|
||||
/// Instance transforms of a pattern or mirror (its copies are named after their sources).
|
||||
pub mats: Vec<crate::Mat>,
|
||||
/// The sheet metal bodies after a sheet feature (their folds place moved faces).
|
||||
pub sheets: Vec<crate::sheet::SheetBody>,
|
||||
}
|
||||
|
||||
/// The lazily computed names of a body, shared by its clones.
|
||||
@@ -334,6 +336,24 @@ fn geometric_from(b: &ModelBody, o: &NamingOrigin, nf: usize, names_of: fn(&Mode
|
||||
names = got.into_iter().map(|n| n.map(|n| format!("{n}@F{fid}.{}", k + 1))).collect();
|
||||
}
|
||||
}
|
||||
// A sheet feature that moved panels (a fold, an unfold): faces it carried along lie on the
|
||||
// faces they were, taken back through the panel they are on.
|
||||
if let (Some(after), Some(before), Some(input)) = (
|
||||
o.sheets.iter().find(|s| s.body == b.name),
|
||||
o.before.sheets.iter().find(|s| s.body == b.name),
|
||||
inputs.iter().find(|(x, _, _)| x.name == b.name),
|
||||
) {
|
||||
let input = std::slice::from_ref(input);
|
||||
for (name, ps) in names.iter_mut().zip(&pts) {
|
||||
if name.is_some() {
|
||||
continue;
|
||||
}
|
||||
let back: Option<Vec<Vec3>> = ps.iter().map(|p| after.point_before(before, *p)).collect();
|
||||
if let Some(back) = back.filter(|b| !b.is_empty()) {
|
||||
*name = vote(&back, input, tol, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
let empty = Vec::new();
|
||||
let names: Vec<String> = names
|
||||
.into_iter()
|
||||
@@ -386,7 +406,7 @@ fn behind(ps: &[Vec3], ns: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<St
|
||||
votes.first().filter(|(_, k)| *k * 2 >= ps.len().max(1)).map(|(n, _)| n.clone())
|
||||
}
|
||||
|
||||
fn ray_hit(o: Vec3, d: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Option<f64> {
|
||||
pub(crate) fn ray_hit(o: Vec3, d: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Option<f64> {
|
||||
let (e1, e2) = (b - a, c - a);
|
||||
let h = d.cross(e2);
|
||||
let det = e1.dot(h);
|
||||
@@ -454,14 +474,12 @@ pub fn strip_edge_piece(n: &str) -> String {
|
||||
|
||||
/// Edges sharing a name: number them along the longest axis of their spread.
|
||||
fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for i in 0..names.len() {
|
||||
let n = names[i].clone();
|
||||
if seen.contains(&n) {
|
||||
continue;
|
||||
}
|
||||
seen.push(n.clone());
|
||||
let same: Vec<usize> = (0..names.len()).filter(|j| names[*j] == n).collect();
|
||||
// The pieces of each name, gathered in one pass (a faceted body has thousands of names).
|
||||
let mut groups: std::collections::BTreeMap<String, Vec<usize>> = std::collections::BTreeMap::new();
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
groups.entry(n.clone()).or_default().push(i);
|
||||
}
|
||||
for (n, same) in groups {
|
||||
if same.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Progress and cooperative cancellation for a long evaluation (opening a large design) run
|
||||
//! off the window thread. The caller wraps the work in [`watch`]; the timeline's evaluation
|
||||
//! loop reports each feature it reaches and, once [`Progress::cancel`] is called, skips the
|
||||
//! rest. Nothing changes for evaluations that are not watched.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
/// Shared between the worker evaluating a design and the window showing its progress.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Progress {
|
||||
cancelled: AtomicBool,
|
||||
done: AtomicUsize,
|
||||
total: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
/// Ask the watched evaluation to stop at its next feature.
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::Relaxed);
|
||||
}
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Features evaluated so far and the timeline's length (0 before evaluation starts).
|
||||
pub fn features(&self) -> (usize, usize) {
|
||||
(self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed))
|
||||
}
|
||||
}
|
||||
|
||||
struct Current {
|
||||
progress: Arc<Progress>,
|
||||
/// Nested evaluations (derived designs) don't overwrite the outer timeline's count.
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CURRENT: RefCell<Option<Current>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Run `f` with `progress` watching every evaluation it makes on this thread.
|
||||
pub fn watch<R>(progress: Arc<Progress>, f: impl FnOnce() -> R) -> R {
|
||||
let previous = CURRENT.with(|c| c.replace(Some(Current { progress, depth: 0 })));
|
||||
struct Restore(Option<Option<Current>>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous) = self.0.take() {
|
||||
CURRENT.with(|c| *c.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _restore = Restore(Some(previous));
|
||||
f()
|
||||
}
|
||||
|
||||
/// Entered at the start of an evaluation; the guard leaves it.
|
||||
pub(crate) struct Evaluation {
|
||||
outer: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn begin(total: usize) -> Evaluation {
|
||||
let outer = CURRENT.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
let Some(cur) = c.as_mut() else { return false };
|
||||
cur.depth += 1;
|
||||
if cur.depth == 1 {
|
||||
cur.progress.total.store(total, Ordering::Relaxed);
|
||||
cur.progress.done.store(0, Ordering::Relaxed);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
Evaluation { outer }
|
||||
}
|
||||
|
||||
impl Evaluation {
|
||||
/// Feature `index` is next. True when the watcher asked to stop: skip it.
|
||||
pub(crate) fn step(&self, index: usize) -> bool {
|
||||
CURRENT.with(|c| {
|
||||
let c = c.borrow();
|
||||
let Some(cur) = c.as_ref() else { return false };
|
||||
if self.outer {
|
||||
cur.progress.done.store(index, Ordering::Relaxed);
|
||||
}
|
||||
cur.progress.is_cancelled()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Evaluation {
|
||||
fn drop(&mut self) {
|
||||
CURRENT.with(|c| {
|
||||
if let Some(cur) = c.borrow_mut().as_mut() {
|
||||
cur.depth = cur.depth.saturating_sub(1);
|
||||
if self.outer {
|
||||
let total = cur.progress.total.load(Ordering::Relaxed);
|
||||
cur.progress.done.store(total, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unwatched_evaluations_never_stop_and_watched_ones_report_and_stop() {
|
||||
let e = begin(3);
|
||||
assert!(!e.step(0));
|
||||
drop(e);
|
||||
let p = Arc::new(Progress::default());
|
||||
watch(p.clone(), || {
|
||||
let e = begin(5);
|
||||
assert!(!e.step(2));
|
||||
assert_eq!(p.features(), (2, 5));
|
||||
{
|
||||
let inner = begin(100);
|
||||
assert!(!inner.step(50));
|
||||
}
|
||||
assert_eq!(p.features(), (2, 5), "a nested evaluation keeps the outer count");
|
||||
p.cancel();
|
||||
assert!(e.step(3));
|
||||
});
|
||||
assert_eq!(p.features(), (5, 5));
|
||||
assert!(!begin(1).step(0), "the watch ends with its scope");
|
||||
}
|
||||
}
|
||||
@@ -262,7 +262,7 @@ fn resolve_uncached(
|
||||
LinkSource::SketchAxis { name } => {
|
||||
vec![solidcraft_sketch::sketch_axis_geom(name).ok_or_else(|| DocError::Unknown(format!("sketch axis `{name}`")))?]
|
||||
}
|
||||
LinkSource::Text { text, at, height, angle } => solidcraft_sketch::text_geometry(text, *at, *height, *angle)?,
|
||||
LinkSource::Text { text, at, height, angle, font } => solidcraft_sketch::text_geometry_in(font.as_deref(), text, *at, *height, *angle)?,
|
||||
LinkSource::Plane { name } => {
|
||||
let pr =
|
||||
if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } };
|
||||
|
||||
+1940
-42
File diff suppressed because it is too large.
Load diff
@@ -88,3 +88,68 @@ fn struts_ending_on_a_bore_wall_join_without_a_panic() {
|
||||
result.unwrap();
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
}
|
||||
|
||||
/// A blind Ø2 hole into the flat back of a long D-profile bar ends 2 mm short of the round front,
|
||||
/// yet the cut was refused (#544). It removes π · 1² · 10 mm³ (within 5 %: the measure tessellates
|
||||
/// the small bore) and adds the bore's wall and floor to the bar's six faces.
|
||||
#[test]
|
||||
fn blind_hole_into_a_long_d_profile_bar_stops_short_of_the_round_front() {
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/blind_hole_in_long_d_profile.json")).unwrap();
|
||||
let steps = script.get("commands").and_then(Value::as_array).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.run_script(&Value::Array(steps[..5].to_vec())).unwrap();
|
||||
let bar = volume(&s, "Slat");
|
||||
s.run_script(&Value::Array(steps[5..].to_vec())).unwrap();
|
||||
let hole = std::f64::consts::PI * 10.0;
|
||||
let removed = bar - volume(&s, "Slat");
|
||||
assert!((removed - hole).abs() < 0.05 * hole, "removed {removed} vs {hole}");
|
||||
let faces = s.model.state().body("Slat").map(|b| solidcraft_kernel::measure(&b.body).unwrap().merged.faces);
|
||||
assert_eq!(faces, Some(8));
|
||||
}
|
||||
|
||||
/// A ring pocket cut from below a recess leaves a 0.5 mm floor joining the outer wall to the inner
|
||||
/// block: one connected part, yet the cut left two bodies and lost volume (#545).
|
||||
#[test]
|
||||
fn ring_cut_below_a_recess_keeps_the_part_in_one_body() {
|
||||
let s = replay(include_str!("../../../examples/regressions/ring_cut_below_recess_thin_floor.json"));
|
||||
let bodies: Vec<(String, f64)> = s.model.state().bodies.iter().map(|b| (b.name.clone(), volume(&s, &b.name))).collect();
|
||||
let want = 100.0 * 100.0 * 30.0 - 88.0 * 88.0 * 12.0 - (95.0 * 95.0 - 68.0 * 68.0) * 17.5;
|
||||
assert_eq!(bodies.len(), 1, "{bodies:?}");
|
||||
let got = volume(&s, "Frame");
|
||||
assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}");
|
||||
}
|
||||
|
||||
/// Four ribs joined on a rounded lid, standing on the plate over the skirt and flush with the
|
||||
/// window's sides: each adds exactly its own material and nothing changes away from it. The
|
||||
/// joins displaced the tool and one left a slit through the far side of the skirt and plate
|
||||
/// (#616), which no volume shows: the area does. The reply of each join carries its warning, as
|
||||
/// the timeline does (an agent placing parts over MCP saw none).
|
||||
#[test]
|
||||
fn ribs_flush_with_a_window_change_nothing_away_from_them() {
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/lid_ribs_flush_with_window.json")).unwrap();
|
||||
let steps = script.get("commands").and_then(Value::as_array).unwrap();
|
||||
let (lid, ribs) = steps.split_at(steps.len() - 4);
|
||||
let mut s = Session::default();
|
||||
s.run_script(&Value::Array(lid.to_vec())).unwrap();
|
||||
let measure = |s: &Session| s.model.state().body("Lid").map(|b| solidcraft_kernel::measure(&b.body).unwrap()).unwrap();
|
||||
let (v0, a0) = (measure(&s).volume, measure(&s).area);
|
||||
assert!((v0 - 6703.5222).abs() < 1e-3, "{v0}");
|
||||
for (i, rib) in ribs.iter().enumerate() {
|
||||
let reply = s.execute(rib["command"].as_str().unwrap(), &rib["params"]).unwrap();
|
||||
let id = reply["feature"].as_u64().unwrap();
|
||||
let result = s.model.result(id).unwrap();
|
||||
assert_eq!(reply["warning"], json!(result.warning), "the reply says what the timeline says");
|
||||
assert!(!result.warning.as_deref().unwrap_or_default().contains("tool displacement"), "{:?}", result.warning);
|
||||
let m = measure(&s);
|
||||
let n = (i + 1) as f64;
|
||||
// 1.6 x 6 x 11.7 less the 1.35 x 6 x 3 inside the skirt.
|
||||
assert!((m.volume - (v0 + n * 88.02)).abs() < 1e-6, "rib {}: {}", i + 1, m.volume - v0);
|
||||
// Top 9.6, outer side 6 x 8.7 above the skirt, inner side 6 x 11.7 (flush with the
|
||||
// window's side), ends 2 x (1.6 x 11.7 - 1.35 x 3); the skirt top 8.1, its inner side
|
||||
// 18 and 1.5 of the plate go under the rib.
|
||||
let area = 9.6 + 52.2 + 70.2 + 29.34 - 8.1 - 18.0 - 1.5;
|
||||
assert!((m.area - (a0 + n * area)).abs() < 1e-6, "rib {}: area {} (no slit adds any)", i + 1, m.area - a0);
|
||||
}
|
||||
let bounds = s.model.state().body("Lid").unwrap().body.tessellate(0.01).unwrap().bounds();
|
||||
assert!((bounds.max.z - 14.1).abs() < 1e-9, "{}", bounds.max.z);
|
||||
}
|
||||
@@ -33,10 +33,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"),
|
||||
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).noundo().doc_only(),
|
||||
CommandSpec::new("view.apply", "Restore Named View", view_apply).icon("camera").noundo().params(
|
||||
"view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (radians, mm) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step",
|
||||
),
|
||||
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
|
||||
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
|
||||
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
|
||||
or folder?: bodies|sketches|construction|origin (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). View state: not saved, not an undo step",
|
||||
or folder?: bodies|sketches|construction|origin (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design; not an undo step",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -375,6 +378,38 @@ fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "view": name }))
|
||||
}
|
||||
|
||||
fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "view.apply";
|
||||
let cam = match (str_(p, "view"), p.get("camera")) {
|
||||
(Some(_), Some(_)) => return Err(bad(cmd, "give `view` or `camera`, not both")),
|
||||
(None, None) => return Err(bad(cmd, "`view` (a name) or `camera` is required")),
|
||||
(None, Some(c)) => {
|
||||
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
|
||||
camera_of(&v)
|
||||
}
|
||||
// Standard view names come first, as in the browser's Named Views folder.
|
||||
(Some(name), None) => match crate::render::StandardView::parse(name) {
|
||||
Some(sv) => {
|
||||
let mut c = crate::view::home_camera(s);
|
||||
c.set_view(sv);
|
||||
c.fit(&crate::view::bounds(s));
|
||||
c
|
||||
}
|
||||
None => s.doc.named_views.iter().find(|v| v.name == name).map(camera_of).ok_or_else(|| bad(cmd, format!("no named view `{name}`")))?,
|
||||
},
|
||||
};
|
||||
if !cam.is_valid() || !cam.fov.is_finite() || cam.fov < 0.0 {
|
||||
return Err(bad(cmd, "the camera must be finite with a positive distance"));
|
||||
}
|
||||
s.camera = Some(cam);
|
||||
Ok(json!({ "camera": cam }))
|
||||
}
|
||||
|
||||
/// The orbit camera of a named view (Z up, no roll).
|
||||
fn camera_of(v: &solidcraft_doc::NamedView) -> crate::render::Camera {
|
||||
crate::render::Camera { target: v.target, yaw: v.yaw, pitch: v.pitch, distance: v.distance, fov: v.fov, ..Default::default() }
|
||||
}
|
||||
|
||||
fn view_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let k = str_(p, "view").ok_or_else(|| bad(cmd, "`view` must be a view name"))?;
|
||||
s.doc.named_views.iter().position(|v| v.name == k).ok_or_else(|| bad(cmd, format!("no named view `{k}`")))
|
||||
@@ -724,6 +759,35 @@ mod tests {
|
||||
assert_eq!(s.doc.param("w").map(|p| p.expr.as_str()), Some("2 in"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_named_view_or_camera_moves_the_view() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 10}));
|
||||
run(&mut s, "view.save", json!({"name": "Photo", "camera": {"target": [1, 2, 3], "yaw": 0.5, "pitch": -0.6, "distance": 150}}));
|
||||
let undo = s.undo.len();
|
||||
assert!(s.camera.is_none());
|
||||
let r = run(&mut s, "view.apply", json!({"view": "Photo"}));
|
||||
let c = s.camera.expect("a camera for the view");
|
||||
assert_eq!((c.target, c.yaw, c.pitch, c.distance), (solidcraft_geom::Vec3::new(1.0, 2.0, 3.0), 0.5, -0.6, 150.0));
|
||||
assert_eq!(r["camera"]["yaw"], 0.5);
|
||||
// A standard view looks from its direction and frames the model.
|
||||
run(&mut s, "view.apply", json!({"view": "top"}));
|
||||
let c = s.camera.expect("a camera for the top view");
|
||||
assert!((c.pitch - std::f64::consts::FRAC_PI_2).abs() < 1e-3 && (c.target.x - 20.0).abs() < 1e-6, "{c:?}");
|
||||
run(&mut s, "view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 1, "pitch": 0.2, "distance": 80, "fov": 0.5}}));
|
||||
assert_eq!(s.camera.map(|c| (c.yaw, c.distance, c.fov)), Some((1.0, 80.0, 0.5)));
|
||||
assert_eq!(s.undo.len(), undo, "view state, not an undo step");
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"view": "Nope"}),
|
||||
json!({"view": "Photo", "camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 1}}),
|
||||
json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0}}),
|
||||
json!({"camera": "x"}),
|
||||
] {
|
||||
assert!(s.execute("view.apply", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_views_are_saved_renamed_and_deleted() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Linked designs: a component whose bodies come from another design file (Insert Design).
|
||||
//!
|
||||
//! The linked design is opened and evaluated on its own, and its solid bodies, as placed in it,
|
||||
//! join a new component through an Import feature (exact B-rep, kept in this design so it opens
|
||||
//! without the part file). The link remembers the file, relative to this design's folder once it
|
||||
//! is saved; Update Links reads the file again and replaces the Import feature's bodies, and
|
||||
//! Break Link keeps the bodies and forgets the file.
|
||||
|
||||
use std::path::{Component as PathPart, Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{DesignLink, Document, FeatureKind};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.insert_design", "Insert Design", insert_design).icon("component").params(
|
||||
"path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design; \
|
||||
name? (default: the design's name); parent?: component id or name (default: the active one); translate?: [x,y,z]",
|
||||
),
|
||||
CommandSpec::new("component.update_links", "Update Links", update_links).params(
|
||||
"component?: id or name (default: every linked component) — reads the linked designs again and replaces their bodies where they changed",
|
||||
),
|
||||
CommandSpec::new("component.break_link", "Break Link", break_link)
|
||||
.params("component: id or name of a linked component — keeps its bodies as they are and forgets the design file"),
|
||||
CommandSpec::new("component.links", "List Links", list_links)
|
||||
.noundo()
|
||||
.params("→ linked components: component, name, path, file (resolved), found"),
|
||||
];
|
||||
|
||||
/// The folder relative link paths are kept against: the design's own, once it has a file.
|
||||
fn base_dir(path: Option<&str>) -> Option<PathBuf> {
|
||||
path.and_then(|p| absolute(Path::new(p)).parent().map(Path::to_path_buf))
|
||||
}
|
||||
|
||||
/// A path as given, made absolute when the platform can, with `.` and `..` worked out.
|
||||
fn absolute(p: &Path) -> PathBuf {
|
||||
let full = std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf());
|
||||
let mut out = PathBuf::new();
|
||||
for c in full.components() {
|
||||
match c {
|
||||
PathPart::CurDir => {}
|
||||
PathPart::ParentDir if matches!(out.components().next_back(), Some(PathPart::Normal(_))) => {
|
||||
out.pop();
|
||||
}
|
||||
c => out.push(c.as_os_str()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `target` relative to the folder `base` (both absolute), when they share a root.
|
||||
fn relative(base: &Path, target: &Path) -> Option<PathBuf> {
|
||||
let (b, t): (Vec<_>, Vec<_>) = (base.components().collect(), target.components().collect());
|
||||
let root = |c: &[PathPart]| c.iter().take_while(|x| matches!(x, PathPart::Prefix(_) | PathPart::RootDir)).count();
|
||||
if root(&b) == 0 || b.get(..root(&b)) != t.get(..root(&b)) {
|
||||
return None;
|
||||
}
|
||||
let common = b.iter().zip(&t).take_while(|(x, y)| x == y).count();
|
||||
let mut out = PathBuf::new();
|
||||
for _ in common..b.len() {
|
||||
out.push("..");
|
||||
}
|
||||
for c in t.iter().skip(common) {
|
||||
out.push(c.as_os_str());
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// How a link stores `file` for a design saved at `design` (`None`: not saved yet).
|
||||
fn stored(design: Option<&str>, file: &Path) -> String {
|
||||
let file = absolute(file);
|
||||
match base_dir(design).and_then(|d| relative(&d, &file)) {
|
||||
// Relative paths are written with `/`, so a design moves between systems with its parts.
|
||||
Some(rel) => rel.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::<Vec<_>>().join("/"),
|
||||
None => file.to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file a stored link path names, for a design saved at `design`.
|
||||
fn resolve(design: Option<&str>, link: &str) -> PathBuf {
|
||||
let p = Path::new(link);
|
||||
match base_dir(design) {
|
||||
Some(d) if p.is_relative() => d.join(p),
|
||||
_ => p.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The links of `doc` stored for a design saved at `to` instead of at `from`.
|
||||
pub(super) fn rebased(doc: &Document, from: Option<&str>, to: &str) -> std::collections::BTreeMap<u64, DesignLink> {
|
||||
doc.links.iter().map(|(c, l)| (*c, DesignLink { path: stored(Some(to), &resolve(from, &l.path)), feature: l.feature })).collect()
|
||||
}
|
||||
|
||||
/// The solid bodies of the design at `file`, evaluated, as an Import feature.
|
||||
fn linked_bodies(file: &Path, own: Option<&str>) -> Result<(String, solidcraft_io::StepFeature)> {
|
||||
let shown = file.display().to_string();
|
||||
if own.is_some_and(|o| absolute(Path::new(o)) == absolute(file)) {
|
||||
return Err(EngineError::Other(format!("{shown}: a design can't be linked into itself")));
|
||||
}
|
||||
let bytes = solidcraft_io::vfs::read(&shown, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
let doc = solidcraft_io::read_design(&bytes).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
let name = doc.name.clone();
|
||||
let part = Session::new(doc);
|
||||
if let Some(e) = part.model.results.iter().find_map(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(format!("{shown}: the design has a feature that fails ({e}); fix it before linking it")));
|
||||
}
|
||||
let world = part.world_state();
|
||||
let solids: Vec<String> = world.bodies.iter().filter(|b| !b.body.is_mesh()).map(|b| b.name.clone()).collect();
|
||||
if solids.is_empty() {
|
||||
return Err(EngineError::Other(format!("{shown}: the design has no solid bodies to link")));
|
||||
}
|
||||
let step = solidcraft_io::export(&part.doc.painted(&world), &solids, solidcraft_io::Format::Step, &name)
|
||||
.map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
let mut feature = solidcraft_io::step_import_feature(&step, &shown).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
if world.bodies.len() > solids.len() {
|
||||
feature.warnings.push(format!("{} mesh bodies of {shown} are not linked", world.bodies.len() - solids.len()));
|
||||
}
|
||||
Ok((name, feature))
|
||||
}
|
||||
|
||||
fn component_key(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<u64> {
|
||||
let k = match p.get(key) {
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
_ => return Err(bad(cmd, format!("`{key}` must be a component id or name"))),
|
||||
};
|
||||
s.doc.find_component(&k).ok_or_else(|| bad(cmd, format!("no component `{k}`")))
|
||||
}
|
||||
|
||||
fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.insert_design";
|
||||
let given = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))?;
|
||||
let translate = match p.get("translate") {
|
||||
None => solidcraft_geom::Vec3::ZERO,
|
||||
Some(v) => vec3(v).filter(|t| t.is_finite() && t.len() < 1e9).ok_or_else(|| bad(cmd, "`translate` must be [x, y, z] in mm"))?,
|
||||
};
|
||||
let file = absolute(Path::new(given));
|
||||
let (design_name, sf) = linked_bodies(&file, s.path.as_deref())?;
|
||||
let name = str_(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string).unwrap_or(design_name);
|
||||
let mut create = json!({"name": name, "activate": false});
|
||||
if let Some(parent) = p.get("parent") {
|
||||
create["parent"] = parent.clone();
|
||||
}
|
||||
let made = s.execute("component.create", &create)?;
|
||||
let comp = made["component"].as_u64().ok_or_else(|| bad(cmd, "the component was not created"))?;
|
||||
let occ = made["occurrence"].as_u64();
|
||||
let id = s.doc_mut().add_feature(sf.kind, Some(&name))?;
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
f.component = comp;
|
||||
f.body_names = sf.body_names;
|
||||
}
|
||||
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| Some(o.id) == occ) {
|
||||
o.transform[3] = [translate.x, translate.y, translate.z, 1.0];
|
||||
}
|
||||
let path = stored(s.path.as_deref(), &file);
|
||||
s.doc_mut().links.insert(comp, DesignLink { path: path.clone(), feature: id });
|
||||
s.refresh();
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
|
||||
Ok(
|
||||
json!({"component": comp, "occurrence": occ, "name": name, "feature": id, "path": path, "bodies": bodies, "created_bodies": bodies, "warnings": sf.warnings}),
|
||||
)
|
||||
}
|
||||
|
||||
fn update_links(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.update_links";
|
||||
let only = if p.get("component").is_some() { Some(component_key(s, p, "component", cmd)?) } else { None };
|
||||
if let Some(c) = only
|
||||
&& !s.doc.links.contains_key(&c)
|
||||
{
|
||||
return Err(bad(cmd, "that component is not linked to a design"));
|
||||
}
|
||||
let links: Vec<(u64, DesignLink)> = s.doc.links.iter().filter(|(c, _)| only.is_none_or(|o| o == **c)).map(|(c, l)| (*c, l.clone())).collect();
|
||||
let (mut updated, mut unchanged, mut warnings) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for (comp, link) in links {
|
||||
let cname = s.doc.components.iter().find(|c| c.id == comp).map(|c| c.name.clone());
|
||||
let Some(cname) = cname else {
|
||||
// The component was deleted: the link goes with it.
|
||||
s.doc_mut().links.remove(&comp);
|
||||
continue;
|
||||
};
|
||||
let Some(FeatureKind::Import { step: old, .. }) = s.doc.feature(link.feature).filter(|f| f.component == comp).map(|f| &f.kind) else {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("{cname}: its linked bodies (feature {}) are gone; break the link or insert the design again", link.feature),
|
||||
));
|
||||
};
|
||||
let old = old.clone();
|
||||
let file = resolve(s.path.as_deref(), &link.path);
|
||||
let (_, sf) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, format!("{cname}: {e}")))?;
|
||||
warnings.extend(sf.warnings.iter().cloned());
|
||||
if matches!(&sf.kind, FeatureKind::Import { step, .. } if *step == old) {
|
||||
unchanged.push(cname);
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = s.doc_mut().feature_mut(link.feature) {
|
||||
// Bodies keep their names where the design still has as many.
|
||||
if f.body_names.len() != sf.body_names.len() {
|
||||
f.body_names = sf.body_names;
|
||||
}
|
||||
f.kind = sf.kind;
|
||||
}
|
||||
updated.push(cname);
|
||||
}
|
||||
s.refresh();
|
||||
if let Some(e) = s.doc.links.values().find_map(|l| s.model.result(l.feature).and_then(|r| r.error.clone())) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
Ok(json!({"updated": updated, "unchanged": unchanged, "warnings": warnings}))
|
||||
}
|
||||
|
||||
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.break_link";
|
||||
let comp = component_key(s, p, "component", cmd)?;
|
||||
let link = s.doc_mut().links.remove(&comp).ok_or_else(|| bad(cmd, "that component is not linked to a design"))?;
|
||||
Ok(json!({"component": comp, "path": link.path}))
|
||||
}
|
||||
|
||||
fn list_links(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let links: Vec<Value> = s
|
||||
.doc
|
||||
.links
|
||||
.iter()
|
||||
.map(|(c, l)| {
|
||||
let file = resolve(s.path.as_deref(), &l.path);
|
||||
let name = s.doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone());
|
||||
json!({"component": c, "name": name, "path": l.path, "file": file.display().to_string(), "found": file.is_file()})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"links": links}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn link_paths_are_kept_relative_to_the_design_and_follow_save_as() {
|
||||
let root = absolute(Path::new("projects"));
|
||||
let part = root.join("parts").join("plate.solidcraft");
|
||||
let design = root.join("assembly").join("case.solidcraft");
|
||||
let design = design.to_string_lossy().into_owned();
|
||||
assert_eq!(stored(Some(&design), &part), "../parts/plate.solidcraft");
|
||||
assert_eq!(absolute(&resolve(Some(&design), "../parts/plate.solidcraft")), part);
|
||||
// A design path given relative to the working folder works the same.
|
||||
assert_eq!(stored(Some("case.solidcraft"), &absolute(Path::new("parts/plate.solidcraft"))), "parts/plate.solidcraft");
|
||||
// Before the design has a file the path is kept whole; saving it makes it relative.
|
||||
let unsaved = stored(None, &part);
|
||||
assert!(Path::new(&unsaved).is_absolute(), "{unsaved}");
|
||||
let mut doc = Document::default();
|
||||
doc.links.insert(7, DesignLink { path: unsaved, feature: 3 });
|
||||
let moved = rebased(&doc, None, &design);
|
||||
assert_eq!(moved.get(&7).map(|l| l.path.as_str()), Some("../parts/plate.solidcraft"));
|
||||
let beside = root.join("parts").join("asm.solidcraft").to_string_lossy().into_owned();
|
||||
assert_eq!(
|
||||
rebased(&Document { links: moved, ..Document::default() }, Some(&design), &beside).get(&7).map(|l| l.path.as_str()),
|
||||
Some("plate.solidcraft")
|
||||
);
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn save_box(path: &Path, height: f64) {
|
||||
let mut part = Session::default();
|
||||
run(&mut part, "solid.box", json!({"length": 10, "width": 20, "height": height, "body_name": "Plate"}));
|
||||
run(&mut part, "file.save", json!({"path": path}));
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_assembly_reopens_with_its_link_updates_it_and_breaks_it() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-link-reopen-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("parts")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("asm")).unwrap();
|
||||
let part = dir.join("parts").join("plate.solidcraft");
|
||||
let case = dir.join("asm").join("case.solidcraft");
|
||||
save_box(&part, 30.0);
|
||||
// Given an assembly holding the part, saved in a folder beside the part's.
|
||||
let mut a = Session::default();
|
||||
let inserted = run(&mut a, "component.insert_design", json!({"path": part}));
|
||||
assert_eq!(inserted["name"], "plate");
|
||||
run(&mut a, "file.save", json!({"path": case}));
|
||||
assert!((volume(&mut a) - 6000.0).abs() < 1e-6);
|
||||
// When it is opened again, the link is kept relative to it and finds the part.
|
||||
let mut b = Session::default();
|
||||
run(&mut b, "doc.open", json!({"path": case}));
|
||||
let links = run(&mut b, "component.links", json!({}));
|
||||
assert_eq!(links["links"][0]["path"], "../parts/plate.solidcraft", "{links}");
|
||||
assert_eq!(links["links"][0]["found"], true, "{links}");
|
||||
assert!((volume(&mut b) - 6000.0).abs() < 1e-6);
|
||||
// An unchanged part leaves the assembly as it is; an edited one comes in on update.
|
||||
assert_eq!(run(&mut b, "component.update_links", json!({}))["unchanged"], json!(["plate"]));
|
||||
save_box(&part, 45.0);
|
||||
assert_eq!(run(&mut b, "component.update_links", json!({}))["updated"], json!(["plate"]));
|
||||
assert!((volume(&mut b) - 9000.0).abs() < 1e-6, "{}", volume(&mut b));
|
||||
// A part file that has gone is reported, and the assembly keeps the bodies it had.
|
||||
std::fs::remove_file(&part).unwrap();
|
||||
let err = b.execute("component.update_links", &json!({})).unwrap_err().to_string();
|
||||
assert!(err.contains("plate"), "{err}");
|
||||
assert!((volume(&mut b) - 9000.0).abs() < 1e-6);
|
||||
// Break Link keeps the bodies and forgets the file.
|
||||
run(&mut b, "component.break_link", json!({"component": "plate"}));
|
||||
assert_eq!(run(&mut b, "component.links", json!({}))["links"], json!([]));
|
||||
assert!((volume(&mut b) - 9000.0).abs() < 1e-6);
|
||||
assert!(b.execute("component.update_links", &json!({"component": "plate"})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_a_missing_file_or_bad_parameters_changes_nothing() {
|
||||
let mut s = Session::default();
|
||||
let missing = std::env::temp_dir().join(format!("solidcraft-no-such-part-{}.solidcraft", std::process::id()));
|
||||
assert!(s.execute("component.insert_design", &json!({"path": missing})).is_err());
|
||||
assert!(s.execute("component.insert_design", &json!({})).is_err());
|
||||
assert!(s.execute("component.insert_design", &json!({"path": "x.solidcraft", "translate": [1, "a", 2]})).is_err());
|
||||
assert!(s.doc.components.is_empty() && s.doc.links.is_empty() && s.doc.features.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -127,3 +127,175 @@ fn excessively_nested_assembly_refuses_partial_totals() {
|
||||
let error = s.execute("component.properties", &json!({"component": 0})).unwrap_err();
|
||||
assert!(error.to_string().contains("traversal limits"), "{error}");
|
||||
}
|
||||
|
||||
/// #532: a product BOM needs materials outside the built-in library (walnut, polypropylene,
|
||||
/// rubber, leather). A user-defined material with its density is assigned like a built-in one
|
||||
/// and sets the body's mass: a 10 mm walnut cube at 0.65 g/cm³ weighs 0.65 g.
|
||||
#[test]
|
||||
fn user_defined_material_sets_the_body_mass() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Slat"}));
|
||||
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Slat"], "material": "Walnut"}));
|
||||
let props = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
assert_eq!(props["bodies"][0]["material"], "Walnut", "{props}");
|
||||
close(&props["bodies"][0]["density_g_cm3"], 0.65);
|
||||
close(&props["total"]["mass_g"], 0.65);
|
||||
}
|
||||
|
||||
/// A design file path in a fresh temporary folder (removed by the caller).
|
||||
fn temp_design(tag: &str) -> (std::path::PathBuf, String) {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-user-material-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("design.solidcraft").to_string_lossy().to_string();
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
fn mass(s: &mut Session) -> Value {
|
||||
run(s, "component.properties", json!({"component": "root"}))["total"]["mass_g"].clone()
|
||||
}
|
||||
|
||||
/// #532: a design's own material is listed after the library, gives its bodies its look, and
|
||||
/// can be edited (density and name: the bodies follow) and deleted; each step is undoable.
|
||||
#[test]
|
||||
fn user_defined_material_is_edited_renamed_and_deleted() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Pad"}));
|
||||
assert!(s.execute("material.edit", &json!({"material": "Rubber", "density_g_cm3": 1.2})).is_err(), "nothing to edit yet");
|
||||
let created = run(&mut s, "material.create", json!({"name": "Rubber", "density_g_cm3": 1.5, "color": "#1c1c1c"}));
|
||||
assert_eq!(created["appearance"]["color"], "#1c1c1c", "{created}");
|
||||
let list = run(&mut s, "material.list", json!({}));
|
||||
let materials = list["materials"].as_array().unwrap();
|
||||
assert_eq!(materials.len(), solidcraft_doc::MATERIALS.len() + 1);
|
||||
let rubber = materials.last().unwrap();
|
||||
assert_eq!((rubber["name"].as_str(), rubber["user"].as_bool()), (Some("Rubber"), Some(true)));
|
||||
|
||||
// Assigned (in any case), it sets the mass and the body's colour.
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Pad"], "material": "rubber"}));
|
||||
close(&mass(&mut s), 1.5);
|
||||
let pad = s.model.state().body("Pad").unwrap().clone();
|
||||
let grey = 28.0 / 255.0;
|
||||
assert_eq!(s.doc.body_color(&pad), Some([grey, grey, grey]));
|
||||
|
||||
// A new density changes the mass; a new name carries the body with it.
|
||||
run(&mut s, "material.edit", json!({"material": "Rubber", "density_g_cm3": 1.2, "name": "Foot Rubber"}));
|
||||
close(&mass(&mut s), 1.2);
|
||||
let props = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
assert_eq!(props["bodies"][0]["material"], "Foot Rubber");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
close(&mass(&mut s), 1.5);
|
||||
assert_eq!(s.doc.materials.get("Pad").map(String::as_str), Some("Rubber"));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
|
||||
// A material in use is not deleted unless its bodies get another one.
|
||||
let refused = s.execute("material.delete", &json!({"material": "Foot Rubber"})).unwrap_err().to_string();
|
||||
assert!(refused.contains("body `Pad`"), "{refused}");
|
||||
run(&mut s, "material.delete", json!({"material": "Foot Rubber", "replace": "Oak"}));
|
||||
close(&mass(&mut s), 0.75);
|
||||
assert!(s.doc.user_materials.is_empty());
|
||||
assert!(s.execute("material.assign", &json!({"bodies": ["Pad"], "material": "Foot Rubber"})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
close(&mass(&mut s), 1.2);
|
||||
}
|
||||
|
||||
/// #532: names and densities are checked before anything changes.
|
||||
#[test]
|
||||
fn user_defined_material_refuses_bad_names_and_densities() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
|
||||
let doc = s.doc.clone();
|
||||
for p in [
|
||||
json!({"name": "Steel", "density_g_cm3": 1.0}),
|
||||
json!({"name": "walnut", "density_g_cm3": 1.0}),
|
||||
json!({"name": " ", "density_g_cm3": 1.0}),
|
||||
json!({"name": "x".repeat(129), "density_g_cm3": 1.0}),
|
||||
json!({"name": "Leather", "density_g_cm3": 0}),
|
||||
json!({"name": "Leather", "density_g_cm3": -1}),
|
||||
json!({"name": "Leather", "density_g_cm3": 1e9}),
|
||||
json!({"name": "Leather", "density_g_cm3": "heavy"}),
|
||||
json!({"name": "Leather"}),
|
||||
json!({"name": "Leather", "density_g_cm3": 0.9, "color": "#zzzzzz"}),
|
||||
json!({"name": "Leather", "density_g_cm3": 0.9, "appearance": "No Such Look"}),
|
||||
json!({"name": "Leather", "density_g_cm3": 0.9, "opacity": 0.5}),
|
||||
] {
|
||||
assert!(s.execute("material.create", &p).is_err(), "{p}");
|
||||
}
|
||||
for p in [
|
||||
json!({"material": "Steel", "density_g_cm3": 1.0}),
|
||||
json!({"material": "Walnut", "name": "Oak"}),
|
||||
json!({"material": "Walnut", "density_g_cm3": 0}),
|
||||
] {
|
||||
assert!(s.execute("material.edit", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(*s.doc, *doc);
|
||||
}
|
||||
|
||||
/// #532: a design's materials are saved with it: after reopening, the bodies keep their
|
||||
/// material, its density and its look.
|
||||
#[test]
|
||||
fn user_defined_material_survives_save_and_reopen() {
|
||||
let (dir, path) = temp_design("saved");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Tab"}));
|
||||
run(&mut s, "material.create", json!({"name": "Leather", "density_g_cm3": 0.86, "appearance": "Rubber - Black"}));
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Tab"], "material": "Leather"}));
|
||||
run(&mut s, "file.save", json!({"path": path}));
|
||||
assert!(s.doc.to_json().contains("\"solidcraft/16\""));
|
||||
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
let props = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
assert_eq!(props["bodies"][0]["material"], "Leather", "{props}");
|
||||
close(&props["total"]["mass_g"], 0.86);
|
||||
assert_eq!(s.doc.material_look("Leather").map(|l| l.name), Some("Rubber - Black".to_string()));
|
||||
run(&mut s, "material.edit", json!({"material": "Leather", "density_g_cm3": 0.9}));
|
||||
close(&mass(&mut s), 0.9);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #532: designs from before user materials open unchanged: built-in assignments keep their
|
||||
/// densities and a design without its own materials saves no `user_materials`. A damaged
|
||||
/// file's unusable density falls back to the default instead of producing nonsense masses.
|
||||
#[test]
|
||||
fn designs_without_user_materials_open_unchanged() {
|
||||
let (dir, path) = temp_design("legacy");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Plate"}));
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Plate"], "material": "Steel"}));
|
||||
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
assert!(legacy.get("user_materials").is_none());
|
||||
legacy["format"] = json!("solidcraft/16");
|
||||
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
close(&mass(&mut s), 7.85);
|
||||
assert!(s.doc.user_materials.is_empty());
|
||||
assert!(s.execute("material.delete", &json!({"material": "Steel"})).is_err(), "no design materials: nothing to delete");
|
||||
|
||||
legacy["user_materials"] = json!([{"name": "Broken", "density": -3.0, "appearance": {"name": "Broken", "color": [1, 2, 3], "opacity": 9.0}}]);
|
||||
legacy["materials"] = json!({"Plate": "Broken"});
|
||||
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
close(&mass(&mut s), solidcraft_doc::DEFAULT_DENSITY);
|
||||
assert_eq!(s.doc.material_look("Broken").map(|l| l.opacity), Some(1.0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #532: configuration material cells take a design's own material and follow its rename.
|
||||
#[test]
|
||||
fn configurations_switch_to_a_user_defined_material() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Slat"}));
|
||||
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
|
||||
run(&mut s, "config.configure", json!({"materials": ["Slat"]}));
|
||||
run(&mut s, "config.row", json!({"name": "Walnut", "values": {"material:Slat": "walnut"}}));
|
||||
run(&mut s, "config.activate", json!({"row": "Walnut"}));
|
||||
close(&mass(&mut s), 0.65);
|
||||
run(&mut s, "material.edit", json!({"material": "Walnut", "name": "Black Walnut"}));
|
||||
run(&mut s, "config.activate", json!({"row": "Default"}));
|
||||
run(&mut s, "config.activate", json!({"row": "Walnut"}));
|
||||
assert_eq!(s.doc.materials.get("Slat").map(String::as_str), Some("Black Walnut"));
|
||||
run(&mut s, "material.delete", json!({"material": "Black Walnut", "replace": "Default"}));
|
||||
assert!(!s.doc.materials.contains_key("Slat"));
|
||||
}
|
||||
@@ -265,7 +265,7 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
|
||||
match s.doc.configs.columns.get(col) {
|
||||
Some(Column::Material(_)) => {
|
||||
text = solidcraft_doc::config::material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
|
||||
text = s.doc.material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
|
||||
}
|
||||
Some(Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_)) => {
|
||||
text = match solidcraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
|
||||
@@ -136,31 +136,42 @@ fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 40 x 30 x 20 box whose top is split at x = 20 and its left half raised 5 by Offset Face:
|
||||
/// the raised top has no construction certificate and no exact history name yet (#494).
|
||||
fn raised_half(s: &mut Session, body: &str) {
|
||||
run(s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":body}));
|
||||
run(s, "solid.split_face", json!({"faces":[[10,15,20]],"plane":{"origin":[20,0,0],"normal":[1,0,0]}}));
|
||||
run(s, "solid.offset_face", json!({"faces":[[10,15,20]],"distance":5}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let occurrence = component["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Stock"}));
|
||||
// A top merged from two joined boxes has no single certified source: the legacy path.
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"corner":[40,0,0],"operation":"join"}));
|
||||
// A face Offset Face raised has no construction certificate or exact history name yet:
|
||||
// the legacy path.
|
||||
raised_half(&mut s, "Stock");
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":false}));
|
||||
let state = s.model.state();
|
||||
let index = state
|
||||
// The raised half's top.
|
||||
let end = state
|
||||
.body("Stock")
|
||||
.unwrap()
|
||||
.body
|
||||
.faces(1e-4)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 20.0).abs() < 1e-6)
|
||||
.unwrap()
|
||||
.index;
|
||||
.into_iter()
|
||||
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 25.0).abs() < 1e-6)
|
||||
.unwrap();
|
||||
let before = s.doc.to_json();
|
||||
let error =
|
||||
super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,20],"body":"Stock","index":index,"path":[occurrence]}), "internal")
|
||||
.unwrap_err();
|
||||
let error = super::super::sketch::face::capture_internal(
|
||||
&s,
|
||||
&json!({"face":[40,15,25],"body":"Stock","index":end.index,"path":[occurrence]}),
|
||||
"internal",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("capture the source occurrence position"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
@@ -278,14 +289,14 @@ fn join_keeps_exact_face_attachments_from_both_operands() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_leaves_a_face_merged_from_both_operands_without_an_attachment() {
|
||||
fn join_gives_a_face_merged_from_both_operands_its_history_name() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10}));
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"corner":[20,0,0],"operation":"join"}));
|
||||
// Both boxes' tops share one support: no single construction identity owns it.
|
||||
let error = s.execute("sketch.create", &json!({"plane":{"face":[10,10,10]}})).unwrap_err().to_string();
|
||||
assert!(error.contains("fixed:true"), "{error}");
|
||||
run(&mut s, "sketch.create", json!({"plane":{"face":[10,10,10],"fixed":true}}));
|
||||
// Both boxes' tops share one support: no single construction certificate owns it, but its
|
||||
// exact history names it (#491).
|
||||
let top = run(&mut s, "sketch.create", json!({"plane":{"face":[10,10,10]}}))["sketch"].as_u64().unwrap();
|
||||
assert_eq!(face_anchor(&s, top).target.certificate, Some(solidcraft_doc::config::FaceCertificate::Lineage));
|
||||
// An unshared support keeps its box role.
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let side = run(&mut s, "sketch.create", json!({"plane":{"face":[0,10,5]}}))["sketch"].as_u64().unwrap();
|
||||
|
||||
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("fillet")
|
||||
.key("F")
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce (exact, the default: a radius too large for the geometry around the edges is refused; reduce: it is reduced to the largest that fits all of them, returned as `radius_applied` and noted in the feature's warning)"),
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning)"),
|
||||
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
|
||||
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
|
||||
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
||||
@@ -107,7 +107,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("presspull")
|
||||
.key("Q")
|
||||
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
|
||||
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"),
|
||||
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place)"),
|
||||
];
|
||||
|
||||
/// Where an extrude starts and which way is positive: a point inside the (first) profile or
|
||||
@@ -365,6 +365,8 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
|
||||
/// `add_feature`; `world` is false when `kind` is already in the active component's frame.
|
||||
fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> Result<Value> {
|
||||
let name = str_(p, "name");
|
||||
// The bodies before the feature, to say which ones it changed or removed.
|
||||
let before = s.model.state();
|
||||
// Commands speak world coordinates; the feature is authored in the active component.
|
||||
let mut kind = kind;
|
||||
if world {
|
||||
@@ -472,8 +474,20 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
}
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
// The bodies this feature made, apart from the ones it changed or that were there (#374).
|
||||
let created: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
|
||||
// Bodies that were there before and now have other geometry, and bodies that are gone.
|
||||
let old: std::collections::HashMap<&str, &solidcraft_doc::kernel::Body> = before.bodies.iter().map(|b| (b.name.as_str(), &b.body)).collect();
|
||||
let now: std::collections::HashSet<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
let changed: Vec<&str> =
|
||||
st.bodies.iter().filter(|b| b.feature != id && old.get(b.name.as_str()).is_some_and(|o| !o.same(&b.body))).map(|b| b.name.as_str()).collect();
|
||||
let removed: Vec<&str> = before.bodies.iter().map(|b| b.name.as_str()).filter(|n| !now.contains(n)).collect();
|
||||
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"feature": id, "name": fname, "bodies": bodies, "ms": r.map(|r| r.ms)}))
|
||||
// A warning (a Boolean that needed a retry, a reference found again elsewhere) is part of
|
||||
// the reply: a script or an agent sees it without asking the timeline (#616).
|
||||
let warning = r.as_ref().and_then(|r| r.warning.clone());
|
||||
Ok(json!({"feature": id, "name": fname, "bodies": bodies, "created_bodies": created, "changed_bodies": changed,
|
||||
"removed_bodies": removed, "warning": warning, "ms": r.map(|r| r.ms)}))
|
||||
}
|
||||
|
||||
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -796,11 +810,15 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "`fit: reduce` takes a constant radius"));
|
||||
}
|
||||
let mut out = add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string), style, fit })?;
|
||||
// The radius the fillet used, when `fit: reduce` reduced it.
|
||||
// The radii the fillet used, when `fit: reduce` reduced it.
|
||||
if let Some(id) = out.get("feature").and_then(Value::as_u64)
|
||||
&& let Some(r) = s.model.result(id).and_then(|res| res.state.fitted_radius(id))
|
||||
&& let Some(state) = s.model.result(id).map(|res| &res.state)
|
||||
&& let Some(r) = state.fitted_radius(id)
|
||||
{
|
||||
out["radius_applied"] = serde_json::json!(r);
|
||||
let each: Vec<Value> =
|
||||
state.fitted_edges(id).unwrap_or_default().into_iter().map(|(p, r)| serde_json::json!({"edge": [p.x, p.y, p.z], "radius": r})).collect();
|
||||
out["radii_applied"] = Value::Array(each);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -875,7 +893,11 @@ fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(a) = &angle {
|
||||
check_expr(s, a, Kind::Angle, cmd, "angle")?;
|
||||
}
|
||||
add_feature(s, p, FeatureKind::Move { bodies, translate: t, rotate_axis: p.get("axis").and_then(vec3), angle })
|
||||
add_feature(
|
||||
s,
|
||||
p,
|
||||
FeatureKind::Move { bodies, translate: t, rotate_axis: p.get("axis").and_then(vec3), angle, copy: bool_(p, "copy").unwrap_or(false) },
|
||||
)
|
||||
}
|
||||
|
||||
fn source_features(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
@@ -1304,7 +1326,33 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] on the face to drill to"))?),
|
||||
None => None,
|
||||
};
|
||||
add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread, to })
|
||||
// A hole picked on a body of another component (a moved one, shown where it is placed) cuts
|
||||
// that body: the pick is already in that body's frame, and the component's own bodies alone
|
||||
// would leave nothing to cut.
|
||||
let mut p = p.clone();
|
||||
if points.is_none()
|
||||
&& p.get("participants").is_none()
|
||||
&& let Some(body) = body_of_other_component_at(s, position)
|
||||
{
|
||||
p["participants"] = json!([body]);
|
||||
}
|
||||
add_feature(s, &p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread, to })
|
||||
}
|
||||
|
||||
/// The body whose face is nearest `p` (within its pick tolerance), when it belongs to another
|
||||
/// component than the active one.
|
||||
fn body_of_other_component_at(s: &Session, p: Vec3) -> Option<String> {
|
||||
let st = s.model.state();
|
||||
let (_, body) = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| !b.body.is_mesh())
|
||||
.filter_map(|b| {
|
||||
let (d, _) = solidcraft_doc::appearance::nearest_face(b, p)?;
|
||||
(d <= solidcraft_doc::appearance::pick_tolerance(b)).then_some((d, b))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))?;
|
||||
(s.doc.model_body_component(body) != s.active_component).then(|| body.name.clone())
|
||||
}
|
||||
|
||||
/// The points of a sketch a hole drills at when only the sketch is given.
|
||||
@@ -1533,6 +1581,13 @@ mod offset_edit_guard_tests;
|
||||
#[path = "offset_plane_face_tests.rs"]
|
||||
mod offset_plane_face_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lineage_face_tests.rs"]
|
||||
mod lineage_face_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "stacked_face_tests.rs"]
|
||||
mod stacked_face_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "solid_move_copy_tests.rs"]
|
||||
mod solid_move_copy_tests;
|
||||
@@ -602,3 +602,79 @@ fn cylinder_auto_inference_refuses_ambiguity_curves_and_seam_crossing_atomically
|
||||
assert_eq!(exact_cylinder_volume(&s), volume);
|
||||
}
|
||||
}
|
||||
|
||||
/// The design from #396: a loft from a Ø12 circle on a 40 × 30 × 20 box's top to a hexagon 15 up.
|
||||
fn loft_on_box(op: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [20, 15]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 20, "body_name": "Box"}));
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 20, "name": "A"}));
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 35, "name": "B"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "A", "name": "L0"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "diameter": 12}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "B", "name": "L1"}));
|
||||
run(&mut s, "sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 4, "sides": 6}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "L0"}, {"sketch": "L1"}], "operation": op}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loft_joined_onto_a_box_exports_one_connected_mesh() {
|
||||
// #396: one body, but its two shells only touched, and the STL came out in two pieces.
|
||||
let mut apart = loft_on_box("new_body");
|
||||
let m = measure(&mut apart);
|
||||
assert_eq!(m["body_count"], 2);
|
||||
let loft = m["bodies"][1]["volume_mm3"].as_f64().unwrap();
|
||||
let mut s = loft_on_box("join");
|
||||
let m = measure(&mut s);
|
||||
assert_eq!(m["body_count"], 1);
|
||||
assert_eq!(m["bodies"][0]["kernel"]["shells"], 1);
|
||||
let v = volume(&mut s);
|
||||
assert!(rel(v, 24000.0 + loft) < 1e-5, "{v} vs {}", 24000.0 + loft);
|
||||
// The exported mesh, its vertices merged, is one connected piece.
|
||||
let mesh = s.model.state().body("Box").unwrap().body.tessellate(0.01).unwrap();
|
||||
let (positions, triangles) = solidcraft_io::weld(&mesh.positions, &mesh.triangles, 1e-4);
|
||||
let mut piece: Vec<usize> = (0..positions.len()).collect();
|
||||
let root = |piece: &mut Vec<usize>, mut i: usize| {
|
||||
while piece[i] != i {
|
||||
i = piece[i];
|
||||
}
|
||||
i
|
||||
};
|
||||
for t in &triangles {
|
||||
let r0 = root(&mut piece, t[0] as usize);
|
||||
for k in [1, 2] {
|
||||
let rk = root(&mut piece, t[k] as usize);
|
||||
piece[rk] = r0;
|
||||
}
|
||||
}
|
||||
let pieces: std::collections::BTreeSet<usize> = triangles.iter().map(|t| root(&mut piece, t[0] as usize)).collect();
|
||||
assert_eq!(pieces.len(), 1);
|
||||
}
|
||||
|
||||
/// A cone that takes only a 1 mm chamfer off a cylinder's top edge leaves nearly all of the
|
||||
/// cylinder inside it; the intersection is still the chamfered cylinder (#443).
|
||||
#[test]
|
||||
fn loft_intersect_that_trims_one_edge_changes_the_body() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 35}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "body_name": "Body"}));
|
||||
for (name, offset, radius) in [("A", -1, 45), ("B", 11, 33)] {
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": offset, "name": name}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": radius}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
}
|
||||
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "A"}, {"sketch": "B"}], "operation": "intersect"}));
|
||||
// r 35 up to z = 9, then a frustum to r 34 at z = 10.
|
||||
let want = std::f64::consts::PI * (35.0 * 35.0 * 9.0 + (35.0 * 35.0 + 35.0 * 34.0 + 34.0 * 34.0) / 3.0);
|
||||
let got = volume(&mut s);
|
||||
assert!((got - want).abs() < want * 2e-4, "{got} vs {want}");
|
||||
let m = measure(&mut s);
|
||||
assert_eq!(m["bodies"][0]["face_types"]["cone"], 1, "{m}");
|
||||
}
|
||||
+219
-19
@@ -1,5 +1,8 @@
|
||||
//! Documents and files: new, open (designs and STEP), insert STEP, save, export.
|
||||
|
||||
#[path = "file_thumbnail.rs"]
|
||||
mod thumbnail;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::Document;
|
||||
use solidcraft_io::Format;
|
||||
@@ -27,11 +30,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("save")
|
||||
.key("Ctrl+S")
|
||||
.noundo()
|
||||
.params("path? (default: current file); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); preview?: bool (experimental saved picture, default: false)"),
|
||||
.params("path? (default: current file); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (warm native geometry, default: false); preview?: bool (saved display meshes, default: false)"),
|
||||
CommandSpec::new("file.save_as", "Save As", save_as)
|
||||
.icon("save")
|
||||
.noundo()
|
||||
.params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); preview?: bool (experimental saved picture, default: false)"),
|
||||
.params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (warm native geometry, default: false); preview?: bool (saved display meshes, default: false)"),
|
||||
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list)
|
||||
.noundo()
|
||||
.params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"),
|
||||
@@ -78,7 +81,7 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
|
||||
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "warnings": sf.warnings}))
|
||||
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": sf.warnings}))
|
||||
}
|
||||
|
||||
/// Add a MeshImport feature for a 3MF or STL file.
|
||||
@@ -98,7 +101,7 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
|
||||
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "warnings": mf.warnings}))
|
||||
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": mf.warnings}))
|
||||
}
|
||||
|
||||
fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -131,16 +134,10 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
|
||||
}
|
||||
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let mut doc = solidcraft_io::read_design(&bytes)?;
|
||||
let mut doc = solidcraft_io::read_design_file(path, s.open_preview.take()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if let Some(stem) = default_name_for(&doc.name, path) {
|
||||
doc.name = stem;
|
||||
}
|
||||
if let Some(notify) = s.open_preview.take()
|
||||
&& let Some(preview) = solidcraft_io::read_design_preview(&bytes)
|
||||
{
|
||||
notify(preview);
|
||||
}
|
||||
*s = Session::new(doc);
|
||||
s.path = Some(path.to_string());
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
@@ -172,13 +169,13 @@ fn rename_design(s: &mut Session, from: &str, to: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> {
|
||||
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool) -> Result<Value> {
|
||||
// Saving an unnamed design names it after its file (not an undoable edit).
|
||||
let renamed = default_name_for(&s.doc.name, path).map(|stem| (s.doc.name.clone(), stem));
|
||||
if let Some((from, to)) = &renamed {
|
||||
rename_design(s, from, to);
|
||||
}
|
||||
let r = write_file(s, path, format, compression, preview);
|
||||
let r = write_file(s, path, format, compression, preview, cache);
|
||||
if r.is_err()
|
||||
&& let Some((from, to)) = &renamed
|
||||
{
|
||||
@@ -187,15 +184,35 @@ fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview:
|
||||
r
|
||||
}
|
||||
|
||||
fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> {
|
||||
fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool) -> Result<Value> {
|
||||
let compression = match compression {
|
||||
"fast" => solidcraft_io::DesignCompression::Fast,
|
||||
"compact" => solidcraft_io::DesignCompression::Compact,
|
||||
_ => return Err(bad("file.save", "compression must be fast or compact")),
|
||||
};
|
||||
// Linked designs are kept relative to the folder the design is saved in.
|
||||
let links = super::component_link::rebased(&s.doc, s.path.as_deref(), path);
|
||||
let old_links = (links != s.doc.links).then(|| std::mem::replace(&mut s.doc_mut().links, links));
|
||||
let r = write_bytes(s, path, format, compression, preview, cache);
|
||||
if r.is_err()
|
||||
&& let Some(old) = old_links
|
||||
{
|
||||
s.doc_mut().links = old;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn write_bytes(
|
||||
s: &mut Session,
|
||||
path: &str,
|
||||
format: &str,
|
||||
compression: solidcraft_io::DesignCompression,
|
||||
preview: bool,
|
||||
cache: bool,
|
||||
) -> Result<Value> {
|
||||
let bytes = match format {
|
||||
"container" => solidcraft_io::write_design_container_with_preview(&s.doc, compression, preview.then(|| saved_preview(s)).flatten().as_ref())?,
|
||||
"json" => solidcraft_io::write_design(&s.doc),
|
||||
"container" => s.design_container(compression, preview, cache)?,
|
||||
"json" => solidcraft_io::write_design(&s.design()),
|
||||
_ => return Err(bad("file.save", "format must be container or json")),
|
||||
};
|
||||
// Atomic, keeping the version being replaced as `<file>.bak`.
|
||||
@@ -205,6 +222,29 @@ fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, prev
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "name": s.doc.name}))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Container bytes for desktop and browser saves. Only already evaluated bodies
|
||||
/// and warm meshes are eligible; saving never forces evaluation or tessellation.
|
||||
pub fn design_container(&self, compression: solidcraft_io::DesignCompression, preview: bool, cache: bool) -> solidcraft_io::Result<Vec<u8>> {
|
||||
let geometry = cache.then(|| saved_geometry(self)).flatten();
|
||||
let preview = preview.then(|| saved_preview(self)).flatten();
|
||||
let thumbnail = cache.then(|| thumbnail::saved(self)).flatten();
|
||||
solidcraft_io::write_design_container_with_thumbnail(&self.design(), compression, preview.as_ref(), geometry.as_deref(), thumbnail.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
fn saved_geometry(s: &Session) -> Option<Vec<solidcraft_io::DesignGeometry>> {
|
||||
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
|
||||
return None;
|
||||
}
|
||||
let world = s.cached_world_state()?;
|
||||
if world.bodies.is_empty() || world.bodies.len() > 4096 {
|
||||
return None;
|
||||
}
|
||||
let painted = s.doc.painted(&world);
|
||||
Some(painted.bodies.iter().map(|b| solidcraft_io::DesignGeometry { name: b.name.clone(), body: b.body.clone() }).collect())
|
||||
}
|
||||
|
||||
/// Cache only warm display geometry. A save never waits for an unevaluated mesh.
|
||||
fn saved_preview(s: &Session) -> Option<solidcraft_io::DesignPreview> {
|
||||
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
|
||||
@@ -246,6 +286,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
str_(p, "format").unwrap_or(default_design_format(&path)),
|
||||
str_(p, "compression").unwrap_or("fast"),
|
||||
bool_(p, "preview").unwrap_or(false),
|
||||
bool_(p, "cache").unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -261,6 +302,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
str_(p, "format").unwrap_or(default_design_format(&path)),
|
||||
str_(p, "compression").unwrap_or("fast"),
|
||||
bool_(p, "preview").unwrap_or(false),
|
||||
bool_(p, "cache").unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -271,7 +313,7 @@ fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Resu
|
||||
if !bodies.is_empty() {
|
||||
return Err(bad("file.export", "design JSON exports the complete document; omit bodies"));
|
||||
}
|
||||
solidcraft_io::write_design(&s.doc)
|
||||
solidcraft_io::write_design(&s.design())
|
||||
} else if format == Format::Step && bodies.is_empty() && !s.doc.occurrences.is_empty() {
|
||||
solidcraft_io::step_assembly(&s.doc, &s.doc.painted(&s.model.state()), &name)?
|
||||
} else {
|
||||
@@ -359,7 +401,7 @@ mod tests {
|
||||
for index in if round % 2 == 0 { [0, 1] } else { [1, 0] } {
|
||||
let path = target.join(if index == 0 { "issue303-warm.json" } else { "issue303-warm.solidcraft" });
|
||||
let began = std::time::Instant::now();
|
||||
session.execute("file.save", &json!({"path":path,"preview":true})).unwrap();
|
||||
session.execute("file.save", &json!({"path":path,"preview":true,"cache":false})).unwrap();
|
||||
samples[index].push(began.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
@@ -396,7 +438,9 @@ mod tests {
|
||||
}
|
||||
let save_path = std::env::temp_dir().join(format!("solidcraft-preview-save-{}.solidcraft", std::process::id()));
|
||||
s.execute("file.save", &json!({"path":save_path})).unwrap();
|
||||
assert!(solidcraft_io::read_design_preview(&std::fs::read(&save_path).unwrap()).is_none(), "ordinary saves omit redundant display data");
|
||||
let saved = std::fs::read(&save_path).unwrap();
|
||||
assert!(solidcraft_io::read_design_geometry(&saved).is_none(), "ordinary saves omit native geometry caches");
|
||||
assert!(!saved.windows(b"cache/preview.bin".len()).any(|s| s == b"cache/preview.bin"), "ordinary saves omit redundant display meshes");
|
||||
s.execute("file.save", &json!({"path":save_path,"preview":true})).unwrap();
|
||||
assert!(solidcraft_io::read_design_preview(&std::fs::read(&save_path).unwrap()).is_some());
|
||||
let _ = std::fs::remove_file(&save_path);
|
||||
@@ -439,6 +483,7 @@ mod tests {
|
||||
}
|
||||
*s.world_cache.lock().unwrap() = None;
|
||||
assert!(saved_preview(&s).is_none());
|
||||
assert!(saved_geometry(&s).is_none());
|
||||
assert!(s.world_cache.lock().unwrap().is_none(), "saving must not prepare cold placements");
|
||||
for body in &s.world_state().bodies {
|
||||
body.mesh();
|
||||
@@ -450,6 +495,7 @@ mod tests {
|
||||
}
|
||||
assert!(!s.pending_moves.is_empty());
|
||||
assert!(saved_preview(&s).is_none(), "uncaptured positions are not in the saved document");
|
||||
assert!(saved_geometry(&s).is_none());
|
||||
s.execute("component.capture_position", &json!({})).unwrap();
|
||||
for body in &s.world_state().bodies {
|
||||
body.mesh();
|
||||
@@ -464,6 +510,131 @@ mod tests {
|
||||
}
|
||||
assert!(editing.active_sketch.is_some());
|
||||
assert!(saved_preview(&editing).is_none());
|
||||
assert!(saved_geometry(&editing).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_save_as_require_explicit_native_cache_opt_in() {
|
||||
let mut session = Session::new(Document::new("Cache opt-in"));
|
||||
session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
let world = session.world_state();
|
||||
for body in &world.bodies {
|
||||
body.mesh();
|
||||
}
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-cache-opt-in-{}.solidcraft", std::process::id()));
|
||||
for command in ["file.save", "file.save_as"] {
|
||||
for cache in [None, Some(true), Some(false), None] {
|
||||
let mut params = json!({"path":path});
|
||||
if let Some(cache) = cache {
|
||||
params["cache"] = json!(cache);
|
||||
}
|
||||
session.execute(command, ¶ms).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert_eq!(solidcraft_io::read_design_geometry(&bytes).is_some(), cache == Some(true), "{command}: {cache:?}");
|
||||
assert_eq!(solidcraft_io::read_design_thumbnail(&bytes).is_some(), cache == Some(true), "{command}: {cache:?}");
|
||||
assert!(
|
||||
!bytes.windows(b"cache/preview.bin".len()).any(|entry| entry == b"cache/preview.bin"),
|
||||
"display meshes require preview: true"
|
||||
);
|
||||
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
|
||||
}
|
||||
}
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::fs::remove_file(format!("{}.bak", path.display())).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_cache_does_not_force_meshes_or_supply_editing_state() {
|
||||
let mut session = Session::new(Document::new("Native cache authority"));
|
||||
session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
let world = session.world_state();
|
||||
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()));
|
||||
let mut cached = saved_geometry(&session).unwrap();
|
||||
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
|
||||
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()), "saving native geometry must not triangulate");
|
||||
assert!(solidcraft_io::read_design_geometry(&bytes).is_some());
|
||||
assert!(
|
||||
solidcraft_io::read_design_geometry(&session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap()).is_none()
|
||||
);
|
||||
cached[0].body = solidcraft_kernel::box_solid(solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::new(100., 100., 100.)).unwrap();
|
||||
let bytes =
|
||||
solidcraft_io::write_design_container_with_cache(&session.doc, solidcraft_io::DesignCompression::Fast, None, Some(&cached)).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-native-forged-{}.solidcraft", std::process::id()));
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let seen = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let callback_seen = seen.clone();
|
||||
let mut reopened = Session {
|
||||
open_preview: Some(Box::new(move |p| {
|
||||
callback_seen.store(!p.meshes.is_empty(), std::sync::atomic::Ordering::Relaxed);
|
||||
})),
|
||||
..Session::default()
|
||||
};
|
||||
reopened.execute("doc.open", &json!({"path":path})).unwrap();
|
||||
assert!(seen.load(std::sync::atomic::Ordering::Relaxed));
|
||||
assert_eq!(reopened.doc, session.doc);
|
||||
let measured = reopened.execute("inspect.measure", &json!({})).unwrap();
|
||||
assert!((measured["bodies"][0]["volume_mm3"].as_f64().unwrap() - 6000.).abs() < 1e-6);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual native cache save benchmark; generate issue303-step.json first"]
|
||||
fn benchmark_native_cache_save() {
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target"));
|
||||
let input = std::env::var_os("SOLIDCRAFT_BENCH_FILE").map(std::path::PathBuf::from).unwrap_or_else(|| target.join("issue303-step.json"));
|
||||
let mut session = Session::new(solidcraft_io::read_design(&std::fs::read(input).unwrap()).unwrap());
|
||||
for body in &session.world_state().bodies {
|
||||
body.mesh();
|
||||
}
|
||||
let modes = [("json", false, "fast"), ("container", false, "fast"), ("container", true, "fast"), ("container", true, "compact")];
|
||||
let mut samples = [Vec::new(), Vec::new(), Vec::new(), Vec::new()];
|
||||
for round in 0..9 {
|
||||
for i in if round % 2 == 0 { [0, 1, 2, 3] } else { [3, 2, 1, 0] } {
|
||||
let (format, cache, compression) = modes[i];
|
||||
let path = target.join(format!("issue303-native-{i}.solidcraft"));
|
||||
let start = std::time::Instant::now();
|
||||
session.execute("file.save", &json!({"path":path,"format":format,"cache":cache,"compression":compression})).unwrap();
|
||||
samples[i].push(start.elapsed().as_secs_f64() * 1000.);
|
||||
}
|
||||
}
|
||||
for (i, times) in samples.iter_mut().enumerate() {
|
||||
times.sort_by(f64::total_cmp);
|
||||
let bytes = std::fs::read(target.join(format!("issue303-native-{i}.solidcraft"))).unwrap();
|
||||
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
|
||||
assert_eq!(solidcraft_io::read_design_geometry(&bytes).is_some(), modes[i].1);
|
||||
eprintln!("native_save mode={i} median_ms={:.3} bytes={}", times[4], bytes.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual fresh-process opening benchmark; one encoding per process"]
|
||||
fn benchmark_native_cache_open() {
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target"));
|
||||
let mode = std::env::var("SOLIDCRAFT_BENCH_ENCODING").unwrap_or_else(|_| "2".into());
|
||||
let path = target.join(format!("issue303-native-{mode}.solidcraft"));
|
||||
let display = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let notify = display.clone();
|
||||
let start = std::time::Instant::now();
|
||||
let mut session = Session {
|
||||
open_preview: Some(Box::new(move |_| {
|
||||
*notify.lock().unwrap() = Some(start.elapsed().as_secs_f64() * 1000.);
|
||||
})),
|
||||
..Session::default()
|
||||
};
|
||||
let result = session.execute("doc.open", &json!({"path":path})).unwrap();
|
||||
assert_eq!(result["errors"], 0);
|
||||
let reconstructed = start.elapsed().as_secs_f64() * 1000.;
|
||||
let mut triangles = 0;
|
||||
let world = session.world_state();
|
||||
for body in &world.bodies {
|
||||
triangles += body.mesh().triangles.len();
|
||||
}
|
||||
let ready = start.elapsed().as_secs_f64() * 1000.;
|
||||
eprintln!(
|
||||
"native_open mode={mode} provisional_ms={:?} rebuilt_ms={reconstructed:.3} meshes_ready_ms={ready:.3} bodies={} triangles={triangles}",
|
||||
*display.lock().unwrap(),
|
||||
world.bodies.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn name_dir(tag: &str) -> std::path::PathBuf {
|
||||
@@ -561,4 +732,33 @@ mod tests {
|
||||
assert!(std::fs::read_to_string(&packaged).unwrap().contains("solidcraft/"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A watched open reports the timeline's progress; cancelled, it skips the rebuild.
|
||||
#[test]
|
||||
fn watched_open_reports_progress_and_stops_when_cancelled() {
|
||||
use solidcraft_doc::progress::{Progress, watch};
|
||||
use std::sync::Arc;
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-watched-open-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("three.solidcraft").to_string_lossy().to_string();
|
||||
let mut s = Session::default();
|
||||
for x in [0, 20, 40] {
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"corner":[x,0,0]})).unwrap();
|
||||
}
|
||||
s.execute("file.save", &json!({"path":path})).unwrap();
|
||||
|
||||
let progress = Arc::new(Progress::default());
|
||||
let mut opened = Session::default();
|
||||
watch(progress.clone(), || opened.execute("doc.open", &json!({"path":path}))).unwrap();
|
||||
assert_eq!(progress.features(), (3, 3));
|
||||
assert_eq!(opened.world_state().bodies.len(), 3);
|
||||
|
||||
let progress = Arc::new(Progress::default());
|
||||
progress.cancel();
|
||||
let mut stopped = Session::default();
|
||||
watch(progress, || stopped.execute("doc.open", &json!({"path":path}))).unwrap();
|
||||
assert!(stopped.model.results.iter().all(|r| r.skipped), "no feature was rebuilt after Cancel");
|
||||
assert!(stopped.world_state().bodies.is_empty());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! A portable picture from warm display meshes only; never evaluate on save.
|
||||
use crate::Session;
|
||||
use solidcraft_geom::Aabb3;
|
||||
use solidcraft_render::{Camera, Rgb, Scene, SceneMesh, StandardView};
|
||||
|
||||
pub(super) fn saved(s: &Session) -> Option<Vec<u8>> {
|
||||
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
|
||||
return None;
|
||||
}
|
||||
let world = s.cached_world_state()?;
|
||||
if world.bodies.is_empty() || world.bodies.len() > 1024 {
|
||||
return None;
|
||||
}
|
||||
let mut scene = Scene::default();
|
||||
let mut bounds = Aabb3::EMPTY;
|
||||
let mut triangles = 0usize;
|
||||
let mut points = 0usize;
|
||||
for body in world.bodies.iter().filter(|b| s.body_visible(&b.name)) {
|
||||
let mesh = body.cached_mesh()?;
|
||||
triangles = triangles.saturating_add(mesh.triangles.len());
|
||||
points = points.saturating_add(mesh.positions.len());
|
||||
if triangles > 100_000 || points > 100_000 {
|
||||
return None;
|
||||
}
|
||||
bounds = bounds.union(&mesh.bounds());
|
||||
let channel = |x: f32| (x.clamp(0., 1.) * 255.).round() as u8;
|
||||
let color = s.doc.body_color(body).map_or(crate::view::colors::BODY, |[r, g, b]| Rgb(channel(r), channel(g), channel(b)));
|
||||
let face_colors =
|
||||
s.doc.face_colors(body).into_iter().map(|(face, look)| (face as u32, Rgb(look.color[0], look.color[1], look.color[2]))).collect();
|
||||
scene.meshes.push(SceneMesh { mesh, color, face_colors });
|
||||
}
|
||||
if bounds.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut camera = Camera::default();
|
||||
camera.set_view(StandardView::Iso);
|
||||
camera.fit(&bounds);
|
||||
camera.distance *= 0.7;
|
||||
scene.radius = (bounds.diagonal() * 0.5).max(camera.half_height()).max(10.);
|
||||
scene.background = Some((crate::view::colors::BG_TOP, crate::view::colors::BG_BOTTOM));
|
||||
let [width, height] = solidcraft_io::THUMBNAIL_SIZE;
|
||||
if !within_raster_budget(&scene, &camera, width, height) {
|
||||
return None;
|
||||
}
|
||||
let image = solidcraft_render::render(&scene, &camera, width, height);
|
||||
solidcraft_render::png(width, height, image.rgba)
|
||||
}
|
||||
|
||||
// Bound overlapping-triangle work too, not only mesh size. The renderer uses
|
||||
// 2x supersampling; conservatively count each projected triangle's pixel box.
|
||||
fn within_raster_budget(scene: &Scene, camera: &Camera, width: usize, height: usize) -> bool {
|
||||
let (w, h) = ((width * 2) as f64, (height * 2) as f64);
|
||||
let matrix = camera.view_proj(w / h, scene.radius.max(1.));
|
||||
let mut pixels = 0usize;
|
||||
let mut colors = 0usize;
|
||||
for entry in &scene.meshes {
|
||||
colors = colors.saturating_add(entry.mesh.triangles.len().saturating_mul(entry.face_colors.len()));
|
||||
if colors > 1_000_000 {
|
||||
return false;
|
||||
}
|
||||
let mut projected = Vec::with_capacity(entry.mesh.positions.len());
|
||||
for point in &entry.mesh.positions {
|
||||
let q = matrix.apply(*point);
|
||||
if q.iter().any(|x| !x.is_finite()) || q[3] <= 1e-9 {
|
||||
return false;
|
||||
}
|
||||
let pixel = [(q[0] / q[3] + 1.) * 0.5 * w, (1. - q[1] / q[3]) * 0.5 * h];
|
||||
if pixel.iter().any(|x| !x.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
projected.push(pixel);
|
||||
}
|
||||
for triangle in &entry.mesh.triangles {
|
||||
let [Some(a), Some(b), Some(c)] = triangle.map(|i| projected.get(i as usize).copied()) else {
|
||||
return false;
|
||||
};
|
||||
let dx = (a[0].max(b[0]).max(c[0]).ceil().clamp(0., w) - a[0].min(b[0]).min(c[0]).floor().clamp(0., w) + 2.) as usize;
|
||||
let dy = (a[1].max(b[1]).max(c[1]).ceil().clamp(0., h) - a[1].min(b[1]).min(c[1]).floor().clamp(0., h) + 2.) as usize;
|
||||
pixels = pixels.saturating_add(dx.saturating_mul(dy));
|
||||
if pixels > 4_000_000 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn portable_thumbnail_refuses_excessive_overdraw() {
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
let mesh = session.world_state().bodies[0].mesh();
|
||||
let bounds = mesh.bounds();
|
||||
let mut camera = Camera::default();
|
||||
camera.set_view(StandardView::Iso);
|
||||
camera.fit(&bounds);
|
||||
let mut scene = Scene { radius: bounds.diagonal(), ..Scene::default() };
|
||||
scene.meshes.push(SceneMesh { mesh: mesh.clone(), color: Rgb(100, 100, 100), face_colors: vec![] });
|
||||
assert!(within_raster_budget(&scene, &camera, 240, 150));
|
||||
let mut repeated = (*mesh).clone();
|
||||
repeated.triangles = vec![repeated.triangles[0]; 100_000];
|
||||
scene.meshes[0].mesh = std::sync::Arc::new(repeated);
|
||||
assert!(!within_raster_budget(&scene, &camera, 240, 150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_thumbnail_preserves_saved_visibility() {
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30,"body_name":"Visible"})).unwrap();
|
||||
session.execute("solid.box", &serde_json::json!({"length":100,"width":100,"height":100,"body_name":"Hidden"})).unwrap();
|
||||
session.execute("browser.visibility", &serde_json::json!({"items":["Hidden"],"visible":false})).unwrap();
|
||||
let world = session.world_state();
|
||||
for body in &world.bodies {
|
||||
body.mesh();
|
||||
}
|
||||
let expected = saved(&session).unwrap();
|
||||
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
|
||||
let doc = solidcraft_io::read_design(&bytes).unwrap();
|
||||
assert_eq!(doc.visibility.hidden_bodies, vec!["Hidden"]);
|
||||
assert_eq!(doc, *session.design());
|
||||
assert_eq!(solidcraft_io::read_design_thumbnail(&bytes), Some(expected));
|
||||
let reopened = Session::new(doc);
|
||||
assert_eq!(reopened.visibility, session.visibility);
|
||||
let disabled = session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap();
|
||||
assert_eq!(solidcraft_io::read_design(&disabled).unwrap().visibility, session.visibility);
|
||||
assert!(solidcraft_io::read_design_thumbnail(&disabled).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_thumbnail_never_forces_cold_evaluation_or_meshing() {
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
let world = session.world_state();
|
||||
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()));
|
||||
assert!(saved(&session).is_none());
|
||||
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()));
|
||||
for body in &world.bodies {
|
||||
body.mesh();
|
||||
}
|
||||
let png = saved(&session).unwrap();
|
||||
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
|
||||
assert_eq!(solidcraft_io::read_design_thumbnail(&bytes), Some(png));
|
||||
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
|
||||
let disabled = session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap();
|
||||
assert!(solidcraft_io::read_design_thumbnail(&disabled).is_none());
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("material.assign", "Physical Material", physical_material)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("material")
|
||||
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …; `material.list`)"),
|
||||
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …, or one made with material.create; `material.list`)"),
|
||||
CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
|
||||
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
|
||||
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
|
||||
@@ -34,7 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("appearance.list", "List Appearances", appearance_list)
|
||||
.noundo()
|
||||
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
|
||||
CommandSpec::new("material.list", "List Materials", material_list).noundo().doc_only(),
|
||||
CommandSpec::new("material.list", "List Materials", material_list)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("→ the built-in materials, then the design's own (user: true, with their look)"),
|
||||
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
|
||||
];
|
||||
|
||||
@@ -283,11 +286,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "material.assign";
|
||||
let bodies = string_list(p, "bodies");
|
||||
let material = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
|
||||
let canonical = solidcraft_doc::MATERIALS
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(material.trim()))
|
||||
.map(|(n, _)| n.to_string())
|
||||
.ok_or_else(|| bad(cmd, format!("unknown material `{material}` (see material.list)")))?;
|
||||
let canonical = s.doc.material_name(material).ok_or_else(|| bad(cmd, format!("unknown material `{material}` (see material.list)")))?;
|
||||
let st = s.model.state();
|
||||
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
|
||||
return Err(bad(cmd, "`bodies` must list existing bodies"));
|
||||
@@ -303,7 +302,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// A colour parameter: `"#rrggbb"` or `[r, g, b]` (0–255).
|
||||
fn color_param(v: &Value) -> Option<[u8; 3]> {
|
||||
pub(super) fn color_param(v: &Value) -> Option<[u8; 3]> {
|
||||
if let Some(t) = v.as_str() {
|
||||
let h = t.trim().trim_start_matches('#');
|
||||
if h.len() != 6 || !h.is_ascii() {
|
||||
@@ -455,7 +454,7 @@ fn dist_to(b: &solidcraft_doc::ModelBody, p: solidcraft_geom::Vec3) -> f64 {
|
||||
c.dist(p)
|
||||
}
|
||||
|
||||
fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
|
||||
pub(super) fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
|
||||
json!({"name": l.name, "color": l.hex(), "opacity": l.opacity})
|
||||
}
|
||||
|
||||
@@ -524,6 +523,10 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn material_list(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"materials": solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d})).collect::<Vec<_>>()}))
|
||||
fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d}));
|
||||
let user = s.doc.user_materials.iter().map(
|
||||
|m| json!({"name": m.name, "density_g_cm3": m.density, "appearance": s.doc.material_look(&m.name).as_ref().map(look_json), "user": true}),
|
||||
);
|
||||
Ok(json!({"materials": built_in.chain(user).collect::<Vec<_>>()}))
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::joints::{self, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Snap};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, num, str_, string_list, vec3};
|
||||
@@ -37,7 +37,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("joint.list", "List Joints", list).noundo().params("→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts"),
|
||||
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).params("re-place the occurrences from the joints → conflicts, errors, dof"),
|
||||
CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
|
||||
"bodies?: [names] (default: all, in world placement) → pairs that overlap with the overlap volume, area, bounding box and centre",
|
||||
"bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, so any number of bodies is fine; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out)",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -523,35 +523,79 @@ fn solve_cmd(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.interference";
|
||||
let want = string_list(p, "bodies");
|
||||
let st = s.world_state();
|
||||
let bodies: Vec<&solidcraft_doc::ModelBody> =
|
||||
st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).filter(|b| !b.body.is_mesh()).collect();
|
||||
if bodies.len() > 200 {
|
||||
return Err(bad(cmd, "too many bodies (200 at most)"));
|
||||
}
|
||||
let mut hits = Vec::new();
|
||||
for (i, a) in bodies.iter().enumerate() {
|
||||
for b in bodies.iter().skip(i + 1) {
|
||||
let (ba, bb) = (a.mesh().bounds(), b.mesh().bounds());
|
||||
let apart = ba.max.x < bb.min.x
|
||||
|| bb.max.x < ba.min.x
|
||||
|| ba.max.y < bb.min.y
|
||||
|| bb.max.y < ba.min.y
|
||||
|| ba.max.z < bb.min.z
|
||||
|| bb.max.z < ba.min.z;
|
||||
if apart {
|
||||
// Prune by bounding boxes first (sort and sweep), so the cost follows the pairs that can
|
||||
// touch rather than the square of the body count.
|
||||
let boxes: Vec<Aabb3> = bodies.iter().map(|b| b.mesh().bounds()).collect();
|
||||
let (pairs, skipped) = overlapping_pairs(&boxes, MAX_INTERFERENCE_PAIRS);
|
||||
let (mut hits, mut unchecked) = (Vec::new(), Vec::new());
|
||||
for (i, j) in pairs {
|
||||
let (Some(a), Some(b)) = (bodies.get(i), bodies.get(j)) else { continue };
|
||||
// A pair whose shared part can't be worked out is reported, not passed as clear.
|
||||
let (shared, report) =
|
||||
solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect));
|
||||
let mut skip = |reason: String| unchecked.push(json!({"a": a.name, "b": b.name, "reason": reason}));
|
||||
let shared = match shared {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
skip(e.to_string());
|
||||
continue;
|
||||
}
|
||||
let Ok(Some(o)) = solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect) else { continue };
|
||||
let Ok(m) = solidcraft_kernel::measure(&o) else { continue };
|
||||
if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) {
|
||||
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}}));
|
||||
};
|
||||
if let Some(reason) = report.unverified() {
|
||||
skip(reason);
|
||||
}
|
||||
let Some(o) = shared else { continue };
|
||||
let m = match solidcraft_kernel::measure(&o) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
skip(e.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) {
|
||||
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}}));
|
||||
}
|
||||
}
|
||||
Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len(), "unchecked": unchecked,
|
||||
"truncated": skipped > 0, "skipped_pairs": skipped}))
|
||||
}
|
||||
|
||||
/// Most pairs with overlapping bounding boxes `inspect.interference` intersects in one call.
|
||||
const MAX_INTERFERENCE_PAIRS: usize = 20_000;
|
||||
|
||||
/// Index pairs `(i, j)`, `i < j`, whose boxes overlap or touch (sort and sweep along X), at most
|
||||
/// `limit` of them, plus how many more there were.
|
||||
fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, usize)>, usize) {
|
||||
let mut order: Vec<usize> = (0..boxes.len()).collect();
|
||||
order.sort_by(|&a, &b| match (boxes.get(a), boxes.get(b)) {
|
||||
(Some(a), Some(b)) => a.min.x.total_cmp(&b.min.x),
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
});
|
||||
let (mut pairs, mut skipped) = (Vec::new(), 0);
|
||||
for (k, &i) in order.iter().enumerate() {
|
||||
let Some(a) = boxes.get(i) else { continue };
|
||||
for &j in order.iter().skip(k + 1) {
|
||||
let Some(b) = boxes.get(j) else { continue };
|
||||
// Sorted by min.x: once one starts past `a`, all later ones do.
|
||||
if b.min.x > a.max.x {
|
||||
break;
|
||||
}
|
||||
if b.max.x < a.min.x || a.max.y < b.min.y || b.max.y < a.min.y || a.max.z < b.min.z || b.max.z < a.min.z {
|
||||
continue;
|
||||
}
|
||||
if pairs.len() < limit {
|
||||
pairs.push((i.min(j), i.max(j)));
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len()}))
|
||||
pairs.sort_unstable();
|
||||
(pairs, skipped)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -151,6 +151,57 @@ fn rigid_cycles_solve_and_conflicts_report() {
|
||||
assert!(s.execute("joint.create", &json!({"type": "hinge"})).is_err());
|
||||
}
|
||||
|
||||
/// A 30 x 30 x 3 plate with a through slot whose x = 20 wall carries one rib (0.3 tall, 0.8
|
||||
/// wide), and a peg in the slot with 0.15 clearance all round.
|
||||
fn ribbed_slot_and_peg(s: &mut Session, peg_x1: f64) {
|
||||
run(s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 30]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"distance": 3, "body_name": "Plate"}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "offset": -1}));
|
||||
let slot = json!([[10, 5], [20, 5], [20, 14.6], [19.7, 14.6], [19.7, 15.4], [20, 15.4], [20, 25], [10, 25]]);
|
||||
run(s, "sketch.line", json!({"points": slot, "closed": true}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"distance": 5, "operation": "cut", "targets": ["Plate"]}));
|
||||
run(s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [10.15, 5.15], "p1": [peg_x1, 24.85]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"distance": 3, "operation": "new", "body_name": "Peg"}));
|
||||
}
|
||||
|
||||
/// The peg overlaps only the rib: 0.15 x 0.8 x 3 = 0.36 mm³ (#442).
|
||||
#[test]
|
||||
fn interference_finds_a_peg_overlapping_a_small_rib() {
|
||||
let mut s = Session::default();
|
||||
ribbed_slot_and_peg(&mut s, 19.85);
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["count"], 1, "{i}");
|
||||
assert!((i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0) - 0.36).abs() < 1e-6, "{i}");
|
||||
let (lo, hi) = (&i["interferences"][0]["bbox"]["min"], &i["interferences"][0]["bbox"]["max"]);
|
||||
assert!((lo[0].as_f64().unwrap_or(0.0) - 19.7).abs() < 1e-6 && (hi[0].as_f64().unwrap_or(0.0) - 19.85).abs() < 1e-6, "{i}");
|
||||
}
|
||||
|
||||
/// An arc revolved up to a post ends on the post's wall, where the two bodies can't be
|
||||
/// intersected: the pair is listed as unchecked instead of passing as clear.
|
||||
#[test]
|
||||
fn interference_lists_a_pair_it_could_not_check() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"base": [0, 14, -5], "radius": 4, "height": 15, "body_name": "Post"}));
|
||||
run(&mut s, "sketch.create", json!({"name": "Profile", "plane": "XZ"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [15, 5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": {"origin": [0, 0, 0], "dir": [0, 0, 1]}, "to": [4, 14, 2], "body_name": "Arc"}));
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["count"], 0, "{i}");
|
||||
let unchecked = i["unchecked"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(unchecked.len(), 1, "{i}");
|
||||
assert!(unchecked[0]["reason"].as_str().is_some_and(|r| r.starts_with("not verified") && !r.contains("displacement")), "{i}");
|
||||
// Bodies that are clear of each other, or overlap plainly, leave the list empty.
|
||||
let mut s = two_cubes();
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["unchecked"].as_array().map(Vec::len), Some(0), "{i}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_links_and_interference() {
|
||||
let mut s = two_cubes();
|
||||
@@ -195,3 +246,52 @@ fn faces_mate_by_default() {
|
||||
run(&mut s, "joint.edit", json!({"joint": "Stack", "flip": true}));
|
||||
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 0.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0)));
|
||||
}
|
||||
|
||||
/// #527: an assembly with its fasteners passes 200 bodies quickly; the clash check must still
|
||||
/// cover the whole design. 201 separate 1 mm cubes 2 mm apart, plus one cube pushed half-way
|
||||
/// into the first: exactly that pair overlaps, by 0.5 mm³.
|
||||
#[test]
|
||||
fn interference_checks_designs_with_more_than_200_bodies() {
|
||||
let mut s = Session::default();
|
||||
for i in 0..201 {
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [2 * i, 0, 0], "operation": "new"}));
|
||||
}
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [0.5, 0, 0], "operation": "new", "body_name": "Clash"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 202);
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["checked"], 202, "{i}");
|
||||
assert_eq!(i["count"], 1, "{i}");
|
||||
let hit = &i["interferences"][0];
|
||||
let mut pair = [hit["a"].as_str().unwrap_or(""), hit["b"].as_str().unwrap_or("")];
|
||||
pair.sort_unstable();
|
||||
assert_eq!(pair, ["Body1", "Clash"], "{i}");
|
||||
assert!((hit["volume_mm3"].as_f64().unwrap_or(0.0) - 0.5).abs() < 1e-6, "{i}");
|
||||
}
|
||||
|
||||
/// The bounding-box sweep behind `inspect.interference` finds exactly the pairs a check of every
|
||||
/// pair finds (touching boxes count), and reports how many it left out past its limit.
|
||||
#[test]
|
||||
fn interference_box_sweep_matches_every_pair_check() {
|
||||
use solidcraft_geom::Aabb3;
|
||||
let cube = |x: f64, y: f64, z: f64, e: f64| Aabb3 { min: Vec3::new(x, y, z), max: Vec3::new(x + e, y + e, z + e) };
|
||||
// A deterministic scatter: overlapping, touching, and apart in only one axis.
|
||||
let boxes: Vec<Aabb3> = (0..60)
|
||||
.map(f64::from)
|
||||
.map(|i| cube((i * 7.3) % 20.0, (i * 3.1) % 9.0, (i * 5.7) % 4.0, 1.0 + (i % 3.0)))
|
||||
.chain([cube(30.0, 0.0, 0.0, 1.0), cube(31.0, 0.0, 0.0, 1.0)])
|
||||
.collect();
|
||||
let mut every = Vec::new();
|
||||
for (i, a) in boxes.iter().enumerate() {
|
||||
for (j, b) in boxes.iter().enumerate().skip(i + 1) {
|
||||
let apart = a.max.x < b.min.x || b.max.x < a.min.x || a.max.y < b.min.y || b.max.y < a.min.y || a.max.z < b.min.z || b.max.z < a.min.z;
|
||||
if !apart {
|
||||
every.push((i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(every.contains(&(60, 61)), "touching boxes are a candidate pair");
|
||||
let (pairs, skipped) = super::overlapping_pairs(&boxes, usize::MAX);
|
||||
assert_eq!((pairs, skipped), (every.clone(), 0));
|
||||
let (pairs, skipped) = super::overlapping_pairs(&boxes, 10);
|
||||
assert_eq!((pairs.len(), skipped), (10, every.len() - 10));
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Sketches on flat faces that no construction certificate names: a Cut's walls and floor, a
|
||||
//! chamfer, a pattern's copies, a side a Join merged from two features, the pieces of a split
|
||||
//! face (#491, #492, #493, #557). They attach by the face's exact history name
|
||||
//! (`FaceCertificate::Lineage`), follow the face through parameter edits and survive a save.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::FaceAttachmentStatus;
|
||||
use solidcraft_doc::config::FaceCertificate;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command} {params}: {e}"))
|
||||
}
|
||||
|
||||
fn sketch_on(s: &mut Session, at: [f64; 3], name: &str) {
|
||||
run(s, "sketch.create", json!({"plane": {"face": at}, "name": name}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
fn status(s: &Session, sketch: &str) -> FaceAttachmentStatus {
|
||||
let id = s.doc.find_feature(sketch).map(|f| f.id).unwrap();
|
||||
s.placed_document().sketch_face_attachment(id, &s.model)
|
||||
}
|
||||
|
||||
fn healthy(s: &Session, sketch: &str) -> (solidcraft_geom::Plane, Option<FaceCertificate>) {
|
||||
match status(s, sketch) {
|
||||
FaceAttachmentStatus::Healthy { plane, target, .. } => (plane, target.certificate),
|
||||
other => panic!("{sketch}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn no_errors(s: &Session) {
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
|
||||
}
|
||||
|
||||
fn reopened(s: &Session) -> Session {
|
||||
let mut t = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
t.refresh();
|
||||
no_errors(&t);
|
||||
t
|
||||
}
|
||||
|
||||
/// A 40 x 30 x 10 block with a slot cut 5 deep across it from x = `slot_x` (a parameter), 10 wide.
|
||||
fn slotted_block() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Block"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "slot_x", "expression": "15 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "SlotSketch"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [15, -5], "p1": [25, 35]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p1"], "type": "horizontal", "value": "slot_x"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "10 mm"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "SlotSketch", "distance": 12, "start_offset": 5, "operation": "cut", "name": "Slot"}));
|
||||
no_errors(&s);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_wall_and_floor_take_a_sketch_that_follows_the_cut() {
|
||||
let mut s = slotted_block();
|
||||
sketch_on(&mut s, [15., 15., 7.5], "Wall");
|
||||
sketch_on(&mut s, [20., 15., 5.], "Floor");
|
||||
let (wall, certificate) = healthy(&s, "Wall");
|
||||
assert_eq!(certificate, Some(FaceCertificate::Lineage));
|
||||
// The wall faces into the slot (+x), its frame's origin over the design origin.
|
||||
assert!((wall.normal().x - 1.).abs() < 1e-9 && wall.origin.x.abs() > 14.9 && (wall.origin.x - 15.).abs() < 1e-9, "{wall:?}");
|
||||
assert!((wall.origin.y.abs() + wall.origin.z.abs()) < 1e-9, "{wall:?}");
|
||||
let (floor, _) = healthy(&s, "Floor");
|
||||
assert!((floor.normal().z - 1.).abs() < 1e-9 && (floor.origin.z - 5.).abs() < 1e-9, "{floor:?}");
|
||||
|
||||
// The slot moves: the sketch on its wall moves with it.
|
||||
run(&mut s, "parameters.change", json!({"name": "slot_x", "expression": "20 mm"}));
|
||||
no_errors(&s);
|
||||
let (wall, _) = healthy(&s, "Wall");
|
||||
assert!((wall.origin.x - 20.).abs() < 1e-9, "{wall:?}");
|
||||
let t = reopened(&s);
|
||||
assert!((healthy(&t, "Wall").0.origin.x - 20.).abs() < 1e-9);
|
||||
assert!((healthy(&t, "Floor").0.origin.z - 5.).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pieces_of_a_split_top_take_a_sketch() {
|
||||
let mut s = slotted_block();
|
||||
// The slot splits the block's top in two; each piece takes a sketch (on that face's plane).
|
||||
for (name, x) in [("Left", 5.), ("Right", 35.)] {
|
||||
sketch_on(&mut s, [x, 15., 10.], name);
|
||||
assert!((healthy(&s, name).0.origin.z - 10.).abs() < 1e-9, "{name}");
|
||||
}
|
||||
run(&mut s, "parameters.change", json!({"name": "slot_x", "expression": "20 mm"}));
|
||||
no_errors(&s);
|
||||
assert!((healthy(&s, "Left").0.origin.z - 10.).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chamfer_face_takes_a_sketch_that_follows_the_chamfer() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "bevel", "expression": "3 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "name": "Base"}));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[40, 15, 10]], "distance": "bevel", "name": "Bevel"}));
|
||||
no_errors(&s);
|
||||
sketch_on(&mut s, [38.5, 15., 8.5], "OnBevel");
|
||||
let (plane, certificate) = healthy(&s, "OnBevel");
|
||||
assert_eq!(certificate, Some(FaceCertificate::Lineage));
|
||||
let n = std::f64::consts::FRAC_1_SQRT_2;
|
||||
assert!((plane.normal().x - n).abs() < 1e-9 && (plane.normal().z - n).abs() < 1e-9, "{plane:?}");
|
||||
// A bigger chamfer moves the face inward: the sketch goes with it.
|
||||
let height = |p: &solidcraft_geom::Plane| (p.origin - solidcraft_geom::Vec3::new(40., 0., 10.)).dot(p.normal());
|
||||
assert!((height(&plane) + 3. * n).abs() < 1e-9, "{plane:?}");
|
||||
run(&mut s, "parameters.change", json!({"name": "bevel", "expression": "5 mm"}));
|
||||
no_errors(&s);
|
||||
assert!((height(&healthy(&s, "OnBevel").0) + 5. * n).abs() < 1e-9);
|
||||
healthy(&reopened(&s), "OnBevel");
|
||||
}
|
||||
|
||||
/// A plate with a boss patterned 3 times along x, `pitch` apart.
|
||||
fn patterned_bosses(sketch_on_seed_first: bool) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "pitch", "expression": "20 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [80, 20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [40, 10, 5]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 6, "operation": "join", "name": "Boss"}));
|
||||
if sketch_on_seed_first {
|
||||
sketch_on(&mut s, [10., 10., 11.], "Seed");
|
||||
}
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Boss"], "dir1": [1, 0, 0], "count1": 3, "spacing1": "pitch", "name": "Row"}));
|
||||
no_errors(&s);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_patterned_copy_takes_its_own_sketch() {
|
||||
let mut s = patterned_bosses(true);
|
||||
// A sketch made on the seed before the pattern keeps its construction certificate: the
|
||||
// copies carry it too, but are named as copies.
|
||||
assert!(matches!(healthy(&s, "Seed").1, Some(FaceCertificate::ExtrudeCap { .. })));
|
||||
for (name, x) in [("First", 10.), ("Middle", 30.), ("Last", 50.)] {
|
||||
sketch_on(&mut s, [x, 10., 11.], name);
|
||||
assert!((healthy(&s, name).0.origin.z - 11.).abs() < 1e-9, "{name}");
|
||||
}
|
||||
run(&mut s, "parameters.change", json!({"name": "pitch", "expression": "25 mm"}));
|
||||
no_errors(&s);
|
||||
for name in ["Seed", "First", "Middle", "Last"] {
|
||||
healthy(&s, name);
|
||||
}
|
||||
let t = reopened(&s);
|
||||
for name in ["Seed", "Middle", "Last"] {
|
||||
healthy(&t, name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_piece_of_a_face_split_on_purpose_still_needs_a_fixed_plane() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Modified"}));
|
||||
run(&mut s, "solid.split_face", json!({"body": "Modified", "faces": [[5, 5, 10]], "plane": {"origin": [5, 0, 0], "normal": [1, 0, 0]}}));
|
||||
let error = s.execute("sketch.create", &json!({"plane": {"face": [2.5, 5, 10]}})).unwrap_err().to_string();
|
||||
assert!(error.contains("fixed:true"), "{error}");
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Physical materials defined in a design (`material.create`, `material.edit`,
|
||||
//! `material.delete`): a name, a density and, optionally, a look. They are assigned like the
|
||||
//! built-in library's (`material.assign`), set masses (`component.properties`) and fill
|
||||
//! configuration material cells. The design keeps them, so they are saved and undone like
|
||||
//! features.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::appearance::{Look, library};
|
||||
use solidcraft_doc::config::Column;
|
||||
use solidcraft_doc::{MATERIALS, UserMaterial, valid_density};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::inspect::{color_param, look_json};
|
||||
use crate::params::{bad, str_};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("material.create", "New Physical Material", create).icon("material").params(
|
||||
"name (not a built-in material's); density_g_cm3: (0, 1000]; appearance?: library name (appearance.library) | color?: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1): the look bodies of this material get without an appearance of their own. Assign it with material.assign",
|
||||
),
|
||||
CommandSpec::new("material.edit", "Edit Physical Material", edit).icon("material").enabled(has_user_materials).params(
|
||||
"material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity? (as material.create; color: null clears the look)",
|
||||
),
|
||||
CommandSpec::new("material.delete", "Delete Physical Material", delete).enabled(has_user_materials).params(
|
||||
"material: a material defined in this design; replace?: material for the bodies and configuration cells that use it (without it, a material in use is refused)",
|
||||
),
|
||||
];
|
||||
|
||||
/// Most materials a design defines.
|
||||
const MAX_USER_MATERIALS: usize = 1000;
|
||||
|
||||
fn has_user_materials(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.doc.user_materials.is_empty() { Err("the design defines no physical materials (material.create)".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// A new material name: 1–128 characters, not a built-in material's or another design
|
||||
/// material's (`except`: the one being renamed).
|
||||
fn new_name(s: &Session, cmd: &str, name: &str, except: Option<&str>) -> Result<String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.chars().count() > 128 {
|
||||
return Err(bad(cmd, "`name` must have 1–128 characters"));
|
||||
}
|
||||
if MATERIALS.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)) {
|
||||
return Err(bad(cmd, format!("`{name}` is a built-in material (material.list)")));
|
||||
}
|
||||
let taken = |m: &&UserMaterial| m.name.eq_ignore_ascii_case(name) && except.is_none_or(|e| !m.name.eq_ignore_ascii_case(e));
|
||||
if s.doc.user_materials.iter().any(|m| taken(&m)) {
|
||||
return Err(bad(cmd, format!("the design already has a material `{name}`")));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
fn density(cmd: &str, p: &Value) -> Result<f64> {
|
||||
p.get("density_g_cm3")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|d| valid_density(*d))
|
||||
.ok_or_else(|| bad(cmd, "`density_g_cm3` must be a number above 0 and at most 1000"))
|
||||
}
|
||||
|
||||
/// The look `appearance` / `color` / `opacity` give: `None` when none is given, `Some(None)`
|
||||
/// to clear it (`color: null`).
|
||||
fn look(cmd: &str, p: &Value, material: &str) -> Result<Option<Option<Look>>> {
|
||||
let opacity = match p.get("opacity") {
|
||||
Some(o) => Some(o.as_f64().filter(|x| x.is_finite() && (0.0..=1.0).contains(x)).ok_or_else(|| bad(cmd, "`opacity` must be 0…1"))?),
|
||||
None => None,
|
||||
};
|
||||
let mut l = match (str_(p, "appearance"), p.get("color")) {
|
||||
(Some(_), Some(c)) if !c.is_null() => return Err(bad(cmd, "give `appearance` or `color`, not both")),
|
||||
(Some(n), _) => library(n).ok_or_else(|| bad(cmd, format!("no appearance `{n}` in the library (appearance.library)")))?,
|
||||
(None, Some(Value::Null)) => return Ok(Some(None)),
|
||||
(None, Some(c)) => {
|
||||
let mut l = Look::custom(color_param(c).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?, 1.0);
|
||||
l.name = material.to_string();
|
||||
l
|
||||
}
|
||||
(None, None) if opacity.is_some() => return Err(bad(cmd, "`opacity` needs `appearance` or `color`")),
|
||||
(None, None) => return Ok(None),
|
||||
};
|
||||
if let Some(o) = opacity {
|
||||
l.opacity = o;
|
||||
}
|
||||
Ok(Some(Some(l)))
|
||||
}
|
||||
|
||||
fn material_json(m: &UserMaterial) -> Value {
|
||||
json!({"name": m.name, "density_g_cm3": m.density, "appearance": m.appearance.as_ref().map(look_json), "user": true})
|
||||
}
|
||||
|
||||
/// The index of the design material `material` names.
|
||||
fn existing(s: &Session, cmd: &str, p: &Value) -> Result<usize> {
|
||||
let name = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
|
||||
s.doc
|
||||
.user_materials
|
||||
.iter()
|
||||
.position(|m| m.name.eq_ignore_ascii_case(name.trim()))
|
||||
.ok_or_else(|| bad(cmd, format!("the design defines no material `{name}` (built-in materials cannot be changed)")))
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "material.create";
|
||||
let name = new_name(s, cmd, str_(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?, None)?;
|
||||
if s.doc.user_materials.len() >= MAX_USER_MATERIALS {
|
||||
return Err(bad(cmd, format!("a design defines at most {MAX_USER_MATERIALS} materials")));
|
||||
}
|
||||
let material = UserMaterial { density: density(cmd, p)?, appearance: look(cmd, p, &name)?.flatten(), name };
|
||||
let out = material_json(&material);
|
||||
s.doc_mut().user_materials.push(material);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Which configuration columns hold materials.
|
||||
fn material_columns(s: &Session) -> Vec<bool> {
|
||||
s.doc.configs.columns.iter().map(|c| matches!(c, Column::Material(_))).collect()
|
||||
}
|
||||
|
||||
/// Where material `name` is used: bodies, and configuration rows with a cell naming it.
|
||||
fn uses(s: &Session, name: &str) -> (Vec<String>, Vec<String>) {
|
||||
let bodies = s.doc.materials.iter().filter(|(_, m)| m.eq_ignore_ascii_case(name)).map(|(b, _)| b.clone()).collect();
|
||||
let columns = material_columns(s);
|
||||
let rows = s
|
||||
.doc
|
||||
.configs
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|r| r.cells.iter().zip(&columns).any(|(cell, material)| *material && cell.trim().eq_ignore_ascii_case(name)))
|
||||
.map(|r| r.name.clone())
|
||||
.collect();
|
||||
(bodies, rows)
|
||||
}
|
||||
|
||||
/// Point every use of material `from` (body assignments, configuration cells) at `to`
|
||||
/// ("Default" removes the body assignments).
|
||||
fn move_uses(s: &mut Session, from: &str, to: &str) {
|
||||
let columns = material_columns(s);
|
||||
let d = s.doc_mut();
|
||||
let bodies: Vec<String> = d.materials.iter().filter(|(_, m)| m.eq_ignore_ascii_case(from)).map(|(b, _)| b.clone()).collect();
|
||||
for b in bodies {
|
||||
if to == "Default" {
|
||||
d.materials.remove(&b);
|
||||
} else {
|
||||
d.materials.insert(b, to.to_string());
|
||||
}
|
||||
}
|
||||
for row in &mut d.configs.rows {
|
||||
for (cell, material) in row.cells.iter_mut().zip(&columns) {
|
||||
if *material && cell.trim().eq_ignore_ascii_case(from) {
|
||||
*cell = to.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "material.edit";
|
||||
let i = existing(s, cmd, p)?;
|
||||
let old = s.doc.user_materials.get(i).cloned().ok_or_else(|| bad(cmd, "no such material"))?;
|
||||
let name = match str_(p, "name") {
|
||||
Some(n) => new_name(s, cmd, n, Some(&old.name))?,
|
||||
None => old.name.clone(),
|
||||
};
|
||||
let density = if p.get("density_g_cm3").is_some() { density(cmd, p)? } else { old.density };
|
||||
let appearance = match look(cmd, p, &name)? {
|
||||
Some(l) => l,
|
||||
// A colour named after the material keeps following its name.
|
||||
None => old.appearance.clone().map(|mut l| {
|
||||
if l.name == old.name {
|
||||
l.name = name.clone();
|
||||
}
|
||||
l
|
||||
}),
|
||||
};
|
||||
let material = UserMaterial { name: name.clone(), density, appearance };
|
||||
let mut out = material_json(&material);
|
||||
if let Some(m) = s.doc_mut().user_materials.get_mut(i) {
|
||||
*m = material;
|
||||
}
|
||||
if name != old.name {
|
||||
move_uses(s, &old.name, &name);
|
||||
}
|
||||
out["bodies"] = json!(uses(s, &name).0);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "material.delete";
|
||||
let i = existing(s, cmd, p)?;
|
||||
let name = s.doc.user_materials.get(i).map(|m| m.name.clone()).ok_or_else(|| bad(cmd, "no such material"))?;
|
||||
let (bodies, rows) = uses(s, &name);
|
||||
let replace = match str_(p, "replace") {
|
||||
Some(r) => {
|
||||
let r = s.doc.material_name(r).ok_or_else(|| bad(cmd, format!("unknown material `{r}` (see material.list)")))?;
|
||||
if r.eq_ignore_ascii_case(&name) {
|
||||
return Err(bad(cmd, "`replace` must be another material"));
|
||||
}
|
||||
Some(r)
|
||||
}
|
||||
None if bodies.is_empty() && rows.is_empty() => None,
|
||||
None => {
|
||||
let mut used: Vec<String> =
|
||||
bodies.iter().map(|b| format!("body `{b}`")).chain(rows.iter().map(|r| format!("configuration `{r}`"))).collect();
|
||||
if used.len() > 10 {
|
||||
let more = used.len() - 10;
|
||||
used.truncate(10);
|
||||
used.push(format!("{more} more"));
|
||||
}
|
||||
return Err(bad(cmd, format!("`{name}` is used by {}: give `replace` (a material for them)", used.join(", "))));
|
||||
}
|
||||
};
|
||||
if let Some(r) = &replace {
|
||||
move_uses(s, &name, r);
|
||||
}
|
||||
s.doc_mut().user_materials.remove(i);
|
||||
Ok(json!({"deleted": name, "replaced_by": replace, "bodies": bodies}))
|
||||
}
|
||||
@@ -22,10 +22,12 @@ mod face;
|
||||
mod features;
|
||||
mod features_more;
|
||||
pub use features::auto_operation;
|
||||
mod component_link;
|
||||
mod config;
|
||||
mod file;
|
||||
mod inspect;
|
||||
pub(crate) mod joints;
|
||||
mod materials;
|
||||
mod measure_sel;
|
||||
mod mesh;
|
||||
pub(crate) mod motion;
|
||||
@@ -203,6 +205,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(sheet::COMMANDS.iter());
|
||||
v.extend(plastic::COMMANDS.iter());
|
||||
v.extend(component::COMMANDS.iter());
|
||||
v.extend(component_link::COMMANDS.iter());
|
||||
v.extend(joints::COMMANDS.iter());
|
||||
v.extend(motion::COMMANDS.iter());
|
||||
v.extend(config::COMMANDS.iter());
|
||||
@@ -214,6 +217,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(parameters::COMMANDS.iter());
|
||||
v.extend(file::COMMANDS.iter());
|
||||
v.extend(inspect::COMMANDS.iter());
|
||||
v.extend(materials::COMMANDS.iter());
|
||||
v.extend(section::COMMANDS.iter());
|
||||
v.extend(diagnostics::COMMANDS.iter());
|
||||
v
|
||||
|
||||
@@ -424,3 +424,118 @@ fn instance_styles_follow_source_identity_after_collision_rename_and_pending_mov
|
||||
assert_eq!(imported.bodies[0].body.color(), Some([1.0, 0.0, 0.0]));
|
||||
assert!(imported.bodies[0].body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0]));
|
||||
}
|
||||
|
||||
/// A design file path in a fresh temporary folder (removed by the caller).
|
||||
fn temp_design(tag: &str, ext: &str) -> (std::path::PathBuf, String) {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-visibility-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(format!("design.{ext}")).to_string_lossy().to_string();
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
/// #531: hiding a placement and a body is saved with the design and comes back when it opens.
|
||||
/// It is not an undo step (undoing a later feature leaves it hidden), but the design then has
|
||||
/// unsaved changes, and showing it again is saved the same way.
|
||||
#[test]
|
||||
fn hidden_placements_and_bodies_are_saved_with_the_design_but_not_undone() {
|
||||
let (dir, path) = temp_design("saved", "solidcraft");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":4,"width":4,"height":4,"body_name":"Base"}));
|
||||
let (component, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
run(&mut s, "file.save", json!({"path": path}));
|
||||
assert!(!s.is_dirty());
|
||||
|
||||
let undo = s.undo.len();
|
||||
run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]}));
|
||||
run(&mut s, "browser.visibility", json!({"visible":false,"items":["Base"]}));
|
||||
assert_eq!(s.undo.len(), undo, "hiding is not an undo step");
|
||||
assert!(s.is_dirty(), "hiding leaves the design with unsaved changes");
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1,"corner":[50,0,0],"operation":"new","body_name":"Later"}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.body_visible("Base") && !s.occurrence_visible(&[second]), "undo keeps the hiding");
|
||||
|
||||
run(&mut s, "file.save", json!({}));
|
||||
assert!(!s.is_dirty());
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
assert!(!s.body_visible("Base"), "the hidden body stays hidden");
|
||||
assert!(!s.occurrence_visible(&[second]), "the hidden placement stays hidden");
|
||||
assert!(s.occurrence_visible(&[first]));
|
||||
assert!(s.doc.components.iter().any(|c| c.id == component));
|
||||
assert!(!s.is_dirty(), "an opened design has no unsaved changes");
|
||||
assert!(s.undo.is_empty());
|
||||
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"folder":"bodies"}));
|
||||
assert!(s.is_dirty());
|
||||
run(&mut s, "file.save", json!({}));
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
assert!(s.body_visible("Base") && s.occurrence_visible(&[second]), "showing again is saved too");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #531: designs saved before visibility was kept (no `visibility` in the file) open with
|
||||
/// everything shown, and a design with nothing hidden saves exactly as before.
|
||||
#[test]
|
||||
fn designs_without_saved_visibility_open_unchanged() {
|
||||
let (dir, path) = temp_design("legacy", "json");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":4,"width":4,"height":4,"body_name":"Base"}));
|
||||
let mut legacy: Value = serde_json::from_slice(&solidcraft_io::write_design(&s.doc)).unwrap();
|
||||
assert!(legacy.get("visibility").is_none(), "nothing hidden: no visibility in the file");
|
||||
legacy["format"] = json!("solidcraft/15");
|
||||
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
|
||||
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
assert!(s.body_visible("Base"));
|
||||
assert_eq!(s.visibility, crate::Visibility::default());
|
||||
assert!(!s.is_dirty());
|
||||
run(&mut s, "file.save", json!({}));
|
||||
let saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert!(saved.get("visibility").is_none());
|
||||
|
||||
run(&mut s, "browser.visibility", json!({"visible":false,"items":["Base"]}));
|
||||
run(&mut s, "file.save", json!({}));
|
||||
let saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(saved["visibility"]["hidden_bodies"], json!(["Base"]));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Command names for linking another design are a proposal (#533); adjust them to the real API.
|
||||
const INSERT_DESIGN: &str = "component.insert_design";
|
||||
const UPDATE_LINKS: &str = "component.update_links";
|
||||
|
||||
fn saved_box_design(path: &std::path::Path, height: f64) {
|
||||
let mut part = Session::default();
|
||||
run(&mut part, "solid.box", json!({"length": 10, "width": 20, "height": height, "body_name": "Plate"}));
|
||||
run(&mut part, "file.save", json!({"path": path}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_design_brings_its_bodies_in_and_follows_edits_to_the_part_file() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-linked-design-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let part = dir.join("plate.solidcraft");
|
||||
// Given a part saved in its own design: a 10 x 20 x 30 plate.
|
||||
saved_box_design(&part, 30.0);
|
||||
// When an assembly inserts it as a linked component, the plate's body is in the assembly.
|
||||
let mut assembly = Session::default();
|
||||
let inserted = assembly.execute(INSERT_DESIGN, &json!({"path": part, "translate": [50, 0, 0]}));
|
||||
let inserted = inserted.unwrap_or_else(|e| panic!("{INSERT_DESIGN}: {e}"));
|
||||
let occurrence = inserted["occurrence"].as_u64().unwrap_or_else(|| panic!("no occurrence in {inserted}"));
|
||||
let measured = run(&mut assembly, "occurrence.properties", json!({"occurrence": occurrence, "path": [occurrence]}));
|
||||
near(&measured["total"]["volume_mm3"], 6000.0);
|
||||
near(&measured["total"]["center_of_mass"][0], 55.0);
|
||||
// When the part is edited (30 -> 45 tall) and saved again, updating the links follows it.
|
||||
saved_box_design(&part, 45.0);
|
||||
run(&mut assembly, UPDATE_LINKS, json!({}));
|
||||
let measured = run(&mut assembly, "occurrence.properties", json!({"occurrence": occurrence, "path": [occurrence]}));
|
||||
near(&measured["total"]["volume_mm3"], 9000.0);
|
||||
near(&measured["total"]["center_of_mass"][0], 55.0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::sheet::{SheetBody, SheetRule};
|
||||
use solidcraft_doc::sheet::{self, SheetBody, SheetRule};
|
||||
use solidcraft_doc::{FeatureKind, FlangeEdge};
|
||||
use solidcraft_geom::{Vec2, Vec3};
|
||||
|
||||
@@ -17,7 +17,8 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \
|
||||
edge: edges: [[x,y,z] on top/bottom edges, or {point: [x,y,z], angle?, height?} for an edge with its own angle and height] + \
|
||||
height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
|
||||
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs); name?, body_name? (the new body)",
|
||||
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs); name?, body_name? (the new body). \
|
||||
Thickness, K-factor and bend radius come from the rule (sheet.manage_rules); passing them here is refused",
|
||||
),
|
||||
CommandSpec::new("sheet.hem", "Hem", hem)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
@@ -36,20 +37,24 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
|
||||
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
|
||||
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's; \
|
||||
gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); active?: bool; delete?: bool",
|
||||
gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); \
|
||||
corner_relief_shape?: notch|round|rectangular (what is cut where two bends meet at a corner of the base, `corner_relief` across; default notch); \
|
||||
active?: bool; delete?: bool",
|
||||
),
|
||||
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
|
||||
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("flat")
|
||||
.noundo()
|
||||
.params("body? → flat size, outline loops and cut-outs (flat coordinates), bend lines and the bend table"),
|
||||
.params(
|
||||
"body? → flat size, outline loops and cut-outs (flat coordinates, as point lists), cutout_shapes (the same cut-outs exactly, in the same order: {circle: {center, radius}} for a round hole, else {segments: [{line: [a, b]} | {arc: {center, radius, start_deg, sweep_deg}} | {polyline: [...]}]}), pockets (with their own cutout_shapes), bend lines and the bend table",
|
||||
),
|
||||
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf)
|
||||
.noundo()
|
||||
.params("body?, path? → DXF (mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"),
|
||||
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table)
|
||||
.noundo()
|
||||
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates"),
|
||||
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; a bend that cut-outs split has a row for each piece"),
|
||||
];
|
||||
|
||||
fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
@@ -103,8 +108,23 @@ fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sheet values a flange takes from its rule. Passing one to `sheet.flange` is refused rather
|
||||
/// than ignored, so a script never gets a part of another thickness than it asked for.
|
||||
const RULE_ONLY: [&str; 8] =
|
||||
["thickness", "k_factor", "bend_radius", "relief_width", "relief_depth", "corner_relief", "corner_relief_shape", "hem_gap"];
|
||||
|
||||
fn flange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sheet.flange";
|
||||
let nested = p.get("base").filter(|b| b.is_object());
|
||||
if let Some(key) = RULE_ONLY.iter().find(|k| p.get(**k).is_some() || nested.is_some_and(|b| b.get(**k).is_some())) {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"`{key}` is not a sheet.flange parameter: the sheet metal rule sets it. Make a rule with \
|
||||
sheet.manage_rules {{name, {key}, …}} and pass its name as `rule`"
|
||||
),
|
||||
));
|
||||
}
|
||||
let ty = match str_(p, "type") {
|
||||
Some(t) => t.to_ascii_lowercase(),
|
||||
None if p.get("edges").is_some() => "edge".into(),
|
||||
@@ -216,7 +236,25 @@ fn fold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => None,
|
||||
};
|
||||
let body = str_(p, "body").map(str::to_string);
|
||||
add_feature(s, p, FeatureKind::SheetFold { sketch, curve, a, b, angle, radius, position, flip: bool_(p, "flip").unwrap_or(false), fixed, body })
|
||||
let r = add_feature(
|
||||
s,
|
||||
p,
|
||||
FeatureKind::SheetFold { sketch, curve, a, b, angle, radius, position, flip: bool_(p, "flip").unwrap_or(false), fixed, body },
|
||||
)?;
|
||||
// A recompute keeps a stored fold whose fixed point no longer touches the sheet (with a
|
||||
// warning); a new fold refuses one, since it picks no side (#263).
|
||||
if let Some(f) = fixed {
|
||||
let id = r.get("feature").and_then(Value::as_u64);
|
||||
let off =
|
||||
id.and_then(|id| s.model.result(id)).and_then(|res| res.warning.as_deref()).is_some_and(|w| w.contains(sheet::FOLD_FIXED_OFF_SHEET));
|
||||
if off {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("the fixed point ({:.3}, {:.3}, {:.3}) is not on the sheet: pick a point on the face of the side that stays", f.x, f.y, f.z),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn unfold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -269,6 +307,10 @@ fn rules(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*slot = e;
|
||||
}
|
||||
}
|
||||
if let Some(shape) = p.get("corner_relief_shape").filter(|v| !v.is_null()) {
|
||||
let shape = shape.as_str().and_then(solidcraft_doc::sheet::ReliefShape::parse);
|
||||
rule.corner_relief_shape = shape.ok_or_else(|| bad(cmd, "`corner_relief_shape` must be notch, round or rectangular"))?.name().into();
|
||||
}
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let values = s.doc.rule_values(&vals, &rule).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let doc = s.doc_mut();
|
||||
@@ -308,6 +350,39 @@ fn sheet_of(s: &Session, p: &Value, cmd: &str) -> Result<SheetBody> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The centre and radius of a loop that is one whole circle (often two semicircles).
|
||||
fn loop_circle(lp: &solidcraft_geom::Loop2) -> Option<(Vec2, f64)> {
|
||||
use solidcraft_geom::Seg2;
|
||||
let Some(Seg2::Arc { center, radius, sweep, .. }) = lp.segs.first() else { return None };
|
||||
let total: f64 = lp.segs.iter().filter_map(|s| if let Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum();
|
||||
let circular = lp.segs.iter().all(
|
||||
|s| matches!(s, Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r - *radius).abs() < 1e-9 && a.signum() == sweep.signum()),
|
||||
);
|
||||
(circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9).then_some((*center, *radius))
|
||||
}
|
||||
|
||||
/// A loop's exact shape for the flat pattern JSON: a circle, or its lines and arcs (other
|
||||
/// curves as point lists at the flat pattern's 0.001 mm tolerance).
|
||||
fn loop_shape(lp: &solidcraft_geom::Loop2) -> Value {
|
||||
use solidcraft_geom::Seg2;
|
||||
let pt = |v: Vec2| json!([v.x, v.y]);
|
||||
if let Some((center, radius)) = loop_circle(lp) {
|
||||
return json!({"circle": {"center": pt(center), "radius": radius}});
|
||||
}
|
||||
let segments: Vec<Value> = lp
|
||||
.segs
|
||||
.iter()
|
||||
.map(|seg| match *seg {
|
||||
Seg2::Line { a, b } => json!({"line": [pt(a), pt(b)]}),
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
json!({"arc": {"center": pt(center), "radius": radius, "start_deg": start.to_degrees(), "sweep_deg": sweep.to_degrees()}})
|
||||
}
|
||||
ref other => json!({"polyline": other.polyline(1e-3).into_iter().map(pt).collect::<Vec<_>>()}),
|
||||
})
|
||||
.collect();
|
||||
json!({"segments": segments})
|
||||
}
|
||||
|
||||
fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
let regions = sh.flat_regions()?;
|
||||
let loops: Vec<Vec<Vec2>> = regions.iter().map(|r| r.outer.polyline(1e-3)).collect();
|
||||
@@ -320,6 +395,7 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
let pt = |v: &Vec2| json!([v.x, v.y]);
|
||||
let outline: Vec<Value> = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect();
|
||||
let holes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(|h| Value::Array(h.polyline(1e-3).iter().map(pt).collect())).collect();
|
||||
let hole_shapes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(loop_shape).collect();
|
||||
let bends: Vec<Value> = sh
|
||||
.bends()
|
||||
.iter()
|
||||
@@ -341,11 +417,12 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
};
|
||||
json!({"outline": p.region.outer.polyline(1e-3).iter().map(pt).collect::<Vec<_>>(),
|
||||
"cutouts": p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect::<Vec<_>>()).collect::<Vec<_>>(),
|
||||
"cutout_shapes": p.region.holes.iter().map(loop_shape).collect::<Vec<_>>(),
|
||||
"side": side, "depth_mm": p.z1-p.z0, "z0_mm": p.z0, "z1_mm": p.z1})
|
||||
})
|
||||
.collect();
|
||||
Ok(
|
||||
json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "pockets": pockets, "bends": bends, "unfolded": sh.flat}),
|
||||
json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "cutout_shapes": hole_shapes, "pockets": pockets, "bends": bends, "unfolded": sh.flat}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -419,17 +496,13 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
for (h, layer) in loops {
|
||||
// A circle is often represented by two semicircles. Keep the whole analytic
|
||||
// boundary as one CIRCLE rather than approximating outer boundaries with chords.
|
||||
if let Some(solidcraft_geom::Seg2::Arc { center, radius, sweep, .. }) = h.segs.first() {
|
||||
let total: f64 = h.segs.iter().filter_map(|s| if let solidcraft_geom::Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum();
|
||||
let circular = h.segs.iter().all(|s| matches!(s, solidcraft_geom::Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r-*radius).abs() < 1e-9 && a.signum() == sweep.signum()));
|
||||
if circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9 {
|
||||
w(0, "CIRCLE");
|
||||
w(8, &layer);
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(*radius));
|
||||
continue;
|
||||
}
|
||||
if let Some((center, radius)) = loop_circle(h) {
|
||||
w(0, "CIRCLE");
|
||||
w(8, &layer);
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(radius));
|
||||
continue;
|
||||
}
|
||||
for seg in &h.segs {
|
||||
match *seg {
|
||||
|
||||
@@ -954,6 +954,81 @@ fn tray_shrunk_by_a_parameter_unfolds_like_one_built_at_that_size() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bend_table_and_the_dxf_follow_what_flat_cuts_leave_of_a_bend() {
|
||||
// A 200 x 100 plate with a 40 mm flange along y = 0: the bend's centre line runs at y = -pi/2
|
||||
// (R 1, T 2, K 0.5: a bend allowance of pi) from x = 0 to 200.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "2 mm", "active": true}));
|
||||
base(&mut s, 200.0, 100.0);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[100, 0, 2]], "height": 40, "angle": 90, "radius": 1, "position": "outside"}));
|
||||
// (bend number, length, start x, end x) of every row; all on the centre line.
|
||||
let rows = |s: &mut Session| -> Vec<(u64, f64, f64, f64)> {
|
||||
let f = flat(s);
|
||||
let num = |v: &Value| v.as_f64().unwrap_or(f64::NAN);
|
||||
f["bends"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|b| {
|
||||
assert!((num(&b["start"][1]) + FRAC_PI_2).abs() < 1e-9 && (num(&b["end"][1]) + FRAC_PI_2).abs() < 1e-9, "{b}");
|
||||
(b["bend"].as_u64().unwrap_or(0), num(&b["length"]), num(&b["start"][0]), num(&b["end"][0]))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let close = |got: &[(u64, f64, f64, f64)], want: &[(u64, f64, f64, f64)]| {
|
||||
got.len() == want.len()
|
||||
&& got.iter().zip(want).all(|(g, w)| g.0 == w.0 && (g.1 - w.1).abs() < 1e-9 && (g.2 - w.2).abs() < 1e-9 && (g.3 - w.3).abs() < 1e-9)
|
||||
};
|
||||
// The DXF's bend lines: both ends' x of every LINE along the centre line, left to right; the
|
||||
// BEND_UP layer holds nothing else.
|
||||
let bend_lines = |s: &mut Session| -> Vec<(f64, f64)> {
|
||||
let text = run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
|
||||
let on_layer = text.lines().collect::<Vec<_>>().windows(2).filter(|w| w[0].trim() == "8" && w[1].trim() == "BEND_UP").count();
|
||||
let mut found: Vec<(f64, f64)> = solidcraft_io::read_dxf(&text)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|g| match g {
|
||||
solidcraft_io::Geom2::Line(a, b) if (a.y + FRAC_PI_2).abs() < 1e-6 && (b.y + FRAC_PI_2).abs() < 1e-6 => {
|
||||
Some((a.x.min(b.x), a.x.max(b.x)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
found.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
assert_eq!(found.len(), on_layer, "{text}");
|
||||
found
|
||||
};
|
||||
assert!(close(&rows(&mut s), &[(1, 200.0, 0.0, 200.0)]), "{:?}", rows(&mut s));
|
||||
assert_eq!(bend_lines(&mut s).len(), 1);
|
||||
// A flat cut takes the flange's left half, its bend and a strip of the base.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
rectangle_tool(&mut s, "Half", [-10.0, -60.0], [100.0, 10.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Half", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!(close(&rows(&mut s), &[(1, 100.0, 100.0, 200.0)]), "{:?}", rows(&mut s));
|
||||
let lines = bend_lines(&mut s);
|
||||
assert!(lines.len() == 1 && (lines[0].0 - 100.0).abs() < 1e-6 && (lines[0].1 - 200.0).abs() < 1e-6, "{lines:?}");
|
||||
let csv = run(&mut s, "sheet.export_bend_table", json!({}));
|
||||
let text = csv["text"].as_str().unwrap_or_default();
|
||||
assert_eq!((text.lines().count(), csv["bends"].as_u64()), (2, Some(1)), "{text}");
|
||||
assert!(text.lines().nth(1).is_some_and(|l| l.contains(",1,90.000000000,up,1.000000000,3.141592654,100.000000000,100.000000000,")), "{text}");
|
||||
// A slot across what is left splits the bend: a row and a line for each piece, both bend 1.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
rectangle_tool(&mut s, "Slot", [140.0, -20.0], [160.0, 10.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!(close(&rows(&mut s), &[(1, 40.0, 100.0, 140.0), (1, 40.0, 160.0, 200.0)]), "{:?}", rows(&mut s));
|
||||
let lines = bend_lines(&mut s);
|
||||
assert!(lines.len() == 2 && (lines[0].1 - 140.0).abs() < 1e-6 && (lines[1].0 - 160.0).abs() < 1e-6, "{lines:?}");
|
||||
let csv = run(&mut s, "sheet.export_bend_table", json!({}));
|
||||
assert_eq!((csv["text"].as_str().unwrap_or_default().lines().count(), csv["bends"].as_u64()), (3, Some(2)));
|
||||
// Unfolded, the flat pattern lists the same pieces.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!(close(&rows(&mut s), &[(1, 40.0, 100.0, 140.0), (1, 40.0, 160.0, 200.0)]), "{:?}", rows(&mut s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() {
|
||||
let mut s = Session::default();
|
||||
@@ -1865,6 +1940,72 @@ fn edge_flanges_on_two_collinear_tab_ends_in_one_feature() {
|
||||
assert!(rel(volume(&mut s), volume(&mut separate)) < 1e-6);
|
||||
}
|
||||
|
||||
/// A 160 x 30 bar with a 20 x 30 leg standing past its top edge at the left end (2 mm, R 1,
|
||||
/// K 0.5, so folding keeps the volume): a base flange, or a plate converted to sheet metal.
|
||||
fn bar_with_leg(s: &mut Session, converted: bool) {
|
||||
run(s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "active": true}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Bar"}));
|
||||
run(s, "sketch.line", json!({"points": [[0, 0], [160, 0], [160, 30], [20, 30], [20, 60], [0, 60]], "closed": true}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
if converted {
|
||||
run(s, "solid.extrude", json!({"sketch": "Bar", "distance": 2, "body_name": "Plate"}));
|
||||
run(s, "sheet.convert", json!({"body": "Plate", "face": [80, 15, 2]}));
|
||||
} else {
|
||||
run(s, "sheet.flange", json!({"sketch": "Bar"}));
|
||||
}
|
||||
}
|
||||
|
||||
/// An edge flange takes its setback off its own edge only: the leg standing past the edge's end
|
||||
/// stays, and the rule's bend relief frees the bend and the flange from it.
|
||||
#[test]
|
||||
fn an_edge_flange_beside_a_leg_of_the_base_trims_its_own_edge_and_relieves_its_bend() {
|
||||
// Bend allowance (π/2)·(R + K·T) = π; the rule's relief is Thickness wide, half of it deep.
|
||||
let (ba, width, depth) = (PI, 2.0, 1.0);
|
||||
for (converted, position, setback) in [(false, "inside", 3.0), (false, "outside", 0.0), (true, "inside", 3.0)] {
|
||||
let what = format!("converted {converted}, {position}");
|
||||
let mut s = Session::default();
|
||||
bar_with_leg(&mut s, converted);
|
||||
// Where the sheet's bottom face lies.
|
||||
let z0 = bbox(&mut s)["min"][2].as_f64().unwrap_or(f64::NAN);
|
||||
// A 90° flange 15 high on the bar's top edge (x 20…160); the leg stands past its left end.
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[90, 30, z0 + 2.0]], "height": 15, "angle": 90, "position": position}));
|
||||
assert!(no_errors_or_warnings(&s), "{what}: {:?}", s.model.results);
|
||||
// By hand: the base loses the setback under that edge alone, and the relief beside the
|
||||
// bend's end at x = 20, from `depth` before the bend line to `width` past the flange's
|
||||
// far edge in the flat (BA and the 12 mm panel past the line).
|
||||
let (line, panel) = (30.0 - setback, 12.0);
|
||||
let relief = width * (depth + ba + panel + width);
|
||||
let base = 160.0 * 30.0 + 20.0 * 30.0 - 140.0 * setback - relief;
|
||||
let want = 2.0 * (base + 140.0 * (ba + panel));
|
||||
assert!(rel(volume(&mut s), want) < 1e-5, "{what}: {} vs {want}", volume(&mut s));
|
||||
assert!(contains(&s, [10.0, 50.0, z0 + 1.0]), "{what}: the leg is kept");
|
||||
assert!(!contains(&s, [19.0, line + 0.5, z0 + 1.0]), "{what}: the relief is cut");
|
||||
assert!(contains(&s, [17.0, line + 0.5, z0 + 1.0]), "{what}: the leg beside the relief stays");
|
||||
assert!(contains(&s, [19.0, 50.0, z0 + 1.0]), "{what}: the leg past the relief's end stays");
|
||||
// The flange's mid plane is R + T/2 = 2 past the bend line; it ends at the leg.
|
||||
assert!(contains(&s, [90.0, line + 2.0, z0 + 10.0]), "{what}: the flange stands");
|
||||
assert!(!contains(&s, [10.0, line + 2.0, z0 + 10.0]), "{what}: the flange ends at the leg");
|
||||
let f = flat(&mut s);
|
||||
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{what}: {f}");
|
||||
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0), "{what}: {f}");
|
||||
assert!((f["bends"][0]["length"].as_f64().unwrap_or(f64::NAN) - 140.0).abs() < 1e-9, "{what}: {f}");
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!(rel(volume(&mut s), want) < 1e-9, "{what}: flat {} vs {want}", volume(&mut s));
|
||||
}
|
||||
}
|
||||
|
||||
/// A bend relief the rule makes too narrow to cut is left out, and the flange says where.
|
||||
#[test]
|
||||
fn a_bend_relief_left_out_is_reported_on_the_flange() {
|
||||
let mut s = Session::default();
|
||||
bar_with_leg(&mut s, false);
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "relief_width": "0 mm"}));
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[90, 30, 2]], "height": 15, "angle": 90}));
|
||||
let w = s.model.results.last().and_then(|r| r.warning.clone()).unwrap_or_default();
|
||||
assert!(w.contains("no bend relief at (20.000, 27.000, 0.000)") && w.contains("relief width"), "{w}");
|
||||
assert!(contains(&s, [10.0, 50.0, 1.0]), "the leg is kept");
|
||||
}
|
||||
|
||||
/// `fixed` inside a cut-out touches no material: the fold must refuse it or say which side it kept (#263).
|
||||
#[test]
|
||||
fn fold_fixed_point_off_the_sheet_is_not_silently_ignored() {
|
||||
@@ -1969,7 +2110,6 @@ fn hole_beside_a_bend_with_a_round_cut_out_cuts_and_the_bend_stays_guarded() {
|
||||
/// A fold whose stored `fixed` point ends up off the sheet after a parameter change keeps working:
|
||||
/// the point still says which side stays (#370). Only a new pick off the sheet is refused (#263).
|
||||
#[test]
|
||||
#[ignore = "fails until #370 is fixed"]
|
||||
fn fold_keeps_working_when_a_parameter_moves_the_sheet_off_its_fixed_point() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "w", "expression": "100 mm"}));
|
||||
@@ -1991,7 +2131,6 @@ fn fold_keeps_working_when_a_parameter_moves_the_sheet_off_its_fixed_point() {
|
||||
/// `fixed` 20 mm above a 2.5 mm sheet touches no material: refuse it or say so, like a point in a
|
||||
/// cut-out (#371).
|
||||
#[test]
|
||||
#[ignore = "fails until #371 is fixed"]
|
||||
fn fold_fixed_point_above_the_sheet_is_not_silently_accepted() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
@@ -2060,6 +2199,103 @@ fn closed_corners_reach_the_flat_pattern_and_the_dxf() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_reliefs_of_the_rule_reach_the_part_the_flat_pattern_and_the_dxf() {
|
||||
use solidcraft_geom::Vec2;
|
||||
// The tray at 72°: its bends meet at the corners of the base, s0 = 3·tan 36° in from the sheet's
|
||||
// edges, and are BA = 2θ wide in the flat. A relief 8 mm across reaches r = 4 mm along both edges
|
||||
// and takes, at each corner, r²·π/4 (round) or r² (rectangular) of the base and r of two bends.
|
||||
let th = 72f64.to_radians();
|
||||
let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0);
|
||||
let corners = [(s0, s0), (120.0 - s0, s0), (120.0 - s0, 90.0 - s0), (s0, 90.0 - s0)];
|
||||
let export = |s: &mut Session| run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
|
||||
let mut notch = Session::default();
|
||||
alu_tray(&mut notch, 72.0, json!({"corner_relief": "8 mm"}));
|
||||
let v0 = volume(&mut notch);
|
||||
assert_eq!(export(&mut notch).matches("0\nARC\n").count(), 0);
|
||||
run(&mut notch, "sheet.unfold", json!({}));
|
||||
let flat0 = volume(&mut notch);
|
||||
for (shape, of_the_base) in [("round", 4.0 * PI), ("rectangular", 16.0)] {
|
||||
let mut s = Session::default();
|
||||
alu_tray(&mut s, 72.0, json!({"corner_relief_shape": shape, "corner_relief": "8 mm"}));
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{shape}: {:?}", s.model.results);
|
||||
let listed = run(&mut s, "sheet.rules", json!({}));
|
||||
assert_eq!(listed["rules"][0]["rule"]["corner_relief_shape"], shape);
|
||||
assert_eq!(listed["rules"][0]["values"]["corner_relief_shape"], shape);
|
||||
// The folded part loses what the flat pattern loses (K = 0.5 keeps the volume of a bend).
|
||||
let (v, want) = (volume(&mut s), 4.0 * (of_the_base + 8.0 * ba) * 2.0);
|
||||
assert!((v0 - v - want).abs() < 0.1, "{shape}: {v0} - {v} vs {want}");
|
||||
// The bend table: every bend is 8 mm shorter.
|
||||
let f = flat(&mut s);
|
||||
let lengths: Vec<f64> =
|
||||
f["bends"].as_array().map(|b| b.iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect()).unwrap_or_default();
|
||||
for (got, edge) in lengths.iter().zip([120.0, 90.0, 120.0, 90.0]) {
|
||||
assert!(lengths.len() == 4 && (got - (edge - 2.0 * s0 - 8.0)).abs() < 1e-6, "{shape}: {lengths:?}");
|
||||
}
|
||||
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{shape}");
|
||||
// The DXF: the round reliefs are arcs of radius 4 about the four corners, the square ones lines.
|
||||
let dxf = export(&mut s);
|
||||
let arcs: Vec<(Vec2, f64)> = solidcraft_io::read_dxf(&dxf)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|g| if let solidcraft_io::Geom2::Arc { c, a, .. } = g { Some((*c, a.dist(*c))) } else { None })
|
||||
.collect();
|
||||
if shape == "round" {
|
||||
assert_eq!(arcs.len(), 4, "{arcs:?}");
|
||||
for (x, y) in corners {
|
||||
assert!(arcs.iter().any(|(c, r)| c.dist(Vec2::new(x, y)) < 1e-6 && (r - 4.0).abs() < 1e-6), "({x}, {y}): {arcs:?}");
|
||||
}
|
||||
} else {
|
||||
assert!(arcs.is_empty() && dxf.contains(&format!("{:.6}\n20\n{:.6}\n", s0 + 4.0, s0 + 4.0)), "{arcs:?}");
|
||||
}
|
||||
// Unfold and refold keep it, and a cut made folded still goes through: a Ø20 hole in the base.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((flat0 - volume(&mut s) - want).abs() < 0.1, "{shape}: unfolded {flat0} - {} vs {want}", volume(&mut s));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{shape}");
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [60, 45], "diameter": 20}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 10, "operation": "cut", "through_all": true}));
|
||||
assert!((v - volume(&mut s) - PI * 100.0 * 2.0).abs() < 0.5, "{shape}: {v} - {}", volume(&mut s));
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((volume(&mut reopened) - volume(&mut s)).abs() < 1e-6, "{shape}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_corner_relief_shape_is_a_rule_setting_checked_and_undone() {
|
||||
let mut s = Session::default();
|
||||
alu_tray(&mut s, 72.0, json!({}));
|
||||
let shape = |s: &mut Session| run(s, "sheet.rules", json!({}))["rules"][0]["rule"]["corner_relief_shape"].clone();
|
||||
let v0 = volume(&mut s);
|
||||
assert_eq!(shape(&mut s), "notch");
|
||||
// Only the three names are shapes; a refused one changes nothing.
|
||||
for wrong in [json!("star"), json!("square"), json!(""), json!(2)] {
|
||||
let e = s.execute("sheet.manage_rules", &json!({"name": "AL2", "corner_relief_shape": wrong})).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(e.contains("`corner_relief_shape` must be notch, round or rectangular"), "{e}");
|
||||
}
|
||||
assert!(shape(&mut s) == "notch" && volume(&mut s) == v0);
|
||||
// Changing the rule changes the parts made with it; other settings leave the shape alone.
|
||||
let r = run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief_shape": "Round"}));
|
||||
assert_eq!((&r["rule"]["corner_relief_shape"], &r["values"]["corner_relief_shape"]), (&json!("round"), &json!("round")));
|
||||
let round = volume(&mut s);
|
||||
assert!(round < v0 - 200.0, "{v0} -> {round}");
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "hem_gap": "0.02 mm"}));
|
||||
assert!(shape(&mut s) == "round" && volume(&mut s) == round);
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief_shape": null, "corner_relief": "6 mm"}));
|
||||
assert!(shape(&mut s) == "round" && volume(&mut s) > round);
|
||||
for _ in 0..3 {
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
}
|
||||
assert!(shape(&mut s) == "notch" && volume(&mut s) == v0);
|
||||
// A relief longer than the bends is left out and reported on the flange; the part is the notched one.
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief_shape": "rectangular", "corner_relief": "200 mm"}));
|
||||
let w = s.model.results.last().and_then(|r| r.warning.clone()).unwrap_or_default();
|
||||
assert_eq!(w.matches("no corner relief at (").count(), 4, "{w}");
|
||||
assert!(w.contains("would take a whole bend away") && volume(&mut s) == v0, "{w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_left_open_are_reported_on_the_flange() {
|
||||
let mut s = Session::default();
|
||||
@@ -2265,6 +2501,197 @@ fn per_edge_values_are_expressions_and_feature_inputs() {
|
||||
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
|
||||
}
|
||||
|
||||
/// The bends' lengths in the flat pattern, in the order the bends were made.
|
||||
fn bend_lengths(s: &mut Session) -> Vec<f64> {
|
||||
flat(s)["bends"].as_array().cloned().unwrap_or_default().iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect()
|
||||
}
|
||||
|
||||
/// A tray on a 120 wide, 90 deep base whose top edge is `top` wide (120: a rectangle), in 2 mm
|
||||
/// aluminium (R 1, K 0.5) with the flange gap `gap`: walls 20 high at `deg` in one feature and,
|
||||
/// in the next feature, a return 12 high at 180° − `deg` on the far edge of each of the walls
|
||||
/// `returns` (0…3: bottom, right, top, left; none: no feature), picked on the walls' inner faces,
|
||||
/// so that the returns turn in over the base and lie level.
|
||||
fn tray_with_returns(top: f64, deg: f64, gap: &str, returns: &[usize]) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(
|
||||
&mut s,
|
||||
"sheet.manage_rules",
|
||||
json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": gap, "active": true}),
|
||||
);
|
||||
let inset = (120.0 - top) / 2.0;
|
||||
let pts = [(0.0, 0.0), (120.0, 0.0), (120.0 - inset, 90.0), (inset, 90.0)];
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.line", json!({"points": pts.map(|p| [p.0, p.1]), "closed": true}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
// A wall's inner face 20 up its outer face from the base's edge: that far in from the edge
|
||||
// and that high (see the doc tests).
|
||||
let th = deg.to_radians();
|
||||
let (into, up) = (-20.0 * th.cos() + 2.0 * th.sin(), 20.0 * th.sin() + 2.0 * th.cos());
|
||||
let pick = |k: usize, into: f64, z: f64| {
|
||||
let (a, b) = (pts[k], pts[(k + 1) % 4]);
|
||||
let l = (b.0 - a.0).hypot(b.1 - a.1);
|
||||
json!([(a.0 + b.0) / 2.0 - (b.1 - a.1) / l * into, (a.1 + b.1) / 2.0 + (b.0 - a.0) / l * into, z])
|
||||
};
|
||||
let edges: Vec<Value> = (0..4).map(|k| pick(k, 0.0, 2.0)).collect();
|
||||
run(&mut s, "sheet.flange", json!({"edges": edges, "height": 20, "angle": format!("{deg} deg")}));
|
||||
if !returns.is_empty() {
|
||||
let edges: Vec<Value> = returns.iter().map(|&k| pick(k, into, up)).collect();
|
||||
run(&mut s, "sheet.flange", json!({"edges": edges, "height": 12, "angle": format!("{} deg", 180.0 - deg)}));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_on_a_tray_close_at_its_corners() {
|
||||
use std::f64::consts::SQRT_2;
|
||||
// Walls at 90° with returns at 90°. The returns' feature used to fail here ("sew: nonmanifold
|
||||
// edge contact"): each return took its wall's bend length and ran through its neighbours.
|
||||
let mut s = tray_with_returns(120.0, 90.0, "0.5 mm", &[0, 1, 2, 3]);
|
||||
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
|
||||
// By hand (the derivation is with the doc tests; e = g/√2). The part fills 120 x 90 x 20.
|
||||
let e = 0.5 / SQRT_2;
|
||||
let b = bbox(&mut s);
|
||||
for (k, want) in [120.0, 90.0, 20.0].into_iter().enumerate() {
|
||||
let (lo, hi) = (b["min"][k].as_f64().unwrap_or(f64::NAN), b["max"][k].as_f64().unwrap_or(f64::NAN));
|
||||
assert!(lo.abs() < 1e-6 && (hi - want).abs() < 1e-6, "{b}");
|
||||
}
|
||||
// The walls' bends are the base's trimmed edges; the returns' bends stop g/√2 short of the
|
||||
// mitre x = y through (3, 3) at both ends.
|
||||
let f = flat(&mut s);
|
||||
let lengths = bend_lengths(&mut s);
|
||||
assert_eq!(lengths.len(), 8, "{f}");
|
||||
for (got, want) in lengths.iter().zip([114.0, 84.0, 114.0, 84.0, 114.0 - 2.0 * e, 84.0 - 2.0 * e, 114.0 - 2.0 * e, 84.0 - 2.0 * e]) {
|
||||
assert!((got - want).abs() < 1e-6, "{lengths:?}");
|
||||
}
|
||||
// The flat pattern is one outline through the corners of the return's panel on the wall on
|
||||
// y = 0: 9 high past two bend zones π wide and the wall's 14, mitred at 45° at both ends.
|
||||
let outline = outline_points(&f);
|
||||
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}");
|
||||
let (y0, y1) = (-11.0 - 2.0 * PI, -20.0 - 2.0 * PI);
|
||||
for (x, y) in [(3.0 + e, y0), (117.0 - e, y0), (108.0 - e, y1), (12.0 + e, y1)] {
|
||||
assert!(outline.iter().any(|p| (p.0 - x).abs() < 1e-6 && (p.1 - y).abs() < 1e-6), "({x}, {y}) not in {outline:?}");
|
||||
}
|
||||
// It holds the base, the walls' bends and panels and the returns' bends and panels, nothing
|
||||
// more or less, and is 20 + 2π wider than the base on every side.
|
||||
let area = 114.0 * 84.0 + 396.0 * PI + 28.0 * (202.0 - 4.0 * e) + 2.0 * PI * (198.0 - 4.0 * e) + 18.0 * (180.0 - 4.0 * e);
|
||||
assert!(rel(polygon_area(&outline), area) < 1e-9, "{} vs {area}", polygon_area(&outline));
|
||||
for (k, want) in [160.0 + 4.0 * PI, 130.0 + 4.0 * PI, 2.0].into_iter().enumerate() {
|
||||
assert!((f["flat_size_mm"][k].as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{}: {want}", f["flat_size_mm"]);
|
||||
}
|
||||
// Folded, the part holds that material (K = 0.5 keeps the volume).
|
||||
let v = volume(&mut s);
|
||||
assert!(rel(v, area * 2.0) < 1e-3, "{v} vs {}", area * 2.0);
|
||||
// The DXF: the outline in straight lines, and eight bend lines.
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
|
||||
assert_eq!(dxf.matches("0\nLINE\n").count(), outline.len() + 8, "{} outline points", outline.len());
|
||||
// Unfold and refold keep it.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!(same_points(&outline_points(&flat(&mut s)), &outline));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!(rel(volume(&mut s), v) < 1e-9);
|
||||
// Reopened, it is the same part.
|
||||
let mut again = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(no_errors_or_warnings(&again), "{:?}", again.model.results);
|
||||
assert!(rel(volume(&mut again), v) < 1e-9);
|
||||
assert!(same_points(&outline_points(&flat(&mut again)), &outline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_on_leaning_walls_stay_inside_the_walls() {
|
||||
// A trapezoid (corners of 77.5° and 102.5°) with walls at 110° and level returns at 70°. Each
|
||||
// return used to run its wall's bend length from where the wall's far edge started, out
|
||||
// through the neighbouring wall: the part reached x = 120.588 where its walls end at 117.587.
|
||||
let mut s = tray_with_returns(80.0, 110.0, "0.5 mm", &[0, 1, 2, 3]);
|
||||
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
|
||||
// The returns lie inside the walls and no higher than their tips: the part's box is the
|
||||
// walls' own.
|
||||
let (with, without) = (bbox(&mut s), bbox(&mut tray_with_returns(80.0, 110.0, "0.5 mm", &[])));
|
||||
for end in ["min", "max"] {
|
||||
for k in 0..3 {
|
||||
let (a, b) = (with[end][k].as_f64().unwrap_or(f64::NAN), without[end][k].as_f64().unwrap_or(f64::NAN));
|
||||
assert!((a - b).abs() < 1e-6, "{with} vs {without}");
|
||||
}
|
||||
}
|
||||
// By hand (the doc tests): a return's panel starts v = 3·sin 110° + (20 − 3·tan 35°)·cos 70°
|
||||
// in from its wall's edge, and at a base corner of angle α its bend stops
|
||||
// x_b = (g/2 + v·cos(α/2)) / sin(α/2) along the edge from the corner.
|
||||
let th = 110f64.to_radians();
|
||||
let v = 3.0 * th.sin() - (20.0 - 3.0 * 35f64.to_radians().tan()) * th.cos();
|
||||
let x_b = |alpha: f64| (0.25 + v * (alpha / 2.0).cos()) / (alpha / 2.0).sin();
|
||||
let (acute, side) = ((90.0f64 / 20.0).atan(), 20f64.hypot(90.0));
|
||||
let want = [120.0 - 2.0 * x_b(acute), side - x_b(acute) - x_b(PI - acute), 80.0 - 2.0 * x_b(PI - acute), side - x_b(acute) - x_b(PI - acute)];
|
||||
let lengths = bend_lengths(&mut s);
|
||||
assert_eq!(lengths.len(), 8, "{lengths:?}");
|
||||
for (got, want) in lengths[4..].iter().zip(want) {
|
||||
assert!((got - want).abs() < 1e-6, "{lengths:?} vs {want:?}");
|
||||
}
|
||||
// One outline in the flat, and the folded part holds its material.
|
||||
let f = flat(&mut s);
|
||||
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}");
|
||||
let (outline, vol) = (outline_points(&f), volume(&mut s));
|
||||
assert!(rel(vol, polygon_area(&outline) * 2.0) < 1e-3, "{vol} vs {}", polygon_area(&outline) * 2.0);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!(same_points(&outline_points(&flat(&mut s)), &outline));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!(rel(volume(&mut s), vol) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_return_alone_on_a_leaning_wall_keeps_out_of_the_walls_beside_it() {
|
||||
use std::f64::consts::SQRT_2;
|
||||
// The tray with walls at 110° and a level return on the wall on y = 0 only. It used to take
|
||||
// that wall's bend length (120 − 2·3·tan 55° = 111.431) from where the wall's far edge starts
|
||||
// and came out through the wall on x = 120: the part reached x = 120.504 where its walls end
|
||||
// at 118.716.
|
||||
let mut s = tray_with_returns(120.0, 110.0, "0.5 mm", &[0]);
|
||||
assert!(no_errors_or_warnings(&s), "{:?}", s.model.results);
|
||||
let (with, without) = (bbox(&mut s), bbox(&mut tray_with_returns(120.0, 110.0, "0.5 mm", &[])));
|
||||
for end in ["min", "max"] {
|
||||
for k in 0..3 {
|
||||
let (a, b) = (with[end][k].as_f64().unwrap_or(f64::NAN), without[end][k].as_f64().unwrap_or(f64::NAN));
|
||||
assert!((a - b).abs() < 1e-6, "{with} vs {without}");
|
||||
}
|
||||
}
|
||||
// By hand (the doc tests): the walls beside it lean in over it and come nearest at its top,
|
||||
// 20·sin 110° up, where their inner faces are 2/sin 110° + 20·cos 70° in from the base's edges.
|
||||
// The return stops square g/√2 short of them at both ends: 120 − 2·9.322312 = 101.355376.
|
||||
let th = 110f64.to_radians();
|
||||
let want = 120.0 - 2.0 * (0.5 / SQRT_2 + 2.0 / th.sin() - 20.0 * th.cos());
|
||||
let lengths = bend_lengths(&mut s);
|
||||
assert!(lengths.len() == 5 && (lengths[4] - want).abs() < 1e-6 && (want - 101.355376).abs() < 1e-6, "{lengths:?} vs {want}");
|
||||
// One outline in the flat, and the folded part holds its material.
|
||||
let f = flat(&mut s);
|
||||
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}");
|
||||
let (outline, vol) = (outline_points(&f), volume(&mut s));
|
||||
assert!(rel(vol, polygon_area(&outline) * 2.0) < 1e-3, "{vol} vs {}", polygon_area(&outline) * 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_follow_the_rules_gap_and_stay_open_with_a_warning_when_it_is_zero() {
|
||||
use std::f64::consts::SQRT_2;
|
||||
// The mitre keeps the rule's gap: at 90° the returns' bends are 114 − 2·g/√2 and 84 − 2·g/√2.
|
||||
let mut s = tray_with_returns(120.0, 90.0, "0.5 mm", &[0, 1, 2, 3]);
|
||||
for g in [0.5, 1.0, 0.2] {
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "gap": format!("{g} mm")}));
|
||||
assert!(no_errors_or_warnings(&s), "{g}: {:?}", s.model.results);
|
||||
let lengths = bend_lengths(&mut s);
|
||||
assert_eq!(lengths.len(), 8, "{g}: {lengths:?}");
|
||||
for (got, want) in lengths[4..].iter().zip([114.0, 84.0, 114.0, 84.0]) {
|
||||
assert!((got - (want - 2.0 * g / SQRT_2)).abs() < 1e-6, "{g}: {lengths:?}");
|
||||
}
|
||||
}
|
||||
// A gap of zero can't be kept: the walls' corners and the returns' corners are left open,
|
||||
// each feature with its warning, and each return spans its wall's far edge (here the wall's
|
||||
// bend, 120 − 2·3·tan 55°).
|
||||
let mut s = tray_with_returns(120.0, 110.0, "0 mm", &[0, 1, 2, 3]);
|
||||
let warnings: Vec<String> = s.model.results.iter().filter_map(|r| r.warning.clone()).collect();
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()) && warnings.len() == 2, "{:?}", s.model.results);
|
||||
assert!(warnings.iter().all(|w| w.matches("left open").count() == 4), "{warnings:?}");
|
||||
let lengths = bend_lengths(&mut s);
|
||||
assert!((lengths[4] - lengths[0]).abs() < 1e-9 && (lengths[0] - (120.0 - 6.0 * 55f64.to_radians().tan())).abs() < 1e-6, "{lengths:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_per_edge_input_is_refused_and_plain_edges_stay_plain() {
|
||||
let mut s = Session::default();
|
||||
@@ -2370,3 +2797,229 @@ fn designs_without_per_edge_values_are_read_and_written_as_before() {
|
||||
design["features"][flange]["edges"][3] = json!({"point": [0.0, 45.0, 2.0], "twist": "3 deg"});
|
||||
assert!(solidcraft_doc::Document::from_json(&design.to_string()).is_err());
|
||||
}
|
||||
|
||||
/// Names of the hole's side faces (curved, smaller than the bend faces) in `Body1`.
|
||||
fn hole_face_names(s: &mut Session) -> Vec<String> {
|
||||
let faces = run(s, "model.faces", json!({"body": "Body1"}));
|
||||
let faces = faces["faces"].as_array().cloned().unwrap_or_default();
|
||||
faces
|
||||
.iter()
|
||||
.filter(|f| f["plane_normal"].is_null() && f["area"].as_f64().is_some_and(|a| a < 100.0))
|
||||
.map(|f| f["name"].as_str().unwrap_or_default().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_on_a_folded_panel_keeps_the_name_of_its_sketch_circle() {
|
||||
// A Ø10 hole at x = 20 on a 100 x 60 plate; the fold at x = 40 stands that (smaller) side up.
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
|
||||
let circle = run(&mut s, "sketch.circle.center", json!({"center": [20, 30], "radius": 5}));
|
||||
let curve = circle["curves"][0].as_str().unwrap_or("c1").to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
let side = format!(":side:{curve}#");
|
||||
let flat = hole_face_names(&mut s);
|
||||
assert_eq!(flat.len(), 2, "{flat:?}");
|
||||
assert!(flat.iter().all(|n| n.contains(&side)), "before the fold: {flat:?}");
|
||||
fold_line(&mut s);
|
||||
run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"}));
|
||||
assert!(bbox(&mut s)["max"][2].as_f64().unwrap_or(0.0) > 20.0, "the hole's panel stands up");
|
||||
let folded = hole_face_names(&mut s);
|
||||
assert_eq!(folded.len(), 2, "{folded:?}");
|
||||
assert!(folded.iter().all(|n| n.contains(&side)), "after the fold the hole is still circle {curve}: {folded:?} (before: {flat:?})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_cut_straight_through_a_flat_sheet_is_a_cutout_like_the_extrude_cut() {
|
||||
// A 10 x 10 box perpendicular to the sheet and through its whole thickness removes exactly
|
||||
// what a sketch rectangle extruded through all removes, which is accepted today.
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "solid.box", json!({"corner": [20, 20, -1], "length": 10, "width": 10, "height": 5, "operation": "cut"}));
|
||||
let mut extruded = Session::default();
|
||||
base(&mut extruded, 100.0, 60.0);
|
||||
rectangle_tool(&mut extruded, "Hole", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut extruded, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
for session in [&mut s, &mut extruded] {
|
||||
assert!((volume(session) - 14750.0).abs() < 1e-6);
|
||||
assert_eq!(flat(session)["cutouts"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - 14750.0).abs() < 1e-6);
|
||||
// A cylinder along the sheet normal is a round hole, exact in the flat pattern and DXF.
|
||||
run(&mut s, "solid.cylinder", json!({"base": [70, 30, -1], "radius": 5, "height": 5, "operation": "cut"}));
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(2));
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
|
||||
assert!(dxf.contains("CIRCLE"), "the hole stays a circle");
|
||||
assert!((volume(&mut s) - (14750.0 - PI * 25.0 * 2.5)).abs() < 0.5);
|
||||
// A box that only dents the top face is a pocket of constant depth.
|
||||
run(&mut s, "solid.box", json!({"corner": [50, 10, 1.5], "length": 10, "width": 10, "height": 5, "operation": "cut"}));
|
||||
let f = flat(&mut s);
|
||||
assert_eq!(f["pockets"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(f["pockets"][0]["depth_mm"], 1.0);
|
||||
// A box across a folded bend, or a cylinder lying in the sheet, is still 3D machining
|
||||
// and still refused atomically.
|
||||
let mut tilted = Session::default();
|
||||
base(&mut tilted, 100.0, 60.0);
|
||||
run(&mut tilted, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
|
||||
let before = tilted.doc.to_json();
|
||||
let error = tilted
|
||||
.execute("solid.box", &json!({"corner": [40, -10, -5], "length": 20, "width": 20, "height": 40, "operation": "cut"}))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("bend") || error.contains("preserv"), "{error}");
|
||||
assert_eq!(tilted.doc.to_json(), before);
|
||||
let error = s
|
||||
.execute("solid.cylinder", &json!({"base": [10, 50, 1.25], "axis": "X", "radius": 1, "height": 20, "operation": "cut"}))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("preserv"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_pattern_reports_a_round_hole_as_a_circle() {
|
||||
// The DXF export already writes this hole as a CIRCLE; the JSON flat pattern should say
|
||||
// "circle, centre (50, 30), radius 1.6" too, not only a sampled polyline: any object in the
|
||||
// cut-out's shape that has a `center` and a `radius` counts.
|
||||
fn circles(v: &Value, out: &mut Vec<([f64; 2], f64)>) {
|
||||
match v {
|
||||
Value::Object(m) => {
|
||||
let center = m.get("center").and_then(Value::as_array).and_then(|c| Some([c.first()?.as_f64()?, c.get(1)?.as_f64()?]));
|
||||
if let (Some(center), Some(radius)) = (center, m.get("radius").and_then(Value::as_f64)) {
|
||||
out.push((center, radius));
|
||||
}
|
||||
m.values().for_each(|v| circles(v, out));
|
||||
}
|
||||
Value::Array(a) => a.iter().for_each(|v| circles(v, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50, 30], "radius": 1.6}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
|
||||
assert!(dxf.contains("CIRCLE"), "the DXF keeps the circle");
|
||||
let f = flat(&mut s);
|
||||
let cutouts = f["cutouts"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(cutouts.len(), 1);
|
||||
// The point list stays for existing readers; `cutout_shapes` carries the exact shape.
|
||||
assert!(cutouts[0].as_array().is_some_and(|p| p.len() > 8));
|
||||
let shapes = f["cutout_shapes"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(shapes.len(), 1);
|
||||
let mut found = Vec::new();
|
||||
circles(&shapes[0], &mut found);
|
||||
let shown: String = shapes[0].to_string().chars().take(120).collect();
|
||||
assert!(
|
||||
found.iter().any(|(c, r)| (c[0] - 50.0).abs() < 1e-9 && (c[1] - 30.0).abs() < 1e-9 && (r - 1.6).abs() < 1e-9),
|
||||
"the round hole is not reported as a circle: {shown} ..."
|
||||
);
|
||||
// A slot is its two lines and two half-circle arcs, exactly.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"}));
|
||||
run(&mut s, "sketch.slot.overall", json!({"p0": [10, 10], "p1": [30, 10], "width": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
let f = flat(&mut s);
|
||||
let shapes = f["cutout_shapes"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(shapes.len(), f["cutouts"].as_array().map_or(0, Vec::len));
|
||||
let slot = shapes.iter().find(|s| s.get("segments").is_some()).cloned().unwrap_or_default();
|
||||
let segments = slot["segments"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(segments.iter().filter(|s| s.get("line").is_some()).count(), 2, "{slot}");
|
||||
let arcs: Vec<&Value> = segments.iter().filter_map(|s| s.get("arc")).collect();
|
||||
assert_eq!(arcs.len(), 2, "{slot}");
|
||||
assert!(
|
||||
arcs.iter()
|
||||
.all(|a| (a["radius"].as_f64().unwrap_or(0.0) - 2.0).abs() < 1e-9 && (a["sweep_deg"].as_f64().unwrap_or(0.0).abs() - 180.0).abs() < 1e-6)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flange_asked_for_0_8_mm_is_0_8_mm_or_refused_never_silently_2_5_mm() {
|
||||
// `thickness` on sheet.flange may become an inline override of the rule, or be refused as
|
||||
// an unknown parameter; either is fine. Building the default 2.5 mm sheet silently is not.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
match s.execute("sheet.flange", &json!({"sketch": "Base", "thickness": 0.8})) {
|
||||
Ok(_) => {
|
||||
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(f64::NAN) - 0.8).abs() < 1e-9);
|
||||
assert!((volume(&mut s) - 100.0 * 60.0 * 0.8).abs() < 1e-6, "built at {} mm3", volume(&mut s));
|
||||
}
|
||||
Err(e) => assert!(e.to_string().contains("thickness"), "{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A new fold still refuses a `fixed` point in a cut-out or past the outline, leaving the design
|
||||
/// untouched (#263); a stored one that drifts off the sheet keeps its side with a warning (#370).
|
||||
#[test]
|
||||
fn fold_refuses_a_new_fixed_point_off_the_sheet_but_warns_on_recompute() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "w", "expression": "100 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Blank"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "w"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Blank"}));
|
||||
rectangle_tool(&mut s, "Window", [10.0, 15.0], [30.0, 45.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Window", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
let before = s.doc.to_json();
|
||||
let undo = s.undo.len();
|
||||
for fixed in [json!([20, 30, 2.5]), json!([150, 30, 2.5])] {
|
||||
let e = s.execute("sheet.fold", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": fixed})).unwrap_err().to_string();
|
||||
assert!(e.contains("fixed") && e.contains("not on the sheet"), "{fixed}: {e}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
run(&mut s, "sheet.fold", json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": [90, 30, 2.5], "name": "Fold1"}));
|
||||
assert!(run(&mut s, "timeline.inspect", json!({"feature": "Fold1"}))["warning"].is_null());
|
||||
run(&mut s, "parameters.change", json!({"name": "w", "expression": "80 mm"}));
|
||||
let fold = run(&mut s, "timeline.inspect", json!({"feature": "Fold1"}));
|
||||
assert!(fold["error"].is_null(), "{fold}");
|
||||
assert!(fold["warning"].as_str().is_some_and(|w| w.contains("fixed point is not on the sheet") && w.contains("stays")), "{fold}");
|
||||
}
|
||||
|
||||
/// Points away from the sheet are refused atomically; points on any face of it, on a flange's
|
||||
/// leg, or a hair off a face still pick the side that stays (#371).
|
||||
#[test]
|
||||
fn fold_fixed_point_must_touch_the_sheet_but_any_face_will_do() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
let before = s.doc.to_json();
|
||||
let undo = s.undo.len();
|
||||
for fixed in [json!([90, 30, 20]), json!([90, 30, -10])] {
|
||||
let e = s.execute("sheet.fold", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": fixed})).unwrap_err().to_string();
|
||||
assert!(e.contains("fixed") && e.contains("not on the sheet"), "{fixed}: {e}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
// The left (smaller) side stays: the right part stands up.
|
||||
for fixed in [json!([10, 30, 2.5]), json!([10, 30, 0]), json!([0, 30, 1.25]), json!([10, 30, 2.500001])] {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sheet.fold", json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": fixed, "name": "Fold1"}));
|
||||
let bb = bbox(&mut s);
|
||||
assert!(bb["min"][0].as_f64().unwrap_or(f64::NAN).abs() < 1e-6 && bb["max"][0].as_f64().unwrap_or(f64::NAN) < 50.0, "{fixed}: {bb}");
|
||||
assert!(run(&mut s, "timeline.inspect", json!({"feature": "Fold1"}))["warning"].is_null(), "{fixed}");
|
||||
}
|
||||
// A point on an edge flange's leg (far above the base panel's slab) is on the sheet.
|
||||
let mut f = Session::default();
|
||||
run(&mut f, "parameters.change", json!({"name": "h", "expression": "20 mm"}));
|
||||
base(&mut f, 100.0, 60.0);
|
||||
run(&mut f, "sheet.flange", json!({"edges": [[100, 30, 2.5]], "height": "h"}));
|
||||
let bb = bbox(&mut f);
|
||||
let x = bb["max"][0].as_f64().unwrap_or(f64::NAN);
|
||||
run(&mut f, "sheet.fold", json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": [x, 30, 15], "name": "Fold1"}));
|
||||
assert!(run(&mut f, "timeline.inspect", json!({"feature": "Fold1"}))["warning"].is_null());
|
||||
// A shorter leg leaves the stored point in the air: the fold recomputes, keeps its side and warns.
|
||||
run(&mut f, "parameters.change", json!({"name": "h", "expression": "10 mm"}));
|
||||
let fold = run(&mut f, "timeline.inspect", json!({"feature": "Fold1"}));
|
||||
assert!(fold["error"].is_null(), "{fold}");
|
||||
assert!(fold["warning"].as_str().is_some_and(|w| w.contains("fixed point is not on the sheet")), "{fold}");
|
||||
assert!((bbox(&mut f)["max"][0].as_f64().unwrap_or(f64::NAN) - x).abs() < 1e-6, "the right side stays");
|
||||
}
|
||||
@@ -294,7 +294,7 @@ pub(super) fn edit_solved<T>(
|
||||
let unsolved = sk.clone();
|
||||
#[cfg(test)]
|
||||
solve_counts::record(Analysis::Full);
|
||||
let rep = doc.solve_sketch(&vals, &mut sk, &stored)?;
|
||||
let rep = doc.solve_sketch(&vals, &mut sk, &stored, None)?;
|
||||
Some(settle(strict, rep, &mut sk, unsolved)?)
|
||||
}
|
||||
};
|
||||
@@ -336,7 +336,7 @@ pub(super) fn counted_solve(
|
||||
pub(super) fn solve_current(sk: &mut Sketch, doc: &solidcraft_doc::Document) -> Result<SolveReport> {
|
||||
let stored = sk.driving_values();
|
||||
let (values, _) = doc.param_values();
|
||||
Ok(doc.solve_sketch(&values, sk, &stored)?)
|
||||
Ok(doc.solve_sketch(&values, sk, &stored, None)?)
|
||||
}
|
||||
|
||||
/// Solves run on this thread by [`counted_solve`] and the edit's own solve: (all of them, those
|
||||
|
||||
@@ -75,19 +75,24 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them", created_doc!())),
|
||||
CommandSpec::new("sketch.centerline", "Centerline", centerline)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("centerline")
|
||||
.enabled(in_sketch)
|
||||
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
|
||||
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!(
|
||||
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg",
|
||||
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg, font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text)
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg", created_doc!())),
|
||||
.params(concat!(
|
||||
"link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg, font?: family or file path (empty: the built-in font)",
|
||||
created_doc!()
|
||||
)),
|
||||
];
|
||||
|
||||
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> Result<LinkSource> {
|
||||
let (t0, at0, h0, a0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0));
|
||||
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option<String>)>) -> Result<LinkSource> {
|
||||
let (t0, at0, h0, a0, f0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0, None));
|
||||
let text = str_(p, "text").map(str::to_string).unwrap_or(t0);
|
||||
if text.trim().is_empty() {
|
||||
return Err(bad(cmd, "`text` must not be empty"));
|
||||
@@ -101,7 +106,15 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> R
|
||||
return Err(bad(cmd, "`height` must be positive"));
|
||||
}
|
||||
let angle = num(p, "angle").map(f64::to_radians).unwrap_or(a0);
|
||||
Ok(LinkSource::Text { text, at, height, angle })
|
||||
let font = match p.get("font") {
|
||||
None | Some(Value::Null) => f0,
|
||||
Some(Value::String(f)) if f.trim().is_empty() => None,
|
||||
Some(Value::String(f)) => Some(f.trim().to_string()),
|
||||
Some(_) => return Err(bad(cmd, "`font` must be a font family name or a font file path")),
|
||||
};
|
||||
// An unknown font or an unreadable file is an error now, not a lost link later.
|
||||
solidcraft_sketch::font_data(font.as_deref()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(LinkSource::Text { text, at, height, angle, font })
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -131,12 +144,12 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(cmd, "give `link` or `entity`")),
|
||||
};
|
||||
let old = match sk.link(&l).map(|x| (x.kind, x.source.clone())) {
|
||||
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle })) => (text, at, height, angle),
|
||||
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle, font })) => (text, at, height, angle, font),
|
||||
_ => return Err(bad(cmd, format!("`{l}` is not text"))),
|
||||
};
|
||||
let src = text_source(p, cmd, Some(old))?;
|
||||
let LinkSource::Text { text, at, height, angle } = &src else { return Err(bad(cmd, "text")) };
|
||||
let geom = solidcraft_sketch::text_geometry(text, *at, *height, *angle)?;
|
||||
let LinkSource::Text { text, at, height, angle, font } = &src else { return Err(bad(cmd, "text")) };
|
||||
let geom = solidcraft_sketch::text_geometry_in(font.as_deref(), text, *at, *height, *angle)?;
|
||||
sk.set_link_source(&l, src.clone());
|
||||
sk.update_link(&l, &geom)?;
|
||||
let curves = ids_of(&sk, &sk.link_curves(&l));
|
||||
|
||||
@@ -75,9 +75,10 @@ fn explicit_rebind_preserves_geometry_dimensions_context_and_one_undo() {
|
||||
#[test]
|
||||
fn unsupported_face_has_explicit_fixed_alternative_and_no_live_projection() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
|
||||
// The tops of two joined boxes merge into one face that no single construction owns.
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[10,0,0],"operation":"join"}));
|
||||
// A top half Offset Face raised: no construction certificate and no exact history name yet.
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Base"}));
|
||||
run(&mut s, "solid.split_face", json!({"faces":[[10,15,20]],"plane":{"origin":[20,0,0],"normal":[1,0,0]}}));
|
||||
run(&mut s, "solid.offset_face", json!({"faces":[[10,15,20]],"distance":5}));
|
||||
let mut pick = top_pick(&s, "Base");
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("sketch.create", &json!({"plane":pick})).unwrap_err();
|
||||
|
||||
@@ -135,7 +135,8 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
if let Geom2::Text { text, at, height, angle } = t {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
if super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, LinkSource::Text { text, at, height, angle }).is_ok() {
|
||||
if super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, LinkSource::Text { text, at, height, angle, font: None }).is_ok()
|
||||
{
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
n_text += 1;
|
||||
|
||||
@@ -429,6 +429,113 @@ fn text_makes_profiles_that_extrude_and_edit() {
|
||||
assert!(s.execute("sketch.text", &json!({"text": "", "at": [0, 0]})).is_err());
|
||||
}
|
||||
|
||||
/// Volumes (mm³) of the bodies in the design, smallest first.
|
||||
fn body_volumes(s: &mut Session) -> Vec<f64> {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
let mut v: Vec<f64> = m["bodies"].as_array().unwrap().iter().map(|b| b["volume_mm3"].as_f64().unwrap()).collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_extrudes_with_its_counters_and_beside_other_curves() {
|
||||
// Letter areas in mm² at a capital height of 10 mm, summed from the Liberation Sans contours (shoelace
|
||||
// formula, quadratic pieces flattened finely), each outer contour minus its counters:
|
||||
// A 39.2159 - 9.0647 B 68.9283 - 12.9336 - 16.6277 8 56.8394 - 10.6451 - 13.7347 I 13.5557
|
||||
// Extruded 2 mm, so the volumes are twice that. Faceted walls: within 0.02 mm³.
|
||||
let near = |got: &[f64], want: &[f64]| {
|
||||
assert_eq!(got.len(), want.len(), "{got:?} against {want:?}");
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.02), "{got:?} against {want:?}");
|
||||
};
|
||||
// Letters with counters, alone: a letter is one body with its counters as holes. (A counter used to be
|
||||
// extruded as a body of its own, or the extrude failed with "profile has no area".)
|
||||
for (text, want) in
|
||||
[("AB", vec![2.0 * (39.2159 - 9.0647), 2.0 * (68.9283 - 12.9336 - 16.6277)]), ("8", vec![2.0 * (56.8394 - 10.6451 - 13.7347)])]
|
||||
{
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.text", json!({"text": text, "at": [0, 0], "height": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 2}));
|
||||
near(&body_volumes(&mut s), &want);
|
||||
}
|
||||
// Text beside a drawn rectangle is extruded with it (it used to be left out): the 30 x 20 x 2 plate and an "I".
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}));
|
||||
run(&mut s, "sketch.text", json!({"text": "I", "at": [40, 0], "height": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 2}));
|
||||
near(&body_volumes(&mut s), &[2.0 * 13.5557, 1200.0]);
|
||||
// A counter can still be picked on its own, by a point inside it: the inner contour of an O, 47.4584 mm².
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.text", json!({"text": "O", "at": [0, 0], "height": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 2, "profiles": [{"point": [5.65, 5.0]}]}));
|
||||
near(&body_volumes(&mut s), &[2.0 * 47.4584]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_embossed_or_debossed_leaves_its_counters_alone() {
|
||||
// A 40 x 30 x 5 plate (6000 mm³) with 10 mm tall text raised from, or cut 1 mm into, its top. Only the letters
|
||||
// change the volume, by their area in mm² (see above) x 1 mm; the counters of the O and of A and B stay as
|
||||
// they are. (Emboss and deboss used to fail with "boolean Cut: no result" or "boolean Union: no result",
|
||||
// because each counter was a tool body of its own, filling the hole of its letter.)
|
||||
let (o, ab) = (83.3133 - 47.4584, (39.2159 - 9.0647) + (68.9283 - 12.9336 - 16.6277));
|
||||
for (text, mode, change) in [("O", "deboss", -o), ("O", "emboss", o), ("AB", "deboss", -ab)] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "body_name": "Plate"}));
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Lid"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "Lid", "name": "Text"}));
|
||||
run(&mut s, "sketch.text", json!({"text": text, "at": [10, 10], "height": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch": "Text", "depth": 1, "mode": mode}));
|
||||
let v = body_volumes(&mut s);
|
||||
assert!(v.len() == 1 && (v[0] - (6000.0 + change)).abs() < 0.02, "{text} {mode}: {v:?} against {}", 6000.0 + change);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_takes_a_font_and_edit_text_keeps_or_changes_it() {
|
||||
// The repository's second font is BIZ UDPGothic. Its "I" is a polygon of two 606 x 145 bars and a 188 x 1369
|
||||
// stem in font units, with a capital height of 1567 units, so at 10 mm its area is
|
||||
// (2 x 606 x 145 + 188 x 1369) x (10 / 1567)^2 = 17.6385 mm². The built-in Liberation Sans "I" is 13.5557 mm².
|
||||
let font = concat!(env!("CARGO_MANIFEST_DIR"), "/../../apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf");
|
||||
let near = |s: &Session, want: f64| {
|
||||
let a = profiles(s);
|
||||
assert!(a.len() == 1 && (a[0] - want).abs() < 1e-3, "{a:?} against {want}");
|
||||
};
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10}));
|
||||
near(&s, 13.5557);
|
||||
let link = r["link"].as_str().unwrap().to_string();
|
||||
// A font by file path changes the outlines; the same edit with the built-in family's name changes them back.
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
|
||||
near(&s, 17.6385);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": "Liberation Sans"}));
|
||||
near(&s, 13.5557);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
|
||||
// An edit that does not name a font keeps it (the height scales the area by 4), and an empty name is the built-in font.
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "height": 20}));
|
||||
near(&s, 4.0 * 17.6385);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": ""}));
|
||||
near(&s, 4.0 * 13.5557);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
|
||||
// The font is kept in the design: after saving and reopening, the text is still in it.
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let source = reopened.doc.sketch(s.active_sketch.unwrap()).unwrap().link(&link).unwrap().source.clone();
|
||||
assert!(matches!(&source, solidcraft_sketch::LinkSource::Text { font: Some(f), .. } if f == font), "{source:?}");
|
||||
// A new text takes the font too. Unknown fonts and values that are not names are errors.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10, "font": font}));
|
||||
near(&s, 17.6385);
|
||||
let e = s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": "No Such Family 12345"})).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown font `No Such Family 12345`"), "{e}");
|
||||
assert!(s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": 12})).is_err());
|
||||
assert!(s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": "C:/no/such/dir/font.ttf"})).is_err());
|
||||
near(&s, 17.6385);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_curve_joins_tangentially() {
|
||||
let mut s = new_sketch();
|
||||
|
||||
@@ -166,8 +166,14 @@ fn owner_frame(s: &Session, doc: &Document, comp: u64) -> solidcraft_doc::Mat {
|
||||
if comp == s.active_component { s.active_component_frame() } else { doc.component_transform(comp) }
|
||||
}
|
||||
|
||||
/// Re-resolve the links of a sketch being edited against the model before it, so edits see
|
||||
/// current reference geometry. Returns warnings.
|
||||
/// Re-resolve the links of a sketch being edited (`sk`, a copy of the stored sketch `id`)
|
||||
/// against the model before it, so edits see current reference geometry. Returns warnings.
|
||||
///
|
||||
/// When a link moved, the rest of the sketch follows it as the evaluation solves it, keeping
|
||||
/// the stored sides and turns, so the edit goes on from the sketch the model shows. Left where
|
||||
/// it was stored beside a line that has moved past it, a dimensioned point would give the
|
||||
/// edit's solve the other side to keep. A sketch that does not solve that way keeps its stored
|
||||
/// geometry, with the links moved.
|
||||
pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> Vec<String> {
|
||||
if sk.links.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -183,10 +189,21 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) ->
|
||||
return vec!["lost face attachment; reattach explicitly".into()];
|
||||
};
|
||||
let Ok(plane) = sketch_plane(&doc, id, &st) else { return Vec::new() };
|
||||
match attachment {
|
||||
let warnings = match attachment {
|
||||
Some(anchor) => solidcraft_doc::project::refresh_face_anchor_links(&doc, &vals, &st, &plane, sk, anchor),
|
||||
None => solidcraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk),
|
||||
};
|
||||
if let Ok(stored) = doc.sketch(id)
|
||||
&& sk.positions_in(stored).is_some()
|
||||
{
|
||||
let mut solved = sk.clone();
|
||||
let follows = solved.refresh_offsets().is_ok()
|
||||
&& doc.solve_sketch(&vals, &mut solved, &stored.driving_values(), Some(stored)).is_ok_and(|report| report.keeps_orientation());
|
||||
if follows {
|
||||
*sk = solved;
|
||||
}
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
/// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id.
|
||||
@@ -547,6 +564,10 @@ pub(super) fn auto_project_face(s: &mut Session, id: u64, _at: Vec3) -> Result<O
|
||||
#[path = "sketch_project_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_project_side_tests.rs"]
|
||||
mod side_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_model_plane_tests.rs"]
|
||||
mod model_plane_tests;
|
||||
@@ -0,0 +1,180 @@
|
||||
//! A dimension from linked geometry keeps its side when the link's source moves: a point held
|
||||
//! 5 from a projected line stays on the side it was drawn on wherever a parameter takes the
|
||||
//! line, also past the place the point is stored at.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sketch `A`: the horizontal line `rail`, 100 long, at the height the parameter `height`
|
||||
/// (50) gives it.
|
||||
fn rail_sketch(s: &mut Session) {
|
||||
run(s, "parameters.add", json!({"name": "height", "expression": "50 mm"}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "A"}));
|
||||
run(s, "sketch.line", json!({"points": [[0, 50], [100, 50]], "ids": ["rail"]}));
|
||||
run(s, "sketch.constraint.horizontal_vertical", json!({"line": "rail", "mode": "horizontal"}));
|
||||
run(s, "sketch.constraint.horizontal_vertical", json!({"points": ["origin", "rail.start"], "mode": "vertical"}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["origin", "rail.start"], "value": "height"}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["rail"], "value": "100 mm"}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
/// Sketch `S` (left open): `rail` brought in by `command`, and the point `p` at (50, `y`),
|
||||
/// 50 from the origin horizontally. Returns the linked line.
|
||||
fn point_by_the_rail(s: &mut Session, command: &str, y: f64) -> String {
|
||||
rail_sketch(s);
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
let r = run(s, command, json!({"refs": [{"sketch": "A", "curve": "rail"}]}));
|
||||
let rail = r["links"][0]["curves"][0].as_str().unwrap().to_string();
|
||||
run(s, "sketch.point", json!({"point": [50, y], "id": "p"}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["origin", "p"], "type": "horizontal", "value": "50 mm"}));
|
||||
rail
|
||||
}
|
||||
|
||||
/// `p` held 5 from `line`: the dimension's parameter.
|
||||
fn five_from(s: &mut Session, line: &str) -> String {
|
||||
let r = run(s, "sketch.dimension", json!({"entities": ["p", line], "type": "distance", "value": "5 mm"}));
|
||||
r["param"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn set_height(s: &mut Session, height: f64) {
|
||||
run(s, "parameters.change", json!({"name": "height", "expression": format!("{height} mm")}));
|
||||
}
|
||||
|
||||
/// Where the model has `p` of sketch `S`.
|
||||
fn p(s: &Session) -> Vec2 {
|
||||
let st = s.model.state();
|
||||
let sk = &st.sketch(s.doc.find_feature("S").unwrap().id).unwrap().sketch;
|
||||
sk.point(sk.resolve_point("p").unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// `p` is at (50, `y`), and the sketch has nothing to report.
|
||||
#[track_caller]
|
||||
fn assert_p(s: &Session, y: f64, when: &str) {
|
||||
assert!(p(s).dist(Vec2::new(50.0, y)) < 1e-9, "{when}: {:?}", p(s));
|
||||
let feature = s.model.result(s.doc.find_feature("S").unwrap().id).unwrap();
|
||||
assert_eq!((&feature.error, &feature.warning), (&None, &None), "{when}");
|
||||
}
|
||||
|
||||
/// A line of sketch `A` whose height a parameter drives, projected into sketch `S`, whose point
|
||||
/// is dimensioned 5 from the projection.
|
||||
#[test]
|
||||
fn a_distance_from_a_projected_line_keeps_its_side_when_the_source_moves_past() {
|
||||
let mut s = Session::default();
|
||||
let rail = point_by_the_rail(&mut s, "sketch.project", 45.0);
|
||||
five_from(&mut s, &rail);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_p(&s, 45.0, "drawn");
|
||||
// The source drops below the point's stored place: the point stays below the projection.
|
||||
for height in [20.0, 50.0, 6.0, 400.0] {
|
||||
set_height(&mut s, height);
|
||||
assert_p(&s, height - 5.0, &format!("height {height}"));
|
||||
}
|
||||
// Editing the sketch afterwards refreshes the projection first; the side is the one from before.
|
||||
set_height(&mut s, 20.0);
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
|
||||
run(&mut s, "sketch.point", json!({"point": [80, 80]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_p(&s, 15.0, "after an edit");
|
||||
}
|
||||
|
||||
/// The same from the other side: the point above the line, the source rising past it and
|
||||
/// coming back; and with the line brought in by Include 3D Geometry.
|
||||
#[test]
|
||||
fn a_point_above_the_line_stays_above_when_the_source_rises_past_and_returns() {
|
||||
for command in ["sketch.project", "sketch.include_3d"] {
|
||||
let mut s = Session::default();
|
||||
let rail = point_by_the_rail(&mut s, command, 55.0);
|
||||
five_from(&mut s, &rail);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
for height in [80.0, 400.0, 50.0, 6.0, 50.0] {
|
||||
set_height(&mut s, height);
|
||||
assert_p(&s, height + 5.0, &format!("{command}, height {height}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Model geometry in a sketch on XZ (whose y runs down the model's Z): the top of a box whose
|
||||
/// height a parameter drives, as a projected edge, as an edge of a projected face and as a
|
||||
/// section of the body. The point is 5 below the top, inside the box.
|
||||
#[test]
|
||||
fn a_distance_from_model_geometry_keeps_its_side_when_the_model_moves_past() {
|
||||
// A projected edge is found again by the point picked on it, so it follows small steps
|
||||
// only; a face (by its name) and a body follow any.
|
||||
let small: &[f64] = &[42.0, 50.0, 58.0, 50.0, 44.0];
|
||||
let large: &[f64] = &[20.0, 50.0, 6.0, 400.0];
|
||||
let cases = [
|
||||
("sketch.project", json!({"edge": [50, 0, 50]}), 0.0, small),
|
||||
("sketch.project", json!({"face": [50, 0, 25]}), 0.0, large),
|
||||
("sketch.intersect", json!({"body": "Body1"}), -20.0, large),
|
||||
];
|
||||
for (command, link, front, heights) in cases {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "height", "expression": "50 mm"}));
|
||||
run(&mut s, "solid.box", json!({"corner": [0, front, 0], "length": 100, "width": 40, "height": "height"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "S"}));
|
||||
run(&mut s, command, json!({"refs": [link]}));
|
||||
let curves = run(&mut s, "sketch.inspect", json!({}))["curves"].clone();
|
||||
let high = |c: &Value, end: &str| (c[end][1].as_f64().unwrap() + 50.0).abs() < 1e-6;
|
||||
let top = curves.as_array().unwrap().iter().find(|c| high(c, "start_at") && high(c, "end_at")).unwrap()["id"].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.point", json!({"point": [50, -45], "id": "p"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p"], "type": "horizontal", "value": "50 mm"}));
|
||||
five_from(&mut s, &top);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// (An edge found again by its picked point says so in a warning.)
|
||||
for height in heights {
|
||||
set_height(&mut s, *height);
|
||||
assert!(p(&s).dist(Vec2::new(50.0, 5.0 - height)) < 1e-9, "{command} {link}, height {height}: {:?}", p(&s));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Another projection added after the source moved stores the sketch without solving it: the
|
||||
/// sketch it stores is the one the model shows.
|
||||
#[test]
|
||||
fn a_projection_added_after_the_source_moved_leaves_the_side() {
|
||||
let mut s = Session::default();
|
||||
let rail = point_by_the_rail(&mut s, "sketch.project", 45.0);
|
||||
five_from(&mut s, &rail);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
set_height(&mut s, 20.0);
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
|
||||
run(&mut s, "sketch.project", json!({"refs": ["origin"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_p(&s, 15.0, "after the projection");
|
||||
set_height(&mut s, 50.0);
|
||||
assert_p(&s, 45.0, "back at 50");
|
||||
}
|
||||
|
||||
/// The side is still the user's to choose, by where the point is when the dimension is added:
|
||||
/// also when the source has moved past the point since it was drawn. That side is kept from
|
||||
/// then on, and through an edit of the dimension's value.
|
||||
#[test]
|
||||
fn a_dimension_added_after_the_source_moved_takes_the_side_its_point_is_on() {
|
||||
let mut s = Session::default();
|
||||
let rail = point_by_the_rail(&mut s, "sketch.project", 45.0);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Drawn below the line; the line drops below it.
|
||||
set_height(&mut s, 20.0);
|
||||
assert_p(&s, 45.0, "no distance yet");
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
|
||||
let distance = five_from(&mut s, &rail);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_p(&s, 25.0, "dimensioned above the line");
|
||||
for height in [50.0, 6.0, 400.0, 20.0] {
|
||||
set_height(&mut s, height);
|
||||
assert_p(&s, height + 5.0, &format!("height {height}"));
|
||||
}
|
||||
run(&mut s, "parameters.change", json!({"name": distance, "expression": "8 mm"}));
|
||||
assert_p(&s, 28.0, "8 from the line");
|
||||
set_height(&mut s, 6.0);
|
||||
assert_p(&s, 14.0, "8 from the line, height 6");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! `solid.move` with `copy: true`: the original body stays where it was, a new body is
|
||||
//! pushed at the transform, and an older design file (no `copy` key at all) still loads.
|
||||
use super::*;
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap()
|
||||
}
|
||||
|
||||
fn cube20(s: &mut Session, name: &str) {
|
||||
run(s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "body_name": name}));
|
||||
}
|
||||
|
||||
fn volume(body: &solidcraft_doc::ModelBody) -> f64 {
|
||||
solidcraft_kernel::measure(&body.body).unwrap().volume
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_keeps_the_original_and_adds_a_transformed_body() {
|
||||
let mut s = Session::default();
|
||||
cube20(&mut s, "Cube");
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Cube"], "translate": [30, 0, 0], "copy": true}));
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.bodies.len(), 2, "a copy move must add one body, not replace the original");
|
||||
|
||||
let original = state.body("Cube").unwrap();
|
||||
let original_bounds = original.mesh().bounds();
|
||||
assert!(original_bounds.min.dist(Vec3::ZERO) < 1e-6, "the original body must stay at its own position: {original_bounds:?}");
|
||||
assert!(original_bounds.max.dist(Vec3::new(20.0, 20.0, 20.0)) < 1e-6, "{original_bounds:?}");
|
||||
|
||||
let copy = state.bodies.iter().find(|b| b.name != "Cube").unwrap();
|
||||
let copy_bounds = copy.mesh().bounds();
|
||||
assert!(copy_bounds.min.dist(Vec3::new(30.0, 0.0, 0.0)) < 1e-6, "the copy must land at the translated position: {copy_bounds:?}");
|
||||
assert!(copy_bounds.max.dist(Vec3::new(50.0, 20.0, 20.0)) < 1e-6, "{copy_bounds:?}");
|
||||
|
||||
let original_volume = volume(original);
|
||||
let copy_volume = volume(copy);
|
||||
assert!((original_volume - 8000.0).abs() < 1e-3, "original volume: {original_volume}");
|
||||
assert!((copy_volume - 8000.0).abs() < 1e-3, "copy volume: {copy_volume}");
|
||||
assert!((original_volume - copy_volume).abs() < 1e-6, "the copy must have the same mass properties as the source");
|
||||
|
||||
let total: f64 = state.bodies.iter().map(volume).sum();
|
||||
assert!((total - 16000.0).abs() < 1e-3, "total volume across both bodies: {total}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_copy_the_move_still_replaces_the_body_in_place() {
|
||||
let mut s = Session::default();
|
||||
cube20(&mut s, "Cube");
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Cube"], "translate": [30, 0, 0]}));
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.bodies.len(), 1, "a plain move must not create a second body");
|
||||
let moved = state.body("Cube").unwrap();
|
||||
let bounds = moved.mesh().bounds();
|
||||
assert!(bounds.min.dist(Vec3::new(30.0, 0.0, 0.0)) < 1e-6, "{bounds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_older_design_with_no_copy_key_at_all_still_loads_and_moves_in_place() {
|
||||
let mut s = Session::default();
|
||||
cube20(&mut s, "Cube");
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Cube"], "translate": [5, 0, 0]}));
|
||||
let saved = s.doc.to_json();
|
||||
assert!(!saved.contains("\"copy\""), "a move without the flag must serialize with no `copy` key, as an older file would read: {saved}");
|
||||
|
||||
// Simulate a design saved before this feature existed: strip the key even if present, then
|
||||
// reopen through the same loader a real design file goes through.
|
||||
let mut old: Value = serde_json::from_str(&saved).unwrap();
|
||||
let features = old.get_mut("features").and_then(Value::as_array_mut).expect("features array");
|
||||
let mut touched = false;
|
||||
for f in features.iter_mut() {
|
||||
if f.get("type").and_then(Value::as_str) == Some("move")
|
||||
&& let Some(obj) = f.as_object_mut()
|
||||
{
|
||||
obj.remove("copy");
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
assert!(touched, "the move feature must be present in the saved design");
|
||||
|
||||
let migrated = solidcraft_doc::Document::from_json(&old.to_string()).unwrap();
|
||||
let reopened = Session::new(migrated);
|
||||
let state = reopened.model.state();
|
||||
assert_eq!(state.bodies.len(), 1, "a pre-copy design must still move the one body in place, never duplicate it");
|
||||
let moved = state.body("Cube").unwrap();
|
||||
assert!(moved.mesh().bounds().min.dist(Vec3::new(5.0, 0.0, 0.0)) < 1e-6);
|
||||
}
|
||||
@@ -67,20 +67,13 @@ fn sketch_and_offset_plane_on_the_top_face_of_a_face_sketched_extrude() {
|
||||
assert!((plane.origin.z - 20.).abs() < 1e-9 && (plane.normal().z - 1.).abs() < 1e-9, "{case}: {plane:?}");
|
||||
s.execute("construct.plane.offset", &json!({"base": {"face": top}, "offset": 5, "name": "Above"}))
|
||||
.unwrap_or_else(|e| panic!("{case}: {e}"));
|
||||
// The second extrude's flat side is an exact source too, unless a full-face
|
||||
// Join merged it with the first extrude's side: that face belongs to neither,
|
||||
// so it offers the explicit fixed plane with a readable reason.
|
||||
// The second extrude's flat side attaches too, also where a full-face Join
|
||||
// merged it with the first extrude's side (#491): that face is known by its
|
||||
// exact history name.
|
||||
if !circle {
|
||||
let side = if full { [40., 20., 15.] } else { [30., 20., 15.] };
|
||||
let params = json!({"plane": {"face": side}, "name": "Side"});
|
||||
if full && operation == "join" {
|
||||
let error = s.execute("sketch.create", ¶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}");
|
||||
}
|
||||
s.execute("sketch.create", &json!({"plane": {"face": side}, "name": "Side"})).unwrap_or_else(|e| panic!("{case} side: {e}"));
|
||||
assert!((healthy(&s, "Side").normal().x - 1.).abs() < 1e-9, "{case}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
}
|
||||
s.refresh();
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! What the application draws of a large sheet with small bends and holes, and that what
|
||||
//! reads the same mesh (bounds, picks, inside tests) answers as before.
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, TAU};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::{Mesh, Vec3};
|
||||
use solidcraft_kernel::{DISPLAY_ANGLE, FaceSurface, face_surface};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
const HOLES: [[f64; 2]; 4] = [[30.0, 20.0], [1500.0, 20.0], [2970.0, 980.0], [30.0, 980.0]];
|
||||
|
||||
/// A 3000 x 1000 x 2 sheet with bend radius 1, four holes of diameter 4 and a 40 mm flange on
|
||||
/// each long side.
|
||||
fn large_sheet() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [3000, 1000]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Holes"}));
|
||||
for c in HOLES {
|
||||
run(&mut s, "sketch.circle.center", json!({"center": c, "diameter": 4}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Holes", "distance": 10, "through_all": true, "operation": "cut"}));
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[1500, 0, 2], [1500, 1000, 2]], "height": 40}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The widest angle between neighbouring lines of vertices around a cylinder face, and how
|
||||
/// many such steps the face has.
|
||||
fn steps_around(m: &Mesh, face: usize, origin: Vec3, axis: Vec3) -> (usize, f64) {
|
||||
let u = axis.any_perp();
|
||||
let v = axis.cross(u);
|
||||
let mut a: Vec<f64> = m
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, f)| **f as usize == face)
|
||||
.flat_map(|(t, _)| t.iter())
|
||||
.map(|k| m.positions[*k as usize] - origin)
|
||||
.map(|d| d.dot(v).atan2(d.dot(u)))
|
||||
.collect();
|
||||
a.sort_by(f64::total_cmp);
|
||||
a.dedup_by(|x, y| (*x - *y).abs() < 1e-7);
|
||||
// Leave out the widest gap: the part of the turn the face does not cover.
|
||||
let n = a.len();
|
||||
let mut gaps: Vec<f64> = (0..n).map(|i| (a[(i + 1) % n] - a[i]).rem_euclid(TAU)).collect();
|
||||
gaps.sort_by(f64::total_cmp);
|
||||
gaps.pop();
|
||||
(gaps.len(), gaps.last().copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_sheet_shows_its_small_bends_and_holes_round() {
|
||||
let s = large_sheet();
|
||||
let st = s.world_state();
|
||||
let b = &st.bodies[0];
|
||||
let m = b.mesh();
|
||||
assert!(b.body.size() * 1e-3 > 3.0, "the chord tolerance is more than any radius here");
|
||||
let (mut bends, mut walls) = (0, 0);
|
||||
for face in 0..b.body.face_count() {
|
||||
let Some(FaceSurface::Cylinder { origin, axis, radius }) = face_surface(&b.body, face) else { continue };
|
||||
let (steps, widest) = steps_around(&m, face, origin, axis);
|
||||
assert!(widest <= DISPLAY_ANGLE * 1.001, "radius {radius}: a facet spans {} degrees", widest.to_degrees());
|
||||
if (radius - 2.0).abs() < 1e-6 {
|
||||
// Half of a hole's wall.
|
||||
walls += 1;
|
||||
assert!(steps >= (TAU / 2.0 / DISPLAY_ANGLE).ceil() as usize, "{steps} facets around half a hole");
|
||||
} else {
|
||||
// A bend's inner (1 mm) or outer (3 mm) side: a quarter turn.
|
||||
bends += 1;
|
||||
assert!((radius - 1.0).abs() < 1e-6 || (radius - 3.0).abs() < 1e-6, "radius {radius}");
|
||||
assert!(steps >= (FRAC_PI_2 / DISPLAY_ANGLE).ceil() as usize, "radius {radius}: {steps} facets across the bend");
|
||||
}
|
||||
}
|
||||
assert_eq!((bends, walls), (4, 8));
|
||||
// The edges the viewport draws: every hole's rim is round, on both sides of the sheet.
|
||||
for [x, y] in HOLES {
|
||||
for z in [0.0, 2.0] {
|
||||
let on = |p: &Vec3| (p.z - z).abs() < 1e-6 && ((p.x - x).hypot(p.y - y) - 2.0).abs() < 1e-6;
|
||||
let rim: Vec<&Vec<Vec3>> = m.edges.iter().filter(|e| e.iter().all(on)).collect();
|
||||
let segments: usize = rim.iter().map(|e| e.len() - 1).sum();
|
||||
assert!(segments >= (TAU / DISPLAY_ANGLE).ceil() as usize, "{segments} segments around the rim at {x}, {y}, {z}");
|
||||
let widest = rim.iter().flat_map(|e| e.windows(2)).map(|w| 2.0 * (w[0].dist(w[1]) / 4.0).asin()).fold(0.0, f64::max);
|
||||
assert!(widest <= DISPLAY_ANGLE * 1.001, "a rim segment spans {} degrees", widest.to_degrees());
|
||||
}
|
||||
}
|
||||
// And the scene the renderers take draws those same polylines.
|
||||
let scene = crate::view::scene(&s, &crate::view::home_camera(&s));
|
||||
let drawn: usize =
|
||||
scene.lines.iter().filter(|l| l.points.iter().all(|p| ((p.x - 30.0).hypot(p.y - 20.0) - 2.0).abs() < 1e-6)).map(|l| l.points.len() - 1).sum();
|
||||
assert!(drawn >= 2 * (TAU / DISPLAY_ANGLE).ceil() as usize, "{drawn} rim segments in the scene");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_reads_the_display_mesh_of_a_large_sheet_answers_as_before() {
|
||||
let s = large_sheet();
|
||||
let st = s.world_state();
|
||||
let b = &st.bodies[0];
|
||||
let m = b.mesh();
|
||||
let coarse = b.body.display_mesh((b.body.size() * 1e-3).max(1e-3)).unwrap();
|
||||
// Bounds: the sheet's box is set by flat faces, so the rounder bends move nothing.
|
||||
let (now, before) = (m.bounds(), coarse.bounds());
|
||||
assert!(now.min.dist(before.min) < 1e-9 && now.max.dist(before.max) < 1e-9, "{now:?} {before:?}");
|
||||
let exact = solidcraft_kernel::measure(&b.body).unwrap().bbox;
|
||||
assert!(now.min.dist(exact.min) < 1e-6 && now.max.dist(exact.max) < 1e-6, "{now:?} {exact:?}");
|
||||
// The cost: the sheet takes 2212 triangles against the chord mesh's 92 when measured, a factor of
|
||||
// 24; what grows is the number of round faces and the facets across each, not the size of the body.
|
||||
assert!(
|
||||
m.triangles.len() <= 30 * coarse.triangles.len() && m.triangles.len() < 4000,
|
||||
"{} triangles, {} at the chord tolerance",
|
||||
m.triangles.len(),
|
||||
coarse.triangles.len()
|
||||
);
|
||||
// The same faces and edges, in the same order, between the same faces, with the same ends.
|
||||
assert_eq!(m.edge_faces, coarse.edge_faces);
|
||||
assert_eq!(m.tri_face.iter().max(), coarse.tri_face.iter().max());
|
||||
assert_eq!(m.edges.len(), coarse.edges.len());
|
||||
for (fine, rough) in m.edges.iter().zip(&coarse.edges) {
|
||||
assert!(fine[0].dist(rough[0]) < 1e-9 && fine[fine.len() - 1].dist(rough[rough.len() - 1]) < 1e-9);
|
||||
}
|
||||
// Picks: a point on a round face (on the true cylinder, which a flat strip across a bend
|
||||
// misses by up to 0.9 mm) is found on that face, as is one in the middle of a flat face.
|
||||
let mut round = 0;
|
||||
for face in 0..b.body.face_count() {
|
||||
let Some(FaceSurface::Cylinder { origin, axis, radius }) = face_surface(&b.body, face) else { continue };
|
||||
let corners: Vec<Vec3> =
|
||||
m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == face).flat_map(|(t, _)| t.map(|k| m.positions[k as usize])).collect();
|
||||
let middle = corners.iter().fold(Vec3::ZERO, |a, p| a + *p) / corners.len() as f64;
|
||||
// The middle of the face's corners, pushed out onto the cylinder.
|
||||
let along = origin + axis * (middle - origin).dot(axis);
|
||||
let p = along + (middle - along).normalized().unwrap() * radius;
|
||||
assert_eq!(solidcraft_doc::appearance::face_index_at(b, p), Some(face), "radius {radius} at {p:?}");
|
||||
round += 1;
|
||||
}
|
||||
assert_eq!(round, 12);
|
||||
let top = solidcraft_doc::appearance::face_index_at(b, Vec3::new(1500.0, 500.0, 2.0)).unwrap();
|
||||
assert!(matches!(face_surface(&b.body, top), Some(FaceSurface::Plane { normal, .. }) if normal.dist(Vec3::Z) < 1e-9));
|
||||
// Inside and outside: in the sheet, in a flange's wall, in a hole and above the sheet.
|
||||
assert!(m.contains(Vec3::new(1500.0, 500.0, 1.0)) && m.contains(Vec3::new(1500.0, now.min.y + 1.0, now.max.z - 10.0)));
|
||||
assert!(!m.contains(Vec3::new(30.0, 20.0, 1.0)) && !m.contains(Vec3::new(1500.0, 500.0, 3.0)));
|
||||
}
|
||||
@@ -252,3 +252,37 @@ fn root_sketch_on_a_moved_components_face() {
|
||||
let pts = sketch_world_points(&mut s, "Top");
|
||||
assert!(pts.iter().any(|p| near(*p, Vec3::new(-30.0, 40.0, 25.0))), "{pts:?}");
|
||||
}
|
||||
|
||||
/// (#558) With the root active, construction geometry, a hole and Press Pull picked on a moved
|
||||
/// component land on it where it is shown, and so do picks on a pasted instance.
|
||||
#[test]
|
||||
fn root_picks_on_a_moved_component_build_where_it_is_shown() {
|
||||
let mut s = moved_part();
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
// The block spans world x 50…80, y 0…20, z 0…10.
|
||||
run(&mut s, "construct.axis.edge", json!({"edge": {"edge": [65, 0, 0]}, "name": "FrontEdge"}));
|
||||
run(
|
||||
&mut s,
|
||||
"construct.plane.three_points",
|
||||
json!({"a": {"vertex": [50, 0, 10]}, "b": {"vertex": [80, 0, 10]}, "c": {"vertex": [50, 20, 10]}, "name": "Lid"}),
|
||||
);
|
||||
let lid = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Lid").unwrap().2;
|
||||
assert!((lid.origin.z - 10.0).abs() < 1e-6 && lid.normal().z.abs() > 1.0 - 1e-9, "{lid:?}");
|
||||
let st = s.model.state();
|
||||
let axis = st.construct.iter().find(|c| c.name == "FrontEdge").map(|c| c.geom.clone()).unwrap();
|
||||
let solidcraft_doc::construct::ConstructGeom::Axis { origin, dir } = axis else { panic!("{axis:?}") };
|
||||
assert!(origin.y.abs() < 1e-6 && origin.z.abs() < 1e-6 && dir.x.abs() > 1.0 - 1e-9, "{origin:?} {dir:?}");
|
||||
let v0 = volume(&mut s);
|
||||
run(&mut s, "solid.hole", json!({"position": [60, 10, 10], "diameter": 4, "depth": 5}));
|
||||
let v1 = volume(&mut s);
|
||||
assert!(v1 < v0 - 1.0, "the hole cut the moved block: {v0} {v1}");
|
||||
run(&mut s, "solid.press_pull", json!({"face": [80, 10, 5], "distance": 2}));
|
||||
assert!(volume(&mut s) > v1 + 1.0, "press pull grew the moved block");
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
|
||||
|
||||
// A pasted instance 40 mm further along y.
|
||||
run(&mut s, "occurrence.copy", json!({"component": "Part", "translate": [0, 40, 0]}));
|
||||
run(&mut s, "construct.plane.along_path", json!({"path": {"edge": [65, 40, 0]}, "t": 0.5, "name": "Across"}));
|
||||
let across = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Across").unwrap().2;
|
||||
assert!((across.origin.y - 40.0).abs() < 1e-6, "{across:?}");
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! A hole placed on a face follows that face along the hole's axis, never sideways: when the face
|
||||
//! moves the hole moves with it, and when the face no longer reaches the place the hole stays
|
||||
//! where it was put and says so instead of sliding to the face's nearest point.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn change(s: &mut Session, name: &str, expression: &str) {
|
||||
run(s, "parameters.change", json!({"name": name, "expression": expression}));
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
/// Whether the model holds material at (x, y, z).
|
||||
fn solid_at(s: &Session, x: f64, y: f64, z: f64) -> bool {
|
||||
s.model.state().bodies.iter().any(|b| b.body.contains(Vec3::new(x, y, z)))
|
||||
}
|
||||
|
||||
/// What the hole feature says (its error and its warning); empty when it is silent.
|
||||
fn said(s: &Session, feature: &str) -> String {
|
||||
let id = s.doc.find_feature(feature).unwrap().id;
|
||||
let r = s.model.result(id).unwrap();
|
||||
format!("{}{}", r.error.clone().unwrap_or_default(), r.warning.clone().unwrap_or_default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_the_face_no_longer_reaches_stays_where_it_was_placed_and_says_so() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "length", "expression": "100 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": "length", "width": 50, "height": 20, "name": "Plate"}));
|
||||
run(&mut s, "solid.hole", json!({"position": [80, 25, 20], "diameter": "6 mm", "name": "Hole"}));
|
||||
// The bore is at x = 80, 3 mm either way.
|
||||
let drilled_at_80 = |s: &Session| !solid_at(s, 80.0, 25.0, 10.0) && solid_at(s, 76.0, 25.0, 10.0) && solid_at(s, 84.0, 25.0, 10.0);
|
||||
assert!(drilled_at_80(&s));
|
||||
// Longer: the place is still on the top face, and the hole stays at x = 80.
|
||||
change(&mut s, "length", "150 mm");
|
||||
assert_eq!(said(&s, "Hole"), "");
|
||||
assert!(drilled_at_80(&s));
|
||||
// Shorter than the place: nothing is drilled at the new end, and the hole says why. A hole
|
||||
// moved to the face's nearest point would cut half a hole there (59 717 mm³ left).
|
||||
change(&mut s, "length", "60 mm");
|
||||
let left = volume(&mut s);
|
||||
assert!((left - 60_000.0).abs() < 1.0, "half a hole was cut at the plate's end: {left} mm³ of 60000");
|
||||
let msg = said(&s, "Hole");
|
||||
assert!(msg.contains("no longer on face") && msg.contains("[80, 25, 20]"), "{msg}");
|
||||
// Long again: the hole is back where it was put, with no message.
|
||||
change(&mut s, "length", "100 mm");
|
||||
assert_eq!(said(&s, "Hole"), "");
|
||||
assert!(drilled_at_80(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slanted_hole_stays_on_its_axis_when_its_face_moves() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "height", "expression": "20 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 60, "width": 30, "height": "height", "name": "Block"}));
|
||||
// Entering the top face at x = 20, running on in x as it goes down: 1 across for every 2 down.
|
||||
run(&mut s, "solid.hole", json!({"position": [20, 15, 20], "direction": [1, 0, -2], "diameter": "2 mm", "name": "Hole"}));
|
||||
// The axis is the line through the place along the direction, whatever the height of the block:
|
||||
// x = 20 + (20 - z) / 2. At mid-height the bore is empty on it and solid 3 mm to either side
|
||||
// (the bore is about 2.2 mm wide there).
|
||||
let on_axis = |s: &Session, h: f64| {
|
||||
let (x, z) = (20.0 + (20.0 - h / 2.0) / 2.0, h / 2.0);
|
||||
assert!(!solid_at(s, x, 15.0, z), "block height {h}: the bore should pass through x = {x} at z = {z}");
|
||||
assert!(solid_at(s, x - 3.0, 15.0, z) && solid_at(s, x + 3.0, 15.0, z), "block height {h}: material 3 mm beside the axis");
|
||||
// (The kernel may note a boolean retry for a slanted bore; the hole's place is on its face.)
|
||||
assert!(!said(s, "Hole").contains("no longer on face"), "block height {h}: {}", said(s, "Hole"));
|
||||
};
|
||||
on_axis(&s, 20.0);
|
||||
// Taller: the top face moves up. Moved to the face's nearest point the hole would slide
|
||||
// sideways onto another axis (5 mm further along x at mid-height); along its axis it enters
|
||||
// higher up the same line.
|
||||
change(&mut s, "height", "30 mm");
|
||||
on_axis(&s, 30.0);
|
||||
// Lower: the same line enters further along.
|
||||
change(&mut s, "height", "12 mm");
|
||||
on_axis(&s, 12.0);
|
||||
}
|
||||
+34
-20
@@ -27,6 +27,7 @@ use solidcraft_geom::Vec3;
|
||||
|
||||
pub use cmd::{CommandInfo, CommandSpec, auto_operation, command_specs, find_command};
|
||||
pub use solidcraft_doc as doc;
|
||||
pub use solidcraft_doc::Visibility;
|
||||
pub use solidcraft_geom as geom;
|
||||
pub use solidcraft_io as io;
|
||||
pub use solidcraft_kernel as kernel;
|
||||
@@ -217,8 +218,15 @@ pub struct Session {
|
||||
pub section: Option<(Vec3, Vec3)>,
|
||||
/// Surface analysis shading the model (zebra, draft, curvature map).
|
||||
pub analysis: Option<SurfaceAnalysis>,
|
||||
/// What is hidden or shown in the view (`browser.visibility`).
|
||||
/// What is hidden or shown in the view (`browser.visibility`): saved with the design, not an
|
||||
/// undo step. A design brings its saved visibility when it opens (`Session::new`).
|
||||
pub visibility: Visibility,
|
||||
/// The camera `view.apply` asked the view to use (view state, not saved). The desktop app
|
||||
/// moves its viewport there and takes it; headless renders keep using it until a standard
|
||||
/// view is chosen.
|
||||
pub camera: Option<render::Camera>,
|
||||
/// The visibility the design file holds (what `is_dirty` compares with).
|
||||
saved_visibility: Visibility,
|
||||
/// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of
|
||||
/// commands are undone however long the undo history is.
|
||||
pub(crate) checkpoints: Vec<Checkpoint>,
|
||||
@@ -252,22 +260,6 @@ pub(crate) struct Checkpoint {
|
||||
pub redo: Vec<Snapshot>,
|
||||
}
|
||||
|
||||
/// Items hidden or shown one by one (view state, not part of the design and not an undo step).
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Visibility {
|
||||
/// Bodies by name.
|
||||
pub hidden_bodies: Vec<String>,
|
||||
/// Exact occurrence placements hidden with all descendants.
|
||||
pub hidden_occurrences: Vec<Vec<u64>>,
|
||||
/// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction geometry, by name.
|
||||
pub hidden_origin: Vec<String>,
|
||||
/// Sketches by feature id.
|
||||
pub hidden_sketches: Vec<u64>,
|
||||
/// Finished sketches shown although a feature uses them.
|
||||
pub shown_sketches: Vec<u64>,
|
||||
}
|
||||
|
||||
/// A way of shading the model's faces to judge their shape (view state, not part of the design).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -297,7 +289,9 @@ impl Default for Session {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(doc: Document) -> Self {
|
||||
pub fn new(mut doc: Document) -> Self {
|
||||
// The session keeps what is hidden (not an undo step); the design file carries it.
|
||||
let visibility = std::mem::take(&mut doc.visibility);
|
||||
let doc = Arc::new(doc);
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
@@ -324,18 +318,32 @@ impl Session {
|
||||
analysis: None,
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
visibility: Visibility::default(),
|
||||
saved_visibility: visibility.clone(),
|
||||
visibility,
|
||||
camera: None,
|
||||
#[cfg(test)]
|
||||
evaluations: 0,
|
||||
crash_journal: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The design has changes not in its file: in the document, or in what is hidden.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
!Arc::ptr_eq(&self.doc, &self.saved) && *self.doc != *self.saved
|
||||
(!Arc::ptr_eq(&self.doc, &self.saved) && *self.doc != *self.saved) || self.visibility != self.saved_visibility
|
||||
}
|
||||
pub fn mark_saved(&mut self) {
|
||||
self.saved = self.doc.clone();
|
||||
self.saved_visibility = self.visibility.clone();
|
||||
}
|
||||
|
||||
/// The design as a file holds it: the document with what is hidden in it.
|
||||
pub fn design(&self) -> std::borrow::Cow<'_, Document> {
|
||||
if self.visibility.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(&*self.doc);
|
||||
}
|
||||
let mut doc = (*self.doc).clone();
|
||||
doc.visibility = self.visibility.clone();
|
||||
std::borrow::Cow::Owned(doc)
|
||||
}
|
||||
/// The design has changes not in its file (a recovered design).
|
||||
pub fn mark_unsaved(&mut self) {
|
||||
@@ -667,6 +675,8 @@ impl Session {
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
visibility: self.visibility.clone(),
|
||||
camera: self.camera,
|
||||
saved_visibility: self.saved_visibility.clone(),
|
||||
#[cfg(test)]
|
||||
evaluations: 0,
|
||||
crash_journal: false,
|
||||
@@ -793,8 +803,12 @@ mod construct_tests;
|
||||
#[cfg(test)]
|
||||
mod delete_face_tests;
|
||||
#[cfg(test)]
|
||||
mod display_mesh_tests;
|
||||
#[cfg(test)]
|
||||
mod extent_tests;
|
||||
#[cfg(test)]
|
||||
mod hole_face_tests;
|
||||
#[cfg(test)]
|
||||
mod naming_history_tests;
|
||||
#[cfg(test)]
|
||||
mod pattern_suppress_tests;
|
||||
|
||||
@@ -35,10 +35,13 @@ pub fn default_dir() -> Option<PathBuf> {
|
||||
env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share")))?
|
||||
};
|
||||
let (dir, legacy) = dirs_under(&root, cfg!(windows) || cfg!(target_os = "macos"));
|
||||
// Compatibility shim (docs/compatibility.md): autosaves made before the rename are copied
|
||||
// to the new folder on first launch; the old folder is left as it was.
|
||||
if let Err(e) = solidcraft_io::adopt_legacy_dir(&dir, &legacy) {
|
||||
log::warn!("recovery: could not copy {} to {}: {e}", legacy.display(), dir.display());
|
||||
// Compatibility shim (docs/compatibility.md): autosaves made before the rename (or, in the
|
||||
// Flatpak sandbox, under the old app id) are copied to the new folder on first launch; the
|
||||
// old folder is left as it was.
|
||||
for old in solidcraft_io::legacy_flatpak_path(&dir).into_iter().chain([legacy]) {
|
||||
if let Err(e) = solidcraft_io::adopt_legacy_dir(&dir, &old) {
|
||||
log::warn!("recovery: could not copy {} to {}: {e}", old.display(), dir.display());
|
||||
}
|
||||
}
|
||||
Some(dir)
|
||||
}
|
||||
@@ -143,7 +146,8 @@ pub struct Autosaver {
|
||||
/// Autosave this often while there are unsaved changes.
|
||||
pub interval: Duration,
|
||||
lock: Option<File>,
|
||||
last: Option<Arc<Document>>,
|
||||
/// The design and what was hidden at the last autosave.
|
||||
last: Option<(Arc<Document>, crate::Visibility)>,
|
||||
last_at: Instant,
|
||||
has_entry: bool,
|
||||
}
|
||||
@@ -189,17 +193,17 @@ impl Autosaver {
|
||||
crate::crash::rebased(s, s.path.as_deref().map(Path::new), false);
|
||||
return Ok(false);
|
||||
}
|
||||
if self.last.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.doc) || **l == *s.doc) {
|
||||
if self.last.as_ref().is_some_and(|(l, v)| (Arc::ptr_eq(l, &s.doc) || **l == *s.doc) && *v == s.visibility) {
|
||||
return Ok(false);
|
||||
}
|
||||
let io = |e: std::io::Error| EngineError::Other(format!("autosave: {e}"));
|
||||
let bytes = solidcraft_io::write_design_container(&s.doc)?;
|
||||
let bytes = solidcraft_io::write_design_container(&s.design())?;
|
||||
solidcraft_io::write_atomic(&file(&self.dir, &self.id, solidcraft_io::DESIGN_EXT), &bytes, false).map_err(io)?;
|
||||
let entry =
|
||||
Entry { id: self.id.clone(), path: s.path.clone(), name: s.doc.name.clone(), saved_at: unix_now(), features: s.doc.features.len() };
|
||||
let meta = serde_json::to_vec_pretty(&entry).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
solidcraft_io::write_atomic(&file(&self.dir, &self.id, "json"), &meta, false).map_err(io)?;
|
||||
self.last = Some(s.doc.clone());
|
||||
self.last = Some((s.doc.clone(), s.visibility.clone()));
|
||||
self.has_entry = true;
|
||||
// Crash replays start from this autosave from here.
|
||||
crate::crash::rebased(s, Some(&file(&self.dir, &self.id, solidcraft_io::DESIGN_EXT)), true);
|
||||
|
||||
+181
-8
@@ -96,6 +96,20 @@ fn join_extrude_from_a_chamfered_top_face() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_cut_in_pieces_twice_gets_pieces_of_their_own_names() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 5, "height": 5, "body_name": "Bar"}));
|
||||
for x in [10, 25] {
|
||||
run(&mut s, "solid.box", json!({"corner": [x, -1, -1], "length": 2, "width": 7, "height": 7, "body_name": "Gap"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Bar", "tools": ["Gap"], "operation": "cut"}));
|
||||
}
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
let unique: std::collections::BTreeSet<&String> = names.iter().collect();
|
||||
assert_eq!(unique.len(), 3, "{names:?}");
|
||||
assert!(names.contains(&"Bar (2)".to_string()), "{names:?}");
|
||||
}
|
||||
|
||||
/// A join that bridges two bodies makes them one body, not an overlap of the first and the second.
|
||||
#[test]
|
||||
fn join_bridging_two_bodies_merges_them() {
|
||||
@@ -363,6 +377,51 @@ fn revolve_a_spherical_shell_off_the_origin() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrude_of_adjacent_profiles_has_whole_edges() {
|
||||
// A holder from two regions of one sketch (a disc and the ring around it, both cut off by a
|
||||
// chord): the chord used to stay split where the inner circle met it, leaving vertices right
|
||||
// on the surface of a handle of that radius, and cutting the handle out of the holder failed.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Holder"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 40}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 35}));
|
||||
let x = (40.0f64 * 40.0 - 33.0 * 33.0).sqrt();
|
||||
run(&mut s, "sketch.line", json!({"points": [[-x, -33], [x, -33]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let si = run(&mut s, "sketch.inspect", json!({"sketch": "Holder"}));
|
||||
let below: Vec<Value> = si["profiles"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|p| (500.0..4900.0).contains(&p["area_mm2"].as_f64().unwrap()))
|
||||
.map(|p| p["index"].clone())
|
||||
.collect();
|
||||
assert_eq!(below.len(), 2, "{si}");
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Holder", "profiles": below, "distance": 23, "body_name": "Holder"}));
|
||||
let topo = run(&mut s, "model.faces", json!({"body": "Holder"}));
|
||||
assert_eq!(topo["faces"].as_array().unwrap().len(), 5, "a cut-off cylinder: {topo}");
|
||||
let lo = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["bodies"][0]["bbox"]["min"][1].as_f64().unwrap();
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Holder"], "translate": [0, -5.0 - lo, 0]}));
|
||||
// The handle: r35 on the Y axis with a bevelled end.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Handle"}));
|
||||
for pts in [
|
||||
[[0.0, 0.0], [0.0, 230.0]],
|
||||
[[0.0, 0.0], [25.0, 0.0]],
|
||||
[[25.0, 0.0], [35.0, 11.5]],
|
||||
[[35.0, 11.5], [35.0, 230.0]],
|
||||
[[35.0, 230.0], [0.0, 230.0]],
|
||||
] {
|
||||
run(&mut s, "sketch.line", json!({ "points": pts }));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.revolve", json!({"sketch": "Handle", "profiles": [0], "axis": "Y", "body_name": "Handle"}));
|
||||
let before = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
run(&mut s, "solid.combine", json!({"target": "Holder", "tools": ["Handle"], "operation": "cut", "keep_tools": true}));
|
||||
let after = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
assert!(after > 1000.0 && after < before * 0.5, "{before} -> {after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_tools_and_constraints() {
|
||||
let mut s = Session::default();
|
||||
@@ -771,12 +830,16 @@ fn step_open_then_model_on_it() {
|
||||
run(&mut s, "solid.hole", json!({"position": at(28.0, 15.0, 20.0), "diameter": 6}));
|
||||
want -= PI * 9.0 * 20.0;
|
||||
assert!(rel(volume(&mut s), want) < 5e-4, "{} vs {want}", volume(&mut s));
|
||||
// Modified imported faces no longer have an immutable construction certificate.
|
||||
// The public attachment refuses them; the user can explicitly choose a fixed plane.
|
||||
let before_fixed = s.doc.to_json();
|
||||
assert!(s.execute("sketch.create", &json!({"plane":{"face":at(10.0,15.0,20.0)}})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before_fixed);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0), "fixed": true}}));
|
||||
// Modified imported faces no longer have an immutable construction certificate; the top
|
||||
// attaches by its exact history name instead.
|
||||
let top = run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0)}}))["sketch"].as_u64().unwrap();
|
||||
assert!(matches!(
|
||||
s.placed_document().sketch_face_attachment(top, &s.model),
|
||||
doc::FaceAttachmentStatus::Healthy {
|
||||
target: doc::config::ConfigFaceTarget { certificate: Some(doc::config::FaceCertificate::Lineage), .. },
|
||||
..
|
||||
}
|
||||
));
|
||||
let c = [x0 + 10.0, y0 + 15.0];
|
||||
// Sketch coordinates on the face are relative to the model origin projected onto it.
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [c[0] - 4.0, c[1] - 4.0], "p1": [c[0] + 4.0, c[1] + 4.0]}));
|
||||
@@ -2747,7 +2810,6 @@ fn a_pattern_repeats_a_fillet_on_the_edges_it_found() {
|
||||
|
||||
/// A copy added by raising the count after a rename takes the next number, not the freed one (#372).
|
||||
#[test]
|
||||
#[ignore = "fails until #372 is fixed"]
|
||||
fn raising_a_pattern_count_after_a_rename_numbers_the_new_copy_after_the_others() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
|
||||
@@ -2780,7 +2842,6 @@ fn a_profile_added_later_is_named_after_body_name() {
|
||||
|
||||
/// The reply names the bodies the feature created, apart from the ones already there (#374).
|
||||
#[test]
|
||||
#[ignore = "fails until #374 is fixed"]
|
||||
fn extrude_reply_lists_the_bodies_it_created() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [0, 50, 0], "operation": "new_body", "body_name": "Base"}));
|
||||
@@ -2848,6 +2909,33 @@ fn fillet_fit_reduce_uses_the_largest_radius_that_fits() {
|
||||
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "reduce", "type": "chord"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_fit_reduce_keeps_the_radius_where_it_fits() {
|
||||
// A 2 mm fin on a block: its top edge can't take 4, a corner of the block far from it can.
|
||||
// Only the fin's edge is reduced; the reply gives each edge's radius.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 30, "height": 10, "corner": [19, 0, 20], "operation": "join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
let edges = json!([[21, 15, 30], [40, 0, 10]]);
|
||||
let out = run(&mut s, "solid.fillet", json!({"edges": edges, "radius": 4, "fit": "reduce"}));
|
||||
let fin = out["radius_applied"].as_f64().unwrap();
|
||||
assert!(fin < 4.0, "{out}");
|
||||
let each: Vec<(Vec<f64>, f64)> = out["radii_applied"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|x| (x["edge"].as_array().unwrap().iter().filter_map(Value::as_f64).collect(), x["radius"].as_f64().unwrap()))
|
||||
.collect();
|
||||
assert_eq!(each, [(vec![21.0, 15.0, 30.0], fin), (vec![40.0, 0.0, 10.0], 4.0)], "{out}");
|
||||
let removed = (1.0 - PI / 4.0) * (fin * fin * 30.0 + 16.0 * 20.0);
|
||||
let want = 40.0 * 30.0 * 20.0 + 2.0 * 30.0 * 10.0 - removed;
|
||||
assert!(rel(volume(&mut s), want) < 2e-4, "{} vs {want}", volume(&mut s));
|
||||
let id = out["feature"].as_u64().unwrap();
|
||||
let warning = s.model.result(id).and_then(|r| r.warning.clone()).unwrap_or_default();
|
||||
assert!(warning.contains("on 1 of 2 edges"), "{warning}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pattern_of_a_reduced_fillet_rounds_each_copy_alike() {
|
||||
// #293: a 6 mm wide post on a plate, its four vertical edges at 3.5 reduced to 3, patterned
|
||||
@@ -2861,3 +2949,88 @@ fn a_pattern_of_a_reduced_fillet_rounds_each_copy_alike() {
|
||||
let expect = 60.0 * 20.0 * 2.0 + 3.0 * (840.0 - (4.0 - PI) * 9.0 * 10.0);
|
||||
assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
/// A taper on a profile of lines and an arc (#546). The D profile (back 9.2, sides 7.4, a
|
||||
/// half-round front R 4.6 tangent to them) grows by d = z · tan 5° with mitred corners:
|
||||
/// A(d) = A₀ + P·d + (2 + π/2)·d², integrated over the 10 mm height.
|
||||
#[test]
|
||||
fn tapered_extrude_of_a_profile_mixing_lines_and_an_arc() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "D"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 4.6], [-7.4, 4.6], [-7.4, -4.6], [0, -4.6]], "closed": false}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [0, -4.6], "end": [0, 4.6]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "D", "distance": 10, "taper": "5 deg"}));
|
||||
let (t, h) = (5f64.to_radians().tan(), 10.0);
|
||||
let (a0, p, k) = (7.4 * 9.2 + PI * 4.6 * 4.6 / 2.0, 2.0 * 7.4 + 9.2 + PI * 4.6, 2.0 + PI / 2.0);
|
||||
let expect = a0 * h + p * t * h * h / 2.0 + k * t * t * h.powi(3) / 3.0;
|
||||
assert!(rel(volume(&mut s), expect) < 1e-3, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
/// A feature that joins into a body creates none, and its reply says so; a primitive's reply
|
||||
/// names the body it made (#374).
|
||||
#[test]
|
||||
fn feature_replies_list_only_the_bodies_they_created() {
|
||||
let mut s = Session::default();
|
||||
let r = run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Base"}));
|
||||
assert_eq!(r["created_bodies"], json!(["Base"]), "{r}");
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Boss"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [5, 5], "radius": 2}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let r = run(&mut s, "solid.extrude", json!({"sketch": "Boss", "distance": "15", "operation": "join"}));
|
||||
assert_eq!(r["created_bodies"], json!([]), "{r}");
|
||||
assert_eq!(r["bodies"], json!(["Base"]), "{r}");
|
||||
}
|
||||
|
||||
/// A feature reply also says which existing bodies it changed and which are gone, so a client
|
||||
/// that skips the full body list (MCP `batch` with `verbose: false`, #530) still sees every effect.
|
||||
#[test]
|
||||
fn feature_replies_list_the_bodies_they_changed_and_removed() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new", "body_name": "B"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0], "operation": "new", "body_name": "C"}));
|
||||
// A pocket in A changes A only.
|
||||
let r = run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 10, "corner": [3, 3, 5], "operation": "cut", "targets": ["A"]}));
|
||||
assert_eq!((&r["created_bodies"], &r["changed_bodies"], &r["removed_bodies"]), (&json!([]), &json!(["A"]), &json!([])), "{r}");
|
||||
// Joining C into B changes B and removes C.
|
||||
let r = run(&mut s, "solid.combine", json!({"target": "B", "tools": ["C"], "operation": "join"}));
|
||||
assert_eq!((&r["changed_bodies"], &r["removed_bodies"]), (&json!(["B"]), &json!(["C"])), "{r}");
|
||||
// A new body changes nothing else.
|
||||
let r = run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [0, 50, 0], "operation": "new", "body_name": "D"}));
|
||||
assert_eq!((&r["created_bodies"], &r["changed_bodies"], &r["removed_bodies"]), (&json!(["D"]), &json!([]), &json!([])), "{r}");
|
||||
}
|
||||
|
||||
/// Drafting a cylinder's side face 5° from the base plane makes a cone frustum (#547): radius 10
|
||||
/// at the base, 10 − 20 · tan 5° at the top.
|
||||
#[test]
|
||||
fn draft_on_a_cylinder_side_makes_a_frustum() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 20}));
|
||||
run(&mut s, "solid.draft", json!({"faces": [[10, 0, 10]], "angle": "5 deg", "neutral": "XY"}));
|
||||
let (big, h) = (10.0, 20.0);
|
||||
let small = big - h * 5f64.to_radians().tan();
|
||||
let expect = PI * h / 3.0 * (big * big + big * small + small * small);
|
||||
assert!(rel(volume(&mut s), expect) < 1e-3, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
/// The copies added after a rename keep their numbers through undo/redo, save/reopen and a
|
||||
/// further count change; lowering the count drops the last copies (#372).
|
||||
#[test]
|
||||
fn pattern_copies_added_after_a_rename_keep_their_numbers() {
|
||||
let mut s = Session::default();
|
||||
let names = |s: &Session| -> Vec<String> { s.model.state().bodies.iter().map(|b| b.name.clone()).collect() };
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20, "name": "Pat"}));
|
||||
run(&mut s, "body.rename", json!({"body": "X (2)", "name": "Second"}));
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "5"}}}));
|
||||
assert_eq!(names(&s), ["X", "Second", "X (3)", "X (4)", "X (5)"]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(names(&s), ["X", "Second", "X (3)"]);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(names(&s), ["X", "Second", "X (3)", "X (4)", "X (5)"]);
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(names(&reopened), ["X", "Second", "X (3)", "X (4)", "X (5)"]);
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "2"}}}));
|
||||
assert_eq!(names(&s), ["X", "Second"]);
|
||||
}
|
||||
@@ -67,3 +67,38 @@ fn modelled_internal_threads_m3_to_m20() {
|
||||
assert!((got - want).abs() < want * 0.03, "M{d}: removed {got}, ISO profile {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue 602: rounding the edges of a hex boss on a threaded shaft froze the app. The thread's
|
||||
/// thousands of facets were healed pair by pair, once per feature after it, and the fillet's
|
||||
/// preview then compared each of them with the whole old body.
|
||||
#[test]
|
||||
fn fillet_beside_a_modelled_thread_finishes() {
|
||||
let mut s = Session::default();
|
||||
for (id, p) in [
|
||||
("sketch.create", json!({"plane": "XY"})),
|
||||
("sketch.circle.center", json!({"center": [0, 0], "diameter": 5})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.extrude", json!({"distance": 10, "body_name": "Screw"})),
|
||||
("sketch.create", json!({"plane": {"face": [0, 0, 10]}})),
|
||||
("sketch.polygon.circumscribed", json!({"center": [0, 0], "radius": 2, "sides": 6})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.extrude", json!({"distance": 3, "profiles": [{"point": [0, 0]}], "operation": "join", "targets": ["Screw"]})),
|
||||
("solid.thread", json!({"face": [2.5, 0, 5], "designation": "M5", "length": 4, "modeled": true})),
|
||||
] {
|
||||
run(&mut s, id, p);
|
||||
}
|
||||
let faces_before = s.model.state().body("Screw").map(|b| b.body.face_count()).unwrap_or(0);
|
||||
assert!(faces_before > 1000, "the thread is faceted: {faces_before} faces");
|
||||
let volume_before = volume(&mut s, "Screw");
|
||||
let top: Vec<Value> = (0..6)
|
||||
.map(|i| {
|
||||
let a = (30.0 + 60.0 * i as f64).to_radians();
|
||||
json!([2.0 * a.cos(), 2.0 * a.sin(), 13])
|
||||
})
|
||||
.collect();
|
||||
let started = std::time::Instant::now();
|
||||
run(&mut s, "solid.fillet", json!({"edges": top, "radius": 0.3}));
|
||||
assert!(started.elapsed().as_secs() < 30, "the fillet took {:?}", started.elapsed());
|
||||
let volume_after = volume(&mut s, "Screw");
|
||||
assert!(volume_after < volume_before && volume_after > volume_before * 0.99, "{volume_before} -> {volume_after}");
|
||||
}
|
||||
@@ -25,7 +25,7 @@ fn sampler_keeps_style_precedence_hidden_references_text_and_disjoint_parts() {
|
||||
let text = sk
|
||||
.add_link(
|
||||
LinkKind::Text,
|
||||
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0. },
|
||||
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0., font: None },
|
||||
&[LinkGeom::Line(Vec2::new(0., 7.), Vec2::new(2., 7.))],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
//! SolidCraft-owned portable B-rep data, independent of a modelling backend.
|
||||
//! Millimetres, radians, right-handed Z-up. See docs/native-brep.md for semantics.
|
||||
//! Validation checks structure and resource budgets, not geometric equivalence or trust.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod envelope;
|
||||
|
||||
pub const VERSION: u32 = 2;
|
||||
pub const MAX_ITEMS: usize = 100_000;
|
||||
const MAX_DEPTH: usize = 16;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Brep {
|
||||
pub version: u32,
|
||||
pub shells: Vec<Shell>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Shell {
|
||||
pub vertices: Vec<[f64; 3]>,
|
||||
pub edges: Vec<Edge>,
|
||||
pub faces: Vec<Face>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Edge {
|
||||
pub vertices: [u32; 2],
|
||||
pub curve: Curve,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Face {
|
||||
pub loops: Vec<Vec<EdgeUse>>,
|
||||
pub surface: Surface,
|
||||
pub forward: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeUse {
|
||||
pub edge: u32,
|
||||
pub forward: bool,
|
||||
}
|
||||
|
||||
/// Right-handed orthonormal frame; z = x_axis cross y_axis.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Frame {
|
||||
pub origin: [f64; 3],
|
||||
pub x_axis: [f64; 3],
|
||||
pub y_axis: [f64; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Curve {
|
||||
Line { start: [f64; 3], end: [f64; 3] },
|
||||
CircleArc { frame: Frame, radius: f64, angle: [f64; 2] },
|
||||
EllipseArc { frame: Frame, radii: [f64; 2], angle: [f64; 2] },
|
||||
Polyline { points: Vec<[f64; 3]> },
|
||||
Spline { knots: Vec<f64>, points: Vec<[f64; 3]> },
|
||||
RationalSpline { knots: Vec<f64>, homogeneous: Vec<[f64; 4]> },
|
||||
Intersection { knots: Vec<f64>, points: Vec<[f64; 3]>, fitting_tolerance: f64, faces: Option<[u32; 2]> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Surface {
|
||||
Plane { origin: [f64; 3], u_axis: [f64; 3], v_axis: [f64; 3] },
|
||||
Cylinder { frame: Frame, radius: f64, angle: [f64; 2], height: [f64; 2] },
|
||||
Cone { frame: Frame, radius_at_origin: f64, half_angle: f64, angle: [f64; 2], height: [f64; 2] },
|
||||
Sphere { frame: Frame, radius: f64, longitude: [f64; 2], latitude: [f64; 2] },
|
||||
Torus { frame: Frame, major_radius: f64, minor_radius: f64, longitude: [f64; 2], meridian: [f64; 2] },
|
||||
Spline { u_knots: Vec<f64>, v_knots: Vec<f64>, points: Vec<Vec<[f64; 3]>> },
|
||||
RationalSpline { u_knots: Vec<f64>, v_knots: Vec<f64>, homogeneous: Vec<Vec<[f64; 4]>> },
|
||||
Revolution { profile: Box<Curve>, origin: [f64; 3], axis: [f64; 3], angle: [f64; 2] },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, thiserror::Error)]
|
||||
#[error("portable B-rep: {0}")]
|
||||
pub struct Invalid(pub &'static str);
|
||||
|
||||
type Result<T> = std::result::Result<T, Invalid>;
|
||||
|
||||
fn require(condition: bool, message: &'static str) -> Result<()> {
|
||||
if condition { Ok(()) } else { Err(Invalid(message)) }
|
||||
}
|
||||
|
||||
struct Budget(usize);
|
||||
impl Budget {
|
||||
fn add(&mut self, n: usize) -> Result<()> {
|
||||
self.0 = self.0.checked_add(n).ok_or(Invalid("item budget"))?;
|
||||
require(self.0 <= MAX_ITEMS, "item budget")
|
||||
}
|
||||
}
|
||||
fn numbers(values: impl IntoIterator<Item = f64>) -> Result<()> {
|
||||
require(values.into_iter().all(|v| v.is_finite() && v.abs() <= 1e12), "non-finite or excessive coordinate")
|
||||
}
|
||||
fn points<const N: usize>(values: &[[f64; N]], budget: &mut Budget) -> Result<()> {
|
||||
budget.add(values.len())?;
|
||||
numbers(values.iter().flatten().copied())
|
||||
}
|
||||
fn knots(values: &[f64], count: usize, budget: &mut Budget) -> Result<()> {
|
||||
budget.add(values.len())?;
|
||||
numbers(values.iter().copied())?;
|
||||
let degree = values.len().checked_sub(count.saturating_add(1)).ok_or(Invalid("spline degree"))?;
|
||||
require((2..=4096).contains(&count) && (1..=16).contains(°ree) && degree < count, "spline degree or size")?;
|
||||
require(values.windows(2).all(|w| w.first() <= w.get(1)), "unordered knots")?;
|
||||
require(values.get(degree) < values.get(count), "empty spline domain")?;
|
||||
// No knot may have multiplicity greater than degree + 1.
|
||||
let mut run = 1;
|
||||
for pair in values.windows(2) {
|
||||
run = if pair.first() == pair.get(1) { run + 1 } else { 1 };
|
||||
require(run <= degree + 1, "knot multiplicity")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn weights(values: &[[f64; 4]]) -> Result<()> {
|
||||
require(values.iter().all(|p| p.get(3).is_some_and(|w| *w >= 1e-12)), "non-positive rational weight")
|
||||
}
|
||||
fn grid<const N: usize>(u: &[f64], v: &[f64], rows: &[Vec<[f64; N]>], budget: &mut Budget) -> Result<()> {
|
||||
let columns = rows.first().map_or(0, Vec::len);
|
||||
knots(u, rows.len(), budget)?;
|
||||
knots(v, columns, budget)?;
|
||||
for row in rows {
|
||||
require(row.len() == columns, "ragged surface")?;
|
||||
points(row, budget)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn vector(p: [f64; 3]) -> crate::Vec3 {
|
||||
crate::Vec3::new(p[0], p[1], p[2])
|
||||
}
|
||||
fn frame(f: &Frame, budget: &mut Budget) -> Result<()> {
|
||||
points(&[f.origin, f.x_axis, f.y_axis], budget)?;
|
||||
let (x, y) = (vector(f.x_axis), vector(f.y_axis));
|
||||
require((x.len() - 1.).abs() <= 1e-8 && (y.len() - 1.).abs() <= 1e-8 && x.dot(y).abs() <= 1e-8, "nonorthonormal frame")
|
||||
}
|
||||
fn range(r: &[f64; 2]) -> Result<()> {
|
||||
numbers(*r)?;
|
||||
require(r[0] < r[1], "empty parameter range")
|
||||
}
|
||||
fn angle(r: &[f64; 2]) -> Result<()> {
|
||||
range(r)?;
|
||||
require(r[1] - r[0] <= std::f64::consts::TAU + 1e-12, "angular range exceeds one turn")
|
||||
}
|
||||
fn radius(r: f64) -> Result<()> {
|
||||
require(r.is_finite() && (1e-12..=1e12).contains(&r), "radius")
|
||||
}
|
||||
fn curve(c: &Curve, depth: usize, budget: &mut Budget) -> Result<()> {
|
||||
require(depth <= MAX_DEPTH, "geometry depth")?;
|
||||
budget.add(1)?;
|
||||
match c {
|
||||
Curve::Line { start, end } => {
|
||||
points(&[*start, *end], budget)?;
|
||||
require(start != end, "collapsed line")
|
||||
}
|
||||
Curve::CircleArc { frame: f, radius: r, angle: a } => {
|
||||
frame(f, budget)?;
|
||||
radius(*r)?;
|
||||
angle(a)
|
||||
}
|
||||
Curve::EllipseArc { frame: f, radii, angle: a } => {
|
||||
frame(f, budget)?;
|
||||
for r in radii {
|
||||
radius(*r)?;
|
||||
}
|
||||
angle(a)
|
||||
}
|
||||
Curve::Polyline { points: p } => {
|
||||
require(p.len() >= 2, "empty polyline")?;
|
||||
points(p, budget)?;
|
||||
require(p.windows(2).all(|p| p.first() != p.get(1)), "collapsed polyline segment")
|
||||
}
|
||||
Curve::Spline { knots: k, points: p } => {
|
||||
knots(k, p.len(), budget)?;
|
||||
points(p, budget)
|
||||
}
|
||||
Curve::RationalSpline { knots: k, homogeneous: p } => {
|
||||
knots(k, p.len(), budget)?;
|
||||
points(p, budget)?;
|
||||
weights(p)
|
||||
}
|
||||
Curve::Intersection { knots: k, points: p, fitting_tolerance, .. } => {
|
||||
require((1e-12..=1.0).contains(fitting_tolerance), "intersection fitting tolerance")?;
|
||||
knots(k, p.len(), budget)?;
|
||||
points(p, budget)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn surface(s: &Surface, depth: usize, budget: &mut Budget) -> Result<()> {
|
||||
require(depth <= MAX_DEPTH, "geometry depth")?;
|
||||
budget.add(1)?;
|
||||
match s {
|
||||
Surface::Plane { origin, u_axis, v_axis } => {
|
||||
points(&[*origin, *u_axis, *v_axis], budget)?;
|
||||
require(vector(*u_axis).cross(vector(*v_axis)).len() > 1e-12, "degenerate plane")
|
||||
}
|
||||
Surface::Cylinder { frame: f, radius: r, angle: a, height } => {
|
||||
frame(f, budget)?;
|
||||
radius(*r)?;
|
||||
angle(a)?;
|
||||
range(height)
|
||||
}
|
||||
Surface::Cone { frame: f, radius_at_origin, half_angle, angle: a, height } => {
|
||||
frame(f, budget)?;
|
||||
numbers([*radius_at_origin, *half_angle])?;
|
||||
require(*radius_at_origin >= 0. && *half_angle > 0. && *half_angle < std::f64::consts::FRAC_PI_2, "cone dimensions")?;
|
||||
angle(a)?;
|
||||
range(height)?;
|
||||
require(height.iter().all(|h| radius_at_origin + h * half_angle.tan() >= 0.), "negative cone radius")
|
||||
}
|
||||
Surface::Sphere { frame: f, radius: r, longitude, latitude } => {
|
||||
frame(f, budget)?;
|
||||
radius(*r)?;
|
||||
angle(longitude)?;
|
||||
range(latitude)?;
|
||||
require(latitude[0] >= -std::f64::consts::FRAC_PI_2 && latitude[1] <= std::f64::consts::FRAC_PI_2, "sphere latitude")
|
||||
}
|
||||
Surface::Torus { frame: f, major_radius, minor_radius, longitude, meridian } => {
|
||||
frame(f, budget)?;
|
||||
radius(*major_radius)?;
|
||||
radius(*minor_radius)?;
|
||||
require(major_radius > minor_radius, "ring torus radii")?;
|
||||
angle(longitude)?;
|
||||
angle(meridian)
|
||||
}
|
||||
Surface::Spline { u_knots, v_knots, points: p } => grid(u_knots, v_knots, p, budget),
|
||||
Surface::RationalSpline { u_knots, v_knots, homogeneous: p } => {
|
||||
grid(u_knots, v_knots, p, budget)?;
|
||||
for row in p {
|
||||
weights(row)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Surface::Revolution { profile, origin, axis, angle: a } => {
|
||||
points(&[*origin, *axis], budget)?;
|
||||
require((vector(*axis).len() - 1.0).abs() <= 1e-8, "revolution axis is not unit length")?;
|
||||
angle(a)?;
|
||||
curve(profile, depth + 1, budget)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Brep {
|
||||
/// Validate portable structure without calling a kernel or evaluating geometry.
|
||||
/// Does not certify watertightness, surface coincidence, or authoritative state.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
require(self.version == VERSION, "unsupported version")?;
|
||||
require(!self.shells.is_empty() && self.shells.len() <= 1024, "shell count")?;
|
||||
let mut budget = Budget(0);
|
||||
for shell in &self.shells {
|
||||
points(&shell.vertices, &mut budget)?;
|
||||
budget.add(shell.edges.len())?;
|
||||
budget.add(shell.faces.len())?;
|
||||
require(!shell.faces.is_empty(), "empty shell")?;
|
||||
for edge in &shell.edges {
|
||||
require(edge.vertices.iter().all(|&v| (v as usize) < shell.vertices.len()), "edge vertices")?;
|
||||
curve(&edge.curve, 0, &mut budget)?;
|
||||
if let Curve::Intersection { faces: Some(hints), .. } = &edge.curve {
|
||||
require(hints.iter().all(|&i| (i as usize) < shell.faces.len()), "intersection face hint")?;
|
||||
}
|
||||
}
|
||||
for face in &shell.faces {
|
||||
require(!face.loops.is_empty(), "face loops")?;
|
||||
surface(&face.surface, 0, &mut budget)?;
|
||||
for wire in &face.loops {
|
||||
require(!wire.is_empty(), "empty wire")?;
|
||||
budget.add(wire.len())?;
|
||||
let mut first = None;
|
||||
let mut last = None;
|
||||
for use_ in wire {
|
||||
let edge = shell.edges.get(use_.edge as usize).ok_or(Invalid("edge reference"))?;
|
||||
let [a, b] = edge.vertices;
|
||||
let (start, end) = if use_.forward { (a, b) } else { (b, a) };
|
||||
require(last.is_none_or(|v| v == start), "disconnected wire")?;
|
||||
first.get_or_insert(start);
|
||||
last = Some(end);
|
||||
}
|
||||
require(first == last, "open wire")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn triangle() -> Brep {
|
||||
let vertices = vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]];
|
||||
let edges = (0..3)
|
||||
.map(|i| Edge { vertices: [i as u32, ((i + 1) % 3) as u32], curve: Curve::Line { start: vertices[i], end: vertices[(i + 1) % 3] } })
|
||||
.collect();
|
||||
Brep {
|
||||
version: VERSION,
|
||||
shells: vec![Shell {
|
||||
vertices,
|
||||
edges,
|
||||
faces: vec![Face {
|
||||
loops: vec![(0..3).map(|edge| EdgeUse { edge, forward: true }).collect()],
|
||||
surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] },
|
||||
forward: true,
|
||||
}],
|
||||
}],
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn portable_open_face_round_trip_without_a_kernel() {
|
||||
let body = triangle();
|
||||
body.validate().unwrap();
|
||||
let bytes = serde_json::to_vec(&body).unwrap();
|
||||
let restored: Brep = serde_json::from_slice(&bytes).unwrap();
|
||||
restored.validate().unwrap();
|
||||
assert_eq!(body, restored);
|
||||
assert!(!String::from_utf8(bytes).unwrap().contains("truck"));
|
||||
}
|
||||
#[test]
|
||||
fn topology_references_and_wire_orientation_are_checked() {
|
||||
let mut b = triangle();
|
||||
b.shells[0].faces[0].loops[0][0].edge = u32::MAX;
|
||||
assert!(b.validate().is_err());
|
||||
let mut b = triangle();
|
||||
b.shells[0].faces[0].loops[0][1].forward = false;
|
||||
assert!(b.validate().is_err());
|
||||
let mut b = triangle();
|
||||
b.shells[0].edges[0].vertices[0] = u32::MAX;
|
||||
assert!(b.validate().is_err());
|
||||
let mut b = triangle();
|
||||
b.version += 1;
|
||||
assert!(b.validate().is_err());
|
||||
let mut b = triangle();
|
||||
b.shells[0].vertices[0][0] = f64::NAN;
|
||||
assert!(b.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn closed_edge_can_share_its_endpoint_vertex() {
|
||||
let mut body = triangle();
|
||||
body.shells[0].vertices.truncate(1);
|
||||
body.shells[0].edges =
|
||||
vec![Edge { vertices: [0, 0], curve: Curve::Polyline { points: vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 0.]] } }];
|
||||
body.shells[0].faces[0].loops = vec![vec![EdgeUse { edge: 0, forward: true }]];
|
||||
body.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_domains_weights_and_nesting_are_bounded() {
|
||||
let mut budget = Budget(0);
|
||||
assert!(knots(&[0., 0., 1., 1.], 2, &mut budget).is_ok());
|
||||
for bad in [vec![0., 0., 0., 0.], vec![0., 1., 0., 1.], vec![0., 0., f64::INFINITY, 1.]] {
|
||||
assert!(knots(&bad, 2, &mut Budget(0)).is_err());
|
||||
}
|
||||
let c = Curve::RationalSpline { knots: vec![0., 0., 1., 1.], homogeneous: vec![[0., 0., 0., 0.], [1., 0., 0., 1.]] };
|
||||
assert!(curve(&c, 0, &mut Budget(0)).is_err());
|
||||
let p = Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [2., 0., 0.] };
|
||||
assert!(surface(&p, 0, &mut Budget(0)).is_err());
|
||||
assert!(curve(&Curve::Line { start: [0.; 3], end: [1.; 3] }, MAX_DEPTH + 1, &mut Budget(0)).is_err());
|
||||
assert!(Budget(MAX_ITEMS).add(1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytic_records_have_independent_frames_and_domains() {
|
||||
use std::f64::consts::{FRAC_PI_2, TAU};
|
||||
let f = Frame { origin: [1., 2., 3.], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] };
|
||||
let surfaces = [
|
||||
Surface::Cylinder { frame: f.clone(), radius: 2., angle: [0., TAU], height: [-1., 3.] },
|
||||
Surface::Cone { frame: f.clone(), radius_at_origin: 2., half_angle: 0.2, angle: [0., TAU], height: [0., 3.] },
|
||||
Surface::Sphere { frame: f.clone(), radius: 2., longitude: [0., TAU], latitude: [-FRAC_PI_2, FRAC_PI_2] },
|
||||
Surface::Torus { frame: f.clone(), major_radius: 4., minor_radius: 1., longitude: [0., TAU], meridian: [0., TAU] },
|
||||
Surface::Revolution {
|
||||
profile: Box::new(Curve::Line { start: [2., 0., 0.], end: [2., 0., 3.] }),
|
||||
origin: [0.; 3],
|
||||
axis: [0., 0., 1.],
|
||||
angle: [0., TAU],
|
||||
},
|
||||
];
|
||||
for s in surfaces {
|
||||
surface(&s, 0, &mut Budget(0)).unwrap();
|
||||
let encoded = serde_json::to_string(&s).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Surface>(&encoded).unwrap(), s);
|
||||
assert!(!encoded.contains("columns") && !encoded.contains("forward"));
|
||||
}
|
||||
for c in [
|
||||
Curve::CircleArc { frame: f.clone(), radius: 2., angle: [0., FRAC_PI_2] },
|
||||
Curve::EllipseArc { frame: f.clone(), radii: [2., 3.], angle: [0., TAU] },
|
||||
Curve::Intersection { knots: vec![0., 0., 1., 1.], points: vec![[0.; 3], [1.; 3]], fitting_tolerance: 1e-6, faces: None },
|
||||
] {
|
||||
curve(&c, 0, &mut Budget(0)).unwrap();
|
||||
let encoded = serde_json::to_string(&c).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Curve>(&encoded).unwrap(), c);
|
||||
assert!(!encoded.contains("leader") && !encoded.contains("surfaces"));
|
||||
}
|
||||
let mut bad = f;
|
||||
bad.y_axis = bad.x_axis;
|
||||
assert!(frame(&bad, &mut Budget(0)).is_err());
|
||||
assert!(angle(&[0., TAU + 0.1]).is_err());
|
||||
assert!(range(&[2., 1.]).is_err());
|
||||
assert!(radius(f64::NAN).is_err());
|
||||
let mut b = triangle();
|
||||
b.shells[0].edges[0].curve =
|
||||
Curve::Intersection { knots: vec![0., 0., 1., 1.], points: vec![[0.; 3], [1., 0., 0.]], fitting_tolerance: 1e-6, faces: Some([0, 1]) };
|
||||
assert!(b.validate().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Stable version-one body metadata records. Runtime model types never define this wire contract.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Metadata {
|
||||
pub version: u32,
|
||||
pub color: Option<[f32; 3]>,
|
||||
pub paint: Option<Paint>,
|
||||
pub boundaries: Option<Vec<Vec<[f64; 3]>>>,
|
||||
pub face_sources: Option<Vec<Option<FaceSource>>>,
|
||||
pub history: Option<History>,
|
||||
pub preserve_boundaries: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Plane {
|
||||
pub origin: [f64; 3],
|
||||
pub x_axis: [f64; 3],
|
||||
pub y_axis: [f64; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Segment {
|
||||
Line { start: [f64; 2], end: [f64; 2] },
|
||||
Arc { center: [f64; 2], radius: f64, start: f64, sweep: f64 },
|
||||
Cubic { points: [[f64; 2]; 4] },
|
||||
Conic { points: [[f64; 2]; 3], middle_weight: f64 },
|
||||
Bezier { points: Vec<[f64; 2]> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Region {
|
||||
pub outer: Vec<Segment>,
|
||||
pub holes: Vec<Vec<Segment>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum FaceSource {
|
||||
ExtrusionSide { segment: Segment, plane: Option<Plane>, coalesced: bool },
|
||||
CertifiedExtrusionSide { producer: u64, sketch: u64, curve: String, plane: Option<Plane> },
|
||||
ExtrusionCap { high: bool, region: Region, plane: Plane },
|
||||
CertifiedExtrusionCap { producer: u64, sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>>, plane: Plane },
|
||||
CylinderCap { producer: u64, high: bool, plane: Plane },
|
||||
BoxFace { producer: Option<u64>, axis: u8, high: bool, plane: Plane },
|
||||
PlanarShell { producer: Option<u64>, source: Box<FaceSource>, inner: bool, plane: Plane },
|
||||
Imported { item: u64, face: u64, payload_sha256: Option<String>, producer: Option<u64> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Paint {
|
||||
pub opacity: f32,
|
||||
pub faces: Vec<FacePaint>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FacePaint {
|
||||
pub face: u32,
|
||||
pub color: [f32; 3],
|
||||
pub opacity: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct History {
|
||||
pub operands: u32,
|
||||
pub faces: Vec<FaceHistory>,
|
||||
pub edges: Vec<EdgeHistory>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FaceHistory {
|
||||
pub origins: Vec<FaceOrigin>,
|
||||
pub change: Change,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeHistory {
|
||||
pub faces: Vec<u32>,
|
||||
pub origins: Vec<EdgeOrigin>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum FaceOrigin {
|
||||
Face { operand: u32, face: u32 },
|
||||
Profile { segment: u32 },
|
||||
Cap { end: bool },
|
||||
Edge { operand: u32, edge: u32 },
|
||||
Offset { operand: u32, face: u32 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum EdgeOrigin {
|
||||
Edge { operand: u32, edge: u32 },
|
||||
Meeting { sides: [Vec<FaceOrigin>; 2] },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Change {
|
||||
Kept,
|
||||
Modified,
|
||||
Split,
|
||||
Merged,
|
||||
Generated,
|
||||
}
|
||||
@@ -3,11 +3,12 @@
|
||||
//! (B-spline basis and NURBS surfaces in [`bspline`], one-surface fills of closed loops in
|
||||
//! [`fill`], T-splines in [`tspline`]). Units are millimetres.
|
||||
//!
|
||||
//! This crate has no knowledge of sketches, documents or the B-rep kernel; every other crate
|
||||
//! This crate has no knowledge of sketches, documents or a modelling backend; every other crate
|
||||
//! builds on it.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod brep;
|
||||
pub mod bspline;
|
||||
pub mod env;
|
||||
pub mod fill;
|
||||
|
||||
@@ -76,6 +76,40 @@ pub struct Mesh {
|
||||
pub edge_faces: Vec<Vec<u32>>,
|
||||
}
|
||||
|
||||
fn boxes_meet(a: &Aabb3, b: &Aabb3) -> bool {
|
||||
a.min.x <= b.max.x && b.min.x <= a.max.x && a.min.y <= b.max.y && b.min.y <= a.max.y && a.min.z <= b.max.z && b.min.z <= a.max.z
|
||||
}
|
||||
|
||||
/// Two triangles that pass through each other (see [`Mesh::crosses`]).
|
||||
fn triangles_cross(t: &[Vec3; 3], u: &[Vec3; 3], tol: f64) -> bool {
|
||||
// Signed distances of one triangle's corners from the other's plane, when they lie more
|
||||
// than `tol` to both sides of it.
|
||||
let sides = |tri: &[Vec3; 3], plane: &[Vec3; 3]| -> Option<(Vec3, [f64; 3])> {
|
||||
let n = (plane[1] - plane[0]).cross(plane[2] - plane[0]).normalized()?;
|
||||
let d = [n.dot(tri[0] - plane[0]), n.dot(tri[1] - plane[0]), n.dot(tri[2] - plane[0])];
|
||||
let (lo, hi) = d.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
|
||||
(lo < -tol && hi > tol).then_some((n, d))
|
||||
};
|
||||
let (Some((nu, dt)), Some((nt, du))) = (sides(t, u), sides(u, t)) else { return false };
|
||||
let Some(line) = nt.cross(nu).normalized() else { return false };
|
||||
// Where a triangle's edges pass through the other's plane, measured along the line the
|
||||
// two planes share.
|
||||
let reach = |tri: &[Vec3; 3], d: &[f64; 3]| -> (f64, f64) {
|
||||
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for (i, j) in [(0, 1), (1, 2), (2, 0)] {
|
||||
let (p, q, dp, dq) = (tri[i], tri[j], d[i], d[j]);
|
||||
if (dp < 0.0) != (dq < 0.0) && dp != dq {
|
||||
let at = line.dot(p + (q - p) * (dp / (dp - dq)));
|
||||
lo = lo.min(at);
|
||||
hi = hi.max(at);
|
||||
}
|
||||
}
|
||||
(lo, hi)
|
||||
};
|
||||
let ((t0, t1), (u0, u1)) = (reach(t, &dt), reach(u, &du));
|
||||
t1.min(u1) - t0.max(u0) > tol
|
||||
}
|
||||
|
||||
/// Mass properties of a closed mesh.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MeshMeasure {
|
||||
@@ -281,6 +315,86 @@ impl Mesh {
|
||||
dirs.iter().filter(|d| self.ray_crossings(p, **d) % 2 == 1).count() >= 2
|
||||
}
|
||||
|
||||
/// Whether the surfaces of two meshes pass through each other by more than `tol`: some
|
||||
/// triangle of one has corners more than `tol` to both sides of a triangle of the other,
|
||||
/// and the reverse, and they share a stretch of the line where their planes meet longer
|
||||
/// than `tol`. Faces that only touch, lie on each other or stop on the other surface don't
|
||||
/// count, nor do two facetings of one curved surface that are within `tol` of it. Bodies
|
||||
/// can still overlap without crossing: one inside the other, or sharing a slab between
|
||||
/// faces that are parallel or in one plane.
|
||||
pub fn crosses(&self, o: &Mesh, tol: f64) -> bool {
|
||||
if !tol.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let tol = tol.max(0.0);
|
||||
let (ba, bb) = (self.bounds(), o.bounds());
|
||||
let region = Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) };
|
||||
if region.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let near = |m: &Mesh| -> Vec<([Vec3; 3], Aabb3)> {
|
||||
m.triangles
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let t = m.tri(t)?;
|
||||
let mut bx = Aabb3::EMPTY;
|
||||
t.iter().for_each(|p| bx.add(*p));
|
||||
(!bx.is_empty() && boxes_meet(&bx, ®ion)).then_some((t, bx))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (ta, tb) = (near(self), near(o));
|
||||
if ta.is_empty() || tb.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// A grid over the shared box holds the other mesh's triangles.
|
||||
let n = ((tb.len() as f64).cbrt().ceil() as usize).clamp(1, 24);
|
||||
let size = region.size();
|
||||
let cell = |v: f64, lo: f64, s: f64| -> usize { if s > 0.0 { (((v - lo) / s * n as f64).floor().max(0.0) as usize).min(n - 1) } else { 0 } };
|
||||
let span = |bx: &Aabb3| -> [(usize, usize); 3] {
|
||||
[
|
||||
(cell(bx.min.x, region.min.x, size.x), cell(bx.max.x, region.min.x, size.x)),
|
||||
(cell(bx.min.y, region.min.y, size.y), cell(bx.max.y, region.min.y, size.y)),
|
||||
(cell(bx.min.z, region.min.z, size.z), cell(bx.max.z, region.min.z, size.z)),
|
||||
]
|
||||
};
|
||||
let mut cells: Vec<Vec<u32>> = vec![Vec::new(); n * n * n];
|
||||
for (i, (_, bx)) in tb.iter().enumerate() {
|
||||
let [x, y, z] = span(bx);
|
||||
for ix in x.0..=x.1 {
|
||||
for iy in y.0..=y.1 {
|
||||
for iz in z.0..=z.1 {
|
||||
if let Some(c) = cells.get_mut((ix * n + iy) * n + iz) {
|
||||
c.push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Per triangle of `o`: the last triangle of `self` tested against it, plus one.
|
||||
let mut seen = vec![0usize; tb.len()];
|
||||
for (i, (t, bx)) in ta.iter().enumerate() {
|
||||
let [x, y, z] = span(bx);
|
||||
for ix in x.0..=x.1 {
|
||||
for iy in y.0..=y.1 {
|
||||
for iz in z.0..=z.1 {
|
||||
for j in cells.get((ix * n + iy) * n + iz).into_iter().flatten() {
|
||||
let (Some(mark), Some((u, ub))) = (seen.get_mut(*j as usize), tb.get(*j as usize)) else { continue };
|
||||
if *mark == i + 1 {
|
||||
continue;
|
||||
}
|
||||
*mark = i + 1;
|
||||
if boxes_meet(bx, ub) && triangles_cross(t, u, tol) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Closest intersection of a ray with the mesh: (distance along `dir`, triangle index).
|
||||
pub fn raycast(&self, o: Vec3, dir: Vec3) -> Option<(f64, usize)> {
|
||||
let mut best: Option<(f64, usize)> = None;
|
||||
|
||||
@@ -233,3 +233,55 @@ fn split_at_and_closest_param() {
|
||||
assert!(l.end().dist(q) < 1e-12 && r.start().dist(q) < 1e-12);
|
||||
assert!((quarter.closest_param(Vec2::new(20.0, 20.0)) - 0.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// An axis-aligned box as a closed mesh, with `n` x `n` quads on each side.
|
||||
fn box_mesh(min: Vec3, max: Vec3, n: usize) -> Mesh {
|
||||
let mut m = Mesh::default();
|
||||
let s = max - min;
|
||||
let at = |c: [f64; 3]| min + Vec3::new(s.x * c[0], s.y * c[1], s.z * c[2]);
|
||||
for axis in 0..3 {
|
||||
for side in [0.0, 1.0] {
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let corner = |a: usize, b: usize| {
|
||||
let mut c = [0.0; 3];
|
||||
c[axis] = side;
|
||||
c[(axis + 1) % 3] = a as f64 / n as f64;
|
||||
c[(axis + 2) % 3] = b as f64 / n as f64;
|
||||
at(c)
|
||||
};
|
||||
let k = m.positions.len() as u32;
|
||||
m.positions.extend([corner(i, j), corner(i + 1, j), corner(i + 1, j + 1), corner(i, j + 1)]);
|
||||
m.triangles.extend(if side > 0.0 { [[k, k + 1, k + 2], [k, k + 2, k + 3]] } else { [[k, k + 2, k + 1], [k, k + 3, k + 2]] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meshes_cross_only_where_their_surfaces_pass_through_each_other() {
|
||||
let cube = |x: f64, y: f64, n: usize| box_mesh(Vec3::new(x, y, 0.0), Vec3::new(x + 10.0, y + 10.0, 10.0), n);
|
||||
let a = cube(0.0, 0.0, 1);
|
||||
// A corner 0.15 deep in the other cube (0.02 % of its volume), and a deep overlap.
|
||||
for (x, y, n) in [(9.85, 3.0, 1), (9.85, 3.0, 7), (9.85, 9.85, 2), (5.0, 5.0, 3)] {
|
||||
assert!(a.crosses(&cube(x, y, n), 0.01), "{x} {y} {n}");
|
||||
assert!(cube(x, y, n).crosses(&a, 0.01), "{x} {y} {n}");
|
||||
}
|
||||
// Apart, touching on a face or an edge, and the same body.
|
||||
for (x, y, n) in [(10.5, 0.0, 1), (10.0, 0.0, 1), (10.0, 3.0, 5), (10.0, 10.0, 2), (0.0, 0.0, 1), (0.0, 0.0, 4)] {
|
||||
assert!(!a.crosses(&cube(x, y, n), 0.01), "{x} {y} {n}");
|
||||
}
|
||||
// One inside the other, standing on a face, and sharing a face plane from inside.
|
||||
let inner = box_mesh(Vec3::new(2.0, 2.0, 2.0), Vec3::new(8.0, 8.0, 8.0), 2);
|
||||
let on_top = box_mesh(Vec3::new(2.0, 2.0, 10.0), Vec3::new(8.0, 8.0, 14.0), 2);
|
||||
let flush = box_mesh(Vec3::new(2.0, 2.0, 4.0), Vec3::new(8.0, 8.0, 10.0), 2);
|
||||
assert!(!a.crosses(&inner, 0.01) && !inner.crosses(&a, 0.01));
|
||||
assert!(!a.crosses(&on_top, 0.01) && !a.crosses(&flush, 0.01));
|
||||
// A slab overlap between faces that are parallel or in one plane has no crossing.
|
||||
assert!(!a.crosses(&cube(9.85, 0.0, 1), 0.01));
|
||||
// Passing through by less than the tolerance doesn't count.
|
||||
assert!(!a.crosses(&cube(9.995, 3.0, 1), 0.01));
|
||||
assert!(!a.crosses(&cube(9.85, 3.0, 1), f64::NAN));
|
||||
}
|
||||
+286
-7
@@ -2,6 +2,12 @@
|
||||
//! evaluated cache is trusted here. Entries are never extracted to filesystem paths.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
mod native_cache;
|
||||
mod thumbnail;
|
||||
pub use native_cache::{DesignGeometry, read_design_geometry};
|
||||
pub use thumbnail::{THUMBNAIL_SIZE, read_design_thumbnail, read_design_thumbnail_file};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -96,6 +102,29 @@ pub fn write_design_container_with_compression(doc: &Document, compression: Desi
|
||||
|
||||
/// Optional display cache failure never prevents saving the authoritative design.
|
||||
pub fn write_design_container_with_preview(doc: &Document, compression: DesignCompression, preview: Option<&DesignPreview>) -> Result<Vec<u8>> {
|
||||
write_design_container_with_cache(doc, compression, preview, None)
|
||||
}
|
||||
|
||||
/// Save an optional warm native cache without making document saving depend on it.
|
||||
/// Display meshes remain separately opt-in; no evaluation or meshing happens here.
|
||||
pub fn write_design_container_with_cache(
|
||||
doc: &Document,
|
||||
compression: DesignCompression,
|
||||
preview: Option<&DesignPreview>,
|
||||
geometry: Option<&[DesignGeometry]>,
|
||||
) -> Result<Vec<u8>> {
|
||||
write_design_container_with_thumbnail(doc, compression, preview, geometry, None)
|
||||
}
|
||||
|
||||
/// Include a bounded portable PNG picture when one is already available.
|
||||
/// Unsupported headers or oversized pictures are omitted without changing the design.
|
||||
pub fn write_design_container_with_thumbnail(
|
||||
doc: &Document,
|
||||
compression: DesignCompression,
|
||||
preview: Option<&DesignPreview>,
|
||||
geometry: Option<&[DesignGeometry]>,
|
||||
thumbnail: Option<&[u8]>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut writer = PackageWriter { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0 };
|
||||
let mut features = Vec::with_capacity(doc.features.len());
|
||||
for feature in &doc.features {
|
||||
@@ -143,6 +172,19 @@ pub fn write_design_container_with_preview(doc: &Document, compression: DesignCo
|
||||
let metadata = serde_json::to_vec(&metadata).map_err(invalid)?;
|
||||
writer.part("document.json", &metadata, true)?;
|
||||
writer.part("container.json", &serde_json::to_vec(&json!({"format": VERSION})).map_err(invalid)?, false)?;
|
||||
if let Some((manifest, data)) = geometry.and_then(|g| native_cache::encode_geometry(g, &metadata).ok())
|
||||
&& writer.names.len().saturating_add(2) <= MAX_ENTRIES
|
||||
&& writer
|
||||
.expanded
|
||||
.saturating_add(manifest.len())
|
||||
.saturating_add(data.len())
|
||||
.saturating_add(writer.names.len().saturating_mul(256))
|
||||
.saturating_add(65_536)
|
||||
<= MAX_DESIGN_BYTES
|
||||
{
|
||||
writer.part("cache/key.json", &manifest, false)?;
|
||||
writer.part("cache/brep.bin", &data, compression == DesignCompression::Compact)?;
|
||||
}
|
||||
if let Some((manifest, data)) = preview.and_then(|p| encode_preview(p, &metadata).ok())
|
||||
&& writer.names.len().saturating_add(2) <= MAX_ENTRIES
|
||||
&& writer
|
||||
@@ -156,6 +198,9 @@ pub fn write_design_container_with_preview(doc: &Document, compression: DesignCo
|
||||
writer.part("cache/preview.json", &manifest, false)?;
|
||||
writer.part("cache/preview.bin", &data, false)?;
|
||||
}
|
||||
if let Some(png) = thumbnail {
|
||||
thumbnail::write(&mut writer, png, &metadata)?;
|
||||
}
|
||||
let bytes = writer.zip.finish().map_err(invalid)?.into_inner();
|
||||
if bytes.len() > MAX_DESIGN_BYTES {
|
||||
return Err(invalid("compressed file too large"));
|
||||
@@ -223,6 +268,7 @@ struct PackageReader<R: Read + Seek> {
|
||||
zip: zip::ZipArchive<R>,
|
||||
blobs: BTreeMap<String, Vec<u8>>,
|
||||
hydrated: usize,
|
||||
document: Option<Arc<[u8]>>,
|
||||
}
|
||||
|
||||
impl<R: Read + Seek> PackageReader<R> {
|
||||
@@ -274,7 +320,15 @@ impl<R: Read + Seek> PackageReader<R> {
|
||||
return Err(invalid("expanded package too large"));
|
||||
}
|
||||
}
|
||||
Ok(Self { zip, blobs: BTreeMap::new(), hydrated: 0 })
|
||||
Ok(Self { zip, blobs: BTreeMap::new(), hydrated: 0, document: None })
|
||||
}
|
||||
// Bind provisional caches and authoritative reconstruction to identical metadata,
|
||||
// even if another process overwrites the open file in place. Blobs remain hash-checked.
|
||||
fn document(&mut self, limit: usize) -> Result<Arc<[u8]>> {
|
||||
if self.document.is_none() {
|
||||
self.document = Some(self.limited_part("document.json", limit)?.into());
|
||||
}
|
||||
self.document.as_ref().filter(|d| d.len() <= limit).cloned().ok_or_else(|| invalid("metadata budget"))
|
||||
}
|
||||
fn part(&mut self, name: &str) -> Result<Vec<u8>> {
|
||||
self.limited_part(name, MAX_DESIGN_BYTES)
|
||||
@@ -363,7 +417,7 @@ fn read_preview<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<DesignP
|
||||
{
|
||||
return Err(invalid("incompatible preview"));
|
||||
}
|
||||
if manifest.get("document").and_then(Value::as_str) != Some(digest(&reader.part("document.json")?).as_str()) {
|
||||
if manifest.get("document").and_then(Value::as_str) != Some(digest(&reader.document(16 << 20)?).as_str()) {
|
||||
return Err(invalid("stale preview"));
|
||||
}
|
||||
let data = reader.limited_part("cache/preview.bin", MAX_PREVIEW_BYTES)?;
|
||||
@@ -404,12 +458,13 @@ fn read_preview<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<DesignP
|
||||
Ok(preview)
|
||||
}
|
||||
|
||||
/// A bounded container reader over an immutable, seekable source.
|
||||
/// A bounded container reader over a seekable source.
|
||||
///
|
||||
/// Opening inspects the directory, not the source blobs. `preview` reads only the
|
||||
/// optional display entries and document metadata; `read_document` then resolves
|
||||
/// and validates authoritative blobs. Keep the source unchanged for this reader's
|
||||
/// lifetime. This type does not turn untrusted cached data into editable geometry.
|
||||
/// and validates authoritative blobs against the same retained metadata snapshot.
|
||||
/// Concurrent source changes either retain that document or fail validation.
|
||||
/// This type does not turn untrusted cached data into editable geometry.
|
||||
pub struct DesignContainerReader<R: Read + Seek> {
|
||||
reader: PackageReader<R>,
|
||||
}
|
||||
@@ -422,7 +477,20 @@ impl<R: Read + Seek> DesignContainerReader<R> {
|
||||
|
||||
/// Optional display-only data. Corruption or incompatibility is a cache miss.
|
||||
pub fn preview(&mut self) -> Option<DesignPreview> {
|
||||
read_preview(&mut self.reader).ok()
|
||||
// An explicitly saved mesh avoids tessellation. Native cache is the default
|
||||
// fallback, generating a bounded provisional mesh without source imports.
|
||||
read_preview(&mut self.reader).ok().or_else(|| native_cache::geometry_preview(self.geometry()?).ok())
|
||||
}
|
||||
|
||||
/// Portable PNG picture only; no source blobs or geometry are evaluated.
|
||||
pub fn thumbnail(&mut self) -> Option<Vec<u8>> {
|
||||
thumbnail::read(&mut self.reader).ok()
|
||||
}
|
||||
|
||||
/// Native geometry is provisional, never a substitute for rebuilding the document.
|
||||
pub fn geometry(&mut self) -> Option<Vec<DesignGeometry>> {
|
||||
let document = self.reader.document(16 << 20).ok()?;
|
||||
native_cache::read_geometry_with(&mut self.reader, &document).ok()
|
||||
}
|
||||
|
||||
/// Consume the reader, resolving every required blob before returning a document.
|
||||
@@ -435,12 +503,58 @@ pub(super) fn read(bytes: &[u8]) -> Result<Document> {
|
||||
DesignContainerReader::new(Cursor::new(bytes))?.read_document()
|
||||
}
|
||||
|
||||
/// Open from disk (or browser VFS), publishing only provisional display before
|
||||
/// authoritative blob hydration. Desktop keeps one seekable file handle instead
|
||||
/// of buffering the complete archive. JSON continues through the normal migrations.
|
||||
pub fn read_design_file(path: &str, notify: Option<impl FnOnce(DesignPreview)>) -> Result<Document> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let mut source = {
|
||||
let metadata = std::fs::metadata(path).map_err(invalid)?;
|
||||
if !metadata.is_file() {
|
||||
return Err(invalid("not a regular file"));
|
||||
}
|
||||
if metadata.len() > MAX_DESIGN_BYTES as u64 {
|
||||
return Err(invalid("file too large"));
|
||||
}
|
||||
let file = std::fs::File::open(path).map_err(invalid)?;
|
||||
if !file.metadata().map_err(invalid)?.is_file() {
|
||||
return Err(invalid("not a regular file"));
|
||||
}
|
||||
file
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let mut source = Cursor::new(crate::vfs::read(path, MAX_DESIGN_BYTES).map_err(invalid)?);
|
||||
let size = source.seek(SeekFrom::End(0)).map_err(invalid)?;
|
||||
if size > MAX_DESIGN_BYTES as u64 {
|
||||
return Err(invalid("file too large"));
|
||||
}
|
||||
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
|
||||
let mut prefix = [0u8; 4];
|
||||
if size >= 4 {
|
||||
source.read_exact(&mut prefix).map_err(invalid)?;
|
||||
}
|
||||
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
|
||||
if is_design_container(&prefix) {
|
||||
let mut reader = DesignContainerReader::new(source)?;
|
||||
if let Some(notify) = notify
|
||||
&& let Some(preview) = reader.preview()
|
||||
{
|
||||
notify(preview);
|
||||
}
|
||||
reader.read_document()
|
||||
} else {
|
||||
let mut bytes = Vec::new();
|
||||
source.take(MAX_DESIGN_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
|
||||
crate::read_design(&bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Document> {
|
||||
let version: Value = serde_json::from_slice(&reader.part("container.json")?).map_err(invalid)?;
|
||||
if !matches!(version.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
|
||||
return Err(invalid("unsupported container version"));
|
||||
}
|
||||
let bytes = reader.part("document.json")?;
|
||||
let bytes = reader.document(MAX_DESIGN_BYTES)?;
|
||||
let metadata: Value = serde_json::from_slice(&bytes).map_err(invalid)?;
|
||||
let text = std::str::from_utf8(&bytes).map_err(invalid)?;
|
||||
// Migrations and all document validation run on the small parametric metadata.
|
||||
@@ -565,6 +679,124 @@ mod tests {
|
||||
assert!(DesignContainerReader::new(Cursor::new(vec![0u8; 1])).is_err());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn file_reader_refuses_nonfiles_before_notifying_preview() {
|
||||
let directory = std::env::temp_dir();
|
||||
let result = read_design_file(directory.to_str().unwrap(), Some(|_| panic!("non-file supplied a preview")));
|
||||
assert!(result.is_err_and(|e| e.to_string().contains("not a regular file")));
|
||||
}
|
||||
|
||||
fn native_geometry() -> Vec<DesignGeometry> {
|
||||
vec![DesignGeometry {
|
||||
name: "Cube".into(),
|
||||
body: solidcraft_kernel::box_solid(solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::new(10., 20., 30.)).unwrap(),
|
||||
}]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_cache_round_trip_and_preview_defer_original_sources() {
|
||||
let mut doc = Document::new("Native cache");
|
||||
let step = "ISO-10303-21;".repeat(200_000);
|
||||
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone(), components: Vec::new() }, None).unwrap();
|
||||
let bodies = native_geometry();
|
||||
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&bodies)).unwrap();
|
||||
let entries = parts(&bytes);
|
||||
assert!(entries.contains_key("cache/brep.bin"));
|
||||
assert!(!entries.contains_key("cache/preview.bin"));
|
||||
assert_eq!(read_design_geometry(&bytes).unwrap()[0].body.face_count(), bodies[0].body.face_count());
|
||||
assert_eq!(bytes, write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&bodies)).unwrap());
|
||||
let count = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let mut reader = DesignContainerReader::new(CountingReader { source: Cursor::new(bytes), count: count.clone() }).unwrap();
|
||||
assert!(!reader.preview().unwrap().meshes.is_empty());
|
||||
assert!(count.get() < 256 * 1024, "read {} bytes before native display", count.get());
|
||||
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
|
||||
assert!(count.get() >= step.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_cache_mismatch_is_only_a_cache_miss() {
|
||||
let doc = document();
|
||||
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&native_geometry())).unwrap();
|
||||
let original = parts(&bytes);
|
||||
for key in ["format", "producer", "app", "document", "payload"] {
|
||||
let mut entries = original.clone();
|
||||
let mut manifest: Value = serde_json::from_slice(&entries["cache/key.json"]).unwrap();
|
||||
manifest[key] = json!("unsupported-or-stale");
|
||||
entries.insert("cache/key.json".into(), serde_json::to_vec(&manifest).unwrap());
|
||||
let bytes = package(&entries);
|
||||
assert!(read_design_geometry(&bytes).is_none());
|
||||
assert!(read_design_preview(&bytes).is_none());
|
||||
assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json());
|
||||
}
|
||||
let mut entries = original;
|
||||
entries.get_mut("cache/brep.bin").unwrap()[0] ^= 1;
|
||||
let bytes = package(&entries);
|
||||
assert!(read_design_geometry(&bytes).is_none());
|
||||
assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_snapshot_survives_in_place_changes_after_preview() {
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
struct Shared {
|
||||
bytes: Rc<RefCell<Vec<u8>>>,
|
||||
position: u64,
|
||||
}
|
||||
impl Read for Shared {
|
||||
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
|
||||
let bytes = self.bytes.borrow();
|
||||
let mut cursor = Cursor::new(bytes.as_slice());
|
||||
cursor.set_position(self.position);
|
||||
let n = cursor.read(out)?;
|
||||
self.position = cursor.position();
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
impl Seek for Shared {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
let bytes = self.bytes.borrow();
|
||||
let mut cursor = Cursor::new(bytes.as_slice());
|
||||
cursor.set_position(self.position);
|
||||
self.position = cursor.seek(pos)?;
|
||||
Ok(self.position)
|
||||
}
|
||||
}
|
||||
let doc = document();
|
||||
let preview = DesignPreview { meshes: vec![mesh()] };
|
||||
let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
|
||||
let offset = zip::ZipArchive::new(Cursor::new(&bytes)).unwrap().by_name("document.json").unwrap().data_start() as usize;
|
||||
let bytes = Rc::new(RefCell::new(bytes));
|
||||
let mut reader = DesignContainerReader::new(Shared { bytes: bytes.clone(), position: 0 }).unwrap();
|
||||
assert!(reader.preview().is_some());
|
||||
bytes.borrow_mut()[offset] ^= 0xff;
|
||||
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
|
||||
assert!(crate::read_design(&bytes.borrow()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_cache_prefixes_and_mutations_never_panic() {
|
||||
let bytes = write_design_container_with_cache(&document(), DesignCompression::Fast, None, Some(&native_geometry())).unwrap();
|
||||
for n in (0..bytes.len()).step_by((bytes.len() / 128).max(1)) {
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| {
|
||||
let _ = read_design_preview(&bytes[..n]);
|
||||
let _ = read_design_geometry(&bytes[..n]);
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
let mut changed = bytes.clone();
|
||||
changed[n] ^= 0xff;
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| {
|
||||
let _ = read_design_preview(&changed);
|
||||
let _ = read_design_geometry(&changed);
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn mesh() -> MeshData {
|
||||
MeshData {
|
||||
name: "Precise mesh".into(),
|
||||
@@ -627,6 +859,53 @@ mod tests {
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_thumbnail_is_keyed_bounded_and_does_not_read_sources() {
|
||||
// IO validates an opaque PNG header/hash; UI tests exercise real PNG decoding.
|
||||
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
|
||||
png.extend(240u32.to_be_bytes());
|
||||
png.extend(150u32.to_be_bytes());
|
||||
let mut doc = Document::from_json(&document().to_json()).unwrap();
|
||||
if let FeatureKind::Import { step, .. } = &mut doc.features[0].kind {
|
||||
*step = "source data".repeat(250_000);
|
||||
}
|
||||
let bytes = write_design_container_with_thumbnail(&doc, DesignCompression::Fast, None, None, Some(&png)).unwrap();
|
||||
let count = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let mut reader = DesignContainerReader::new(CountingReader { source: Cursor::new(bytes.clone()), count: count.clone() }).unwrap();
|
||||
assert_eq!(reader.thumbnail(), Some(png.clone()));
|
||||
assert!(count.get() < 256 * 1024);
|
||||
assert_eq!(reader.read_document().unwrap(), doc);
|
||||
let original = parts(&bytes);
|
||||
for field in ["format", "app", "document", "payload"] {
|
||||
let mut entries = original.clone();
|
||||
let mut key: Value = serde_json::from_slice(&entries["cache/thumbnail.json"]).unwrap();
|
||||
key[field] = json!("mismatch");
|
||||
entries.insert("cache/thumbnail.json".into(), serde_json::to_vec(&key).unwrap());
|
||||
let changed = package(&entries);
|
||||
assert!(read_design_thumbnail(&changed).is_none());
|
||||
assert_eq!(crate::read_design(&changed).unwrap(), doc);
|
||||
}
|
||||
let mut entries = original.clone();
|
||||
let source = entries.keys().find(|k| k.ends_with(".step")).unwrap().clone();
|
||||
entries.insert(source, b"corrupt source".to_vec());
|
||||
let changed = package(&entries);
|
||||
assert_eq!(read_design_thumbnail(&changed), Some(png));
|
||||
assert!(crate::read_design(&changed).is_err());
|
||||
for n in [0, 1, 8, bytes.len() / 2, bytes.len() - 1] {
|
||||
assert!(read_design_thumbnail(&bytes[..n]).is_none());
|
||||
}
|
||||
for i in (0..bytes.len()).step_by((bytes.len() / 128).max(1)) {
|
||||
let mut changed = bytes.clone();
|
||||
changed[i] ^= 0xff;
|
||||
assert!(std::panic::catch_unwind(|| read_design_thumbnail(&changed)).is_ok());
|
||||
}
|
||||
for invalid in [vec![], vec![0; 300_000]] {
|
||||
let bytes = write_design_container_with_thumbnail(&doc, DesignCompression::Fast, None, None, Some(&invalid)).unwrap();
|
||||
assert!(read_design_thumbnail(&bytes).is_none());
|
||||
assert_eq!(crate::read_design(&bytes).unwrap(), doc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_preview_is_bounded_keyed_and_never_authoritative() {
|
||||
let doc = Document::from_json(&document().to_json()).unwrap();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Optional native cache. Its SolidCraft schema is independent of the producing kernel.
|
||||
use super::*;
|
||||
const PRODUCER: &str = "solidcraft-truck-adapter/2";
|
||||
/// Provisional evaluated geometry. Never an editable document state.
|
||||
#[derive(Clone)]
|
||||
pub struct DesignGeometry {
|
||||
pub name: String,
|
||||
pub body: solidcraft_kernel::Body,
|
||||
}
|
||||
pub(super) fn encode_geometry(bodies: &[DesignGeometry], document: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
|
||||
if bodies.is_empty() || bodies.len() > 4096 {
|
||||
return Err(invalid("native body count"));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
let mut entries = Vec::new();
|
||||
let mut faces = 0usize;
|
||||
for entry in bodies {
|
||||
faces = faces.saturating_add(entry.body.face_count());
|
||||
if faces > 100_000 || entry.name.len() > 1024 {
|
||||
return Err(invalid("native geometry budget"));
|
||||
}
|
||||
let bytes = solidcraft_kernel::encode_native_body(&entry.body).map_err(invalid)?;
|
||||
let offset = data.len();
|
||||
if offset.saturating_add(bytes.len()) > 64 << 20 {
|
||||
return Err(invalid("native cache size"));
|
||||
}
|
||||
entries.push(json!({"name":entry.name,"offset":offset,"length":bytes.len()}));
|
||||
data.extend(bytes);
|
||||
}
|
||||
let manifest = serde_json::to_vec(&json!({
|
||||
"format":"solidcraft-brep-cache/1", "producer":PRODUCER,
|
||||
"app":env!("CARGO_PKG_VERSION"), "document":digest(document), "payload":digest(&data), "bodies":entries
|
||||
}))
|
||||
.map_err(invalid)?;
|
||||
if manifest.len() > 1 << 20 {
|
||||
return Err(invalid("native manifest size"));
|
||||
}
|
||||
Ok((manifest, data))
|
||||
}
|
||||
|
||||
/// Read only metadata and the native cache. Original blobs are not read/decompressed
|
||||
/// until the authoritative document reader needs them for background reconstruction.
|
||||
/// Any optional geometry failure is a miss, never a document error.
|
||||
pub fn read_design_geometry(bytes: &[u8]) -> Option<Vec<DesignGeometry>> {
|
||||
if bytes.len() > MAX_DESIGN_BYTES || !is_design_container(bytes) {
|
||||
return None;
|
||||
}
|
||||
DesignContainerReader::new(Cursor::new(bytes)).ok()?.geometry()
|
||||
}
|
||||
pub(super) fn read_geometry_with<R: Read + Seek>(reader: &mut PackageReader<R>, document: &[u8]) -> Result<Vec<DesignGeometry>> {
|
||||
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
|
||||
if container.get("format").and_then(Value::as_str) != Some(VERSION) {
|
||||
return Err(invalid("container version"));
|
||||
}
|
||||
let manifest: Value = serde_json::from_slice(&reader.limited_part("cache/key.json", 1 << 20)?).map_err(invalid)?;
|
||||
if manifest.get("format").and_then(Value::as_str) != Some("solidcraft-brep-cache/1")
|
||||
|| manifest.get("producer").and_then(Value::as_str) != Some(PRODUCER)
|
||||
|| manifest.get("app").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
|
||||
{
|
||||
return Err(invalid("native cache version"));
|
||||
}
|
||||
|
||||
if manifest.get("document").and_then(Value::as_str) != Some(digest(document).as_str()) {
|
||||
return Err(invalid("native document key"));
|
||||
}
|
||||
// Validate the small parametric metadata before showing a provisional model.
|
||||
Document::from_json(std::str::from_utf8(document).map_err(invalid)?)?;
|
||||
let data = reader.limited_part("cache/brep.bin", 64 << 20)?;
|
||||
if manifest.get("payload").and_then(Value::as_str) != Some(digest(&data).as_str()) {
|
||||
return Err(invalid("native payload key"));
|
||||
}
|
||||
let entries =
|
||||
manifest.get("bodies").and_then(Value::as_array).filter(|b| !b.is_empty() && b.len() <= 4096).ok_or_else(|| invalid("native bodies"))?;
|
||||
let mut bodies = Vec::new();
|
||||
let mut end = 0usize;
|
||||
let mut faces = 0usize;
|
||||
for entry in entries {
|
||||
let n = |key| entry.get(key).and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or_else(|| invalid("native slice"));
|
||||
let offset = n("offset")?;
|
||||
let length = n("length")?;
|
||||
if offset != end {
|
||||
return Err(invalid("overlapping native bodies"));
|
||||
}
|
||||
end = end.checked_add(length).ok_or_else(|| invalid("native length overflow"))?;
|
||||
let name = entry.get("name").and_then(Value::as_str).filter(|n| n.len() <= 1024).ok_or_else(|| invalid("native body name"))?.to_owned();
|
||||
let body = solidcraft_kernel::decode_native_body(data.get(offset..end).ok_or_else(|| invalid("truncated native body"))?).map_err(invalid)?;
|
||||
faces = faces.saturating_add(body.face_count());
|
||||
if faces > 100_000 {
|
||||
return Err(invalid("native face budget"));
|
||||
}
|
||||
bodies.push(DesignGeometry { name, body });
|
||||
}
|
||||
if end != data.len() {
|
||||
return Err(invalid("trailing native data"));
|
||||
}
|
||||
Ok(bodies)
|
||||
}
|
||||
pub(super) fn geometry_preview(bodies: Vec<DesignGeometry>) -> Result<DesignPreview> {
|
||||
let mut preview = DesignPreview::default();
|
||||
let mut triangles = 0usize;
|
||||
let mut display_bytes = 0usize;
|
||||
let faces: usize = bodies.iter().map(|b| b.body.face_count()).sum();
|
||||
let detail = if faces > 20_000 {
|
||||
1
|
||||
} else if faces > 4_000 {
|
||||
4
|
||||
} else {
|
||||
8
|
||||
};
|
||||
let mut work = 1_000_000usize;
|
||||
for entry in bodies {
|
||||
let mesh = solidcraft_kernel::native_preview_mesh(&entry.body, detail, &mut work).map_err(invalid)?;
|
||||
triangles = triangles.saturating_add(mesh.triangles.len());
|
||||
display_bytes = display_bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(12));
|
||||
if triangles > 1_000_000 || display_bytes > 128 << 20 {
|
||||
return Err(invalid("regenerated display budget"));
|
||||
}
|
||||
let displayed = MeshData {
|
||||
name: entry.name,
|
||||
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
|
||||
triangles: mesh.triangles.iter().flatten().copied().collect(),
|
||||
color: entry.body.color(),
|
||||
};
|
||||
validate_mesh(&displayed)?;
|
||||
preview.meshes.push(displayed);
|
||||
}
|
||||
Ok(preview)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Portable, disposable PNG pictures. Reading never resolves source blobs.
|
||||
use super::*;
|
||||
pub const THUMBNAIL_SIZE: [usize; 2] = [240, 150];
|
||||
const MAX_BYTES: usize = 256 << 10;
|
||||
const FORMAT: &str = "solidcraft-thumbnail/1";
|
||||
|
||||
fn valid_header(bytes: &[u8]) -> bool {
|
||||
bytes.len() <= MAX_BYTES
|
||||
&& bytes.get(..8) == Some(b"\x89PNG\r\n\x1a\n")
|
||||
&& bytes.get(8..16) == Some(b"\0\0\0\rIHDR")
|
||||
&& bytes.get(16..20) == Some((THUMBNAIL_SIZE[0] as u32).to_be_bytes().as_slice())
|
||||
&& bytes.get(20..24) == Some((THUMBNAIL_SIZE[1] as u32).to_be_bytes().as_slice())
|
||||
}
|
||||
pub(super) fn write(writer: &mut PackageWriter, png: &[u8], document: &[u8]) -> Result<()> {
|
||||
if !valid_header(png) {
|
||||
return Ok(());
|
||||
}
|
||||
let key = serde_json::to_vec(&json!({"format": FORMAT, "app": env!("CARGO_PKG_VERSION"), "document": digest(document), "payload": digest(png)}))
|
||||
.map_err(invalid)?;
|
||||
if writer.names.len().saturating_add(2) > MAX_ENTRIES
|
||||
|| writer
|
||||
.expanded
|
||||
.saturating_add(key.len())
|
||||
.saturating_add(png.len())
|
||||
.saturating_add(writer.names.len().saturating_mul(256))
|
||||
.saturating_add(65_536)
|
||||
> MAX_DESIGN_BYTES
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
writer.part("cache/thumbnail.json", &key, false)?;
|
||||
writer.part("cache/thumbnail.png", png, false)
|
||||
}
|
||||
pub(super) fn read<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Vec<u8>> {
|
||||
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
|
||||
if container.get("format").and_then(Value::as_str) != Some(VERSION) {
|
||||
return Err(invalid("thumbnail container version"));
|
||||
}
|
||||
let key: Value = serde_json::from_slice(&reader.limited_part("cache/thumbnail.json", 4096)?).map_err(invalid)?;
|
||||
let document = reader.document(16 << 20)?;
|
||||
if key.get("format").and_then(Value::as_str) != Some(FORMAT)
|
||||
|| key.get("app").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
|
||||
|| key.get("document").and_then(Value::as_str) != Some(digest(&document).as_str())
|
||||
{
|
||||
return Err(invalid("thumbnail key"));
|
||||
}
|
||||
let png = reader.limited_part("cache/thumbnail.png", MAX_BYTES)?;
|
||||
if !valid_header(&png) || key.get("payload").and_then(Value::as_str) != Some(digest(&png).as_str()) {
|
||||
return Err(invalid("thumbnail payload"));
|
||||
}
|
||||
// Consumers must also use a bounded PNG decoder; the header is not a decode.
|
||||
Ok(png)
|
||||
}
|
||||
/// Optional PNG bytes, bounded and keyed to this exact document. Consumers must
|
||||
/// decode with dimension/allocation limits; invalid pictures are cache misses.
|
||||
pub fn read_design_thumbnail(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
DesignContainerReader::new(Cursor::new(bytes)).ok()?.thumbnail()
|
||||
}
|
||||
/// Read a portable picture without importing or reading the source blobs.
|
||||
pub fn read_design_thumbnail_file(path: &str) -> Option<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let source = {
|
||||
let metadata = std::fs::metadata(path).ok()?;
|
||||
if !metadata.is_file() || metadata.len() > MAX_DESIGN_BYTES as u64 {
|
||||
return None;
|
||||
}
|
||||
let source = std::fs::File::open(path).ok()?;
|
||||
let metadata = source.metadata().ok()?;
|
||||
if !metadata.is_file() || metadata.len() > MAX_DESIGN_BYTES as u64 {
|
||||
return None;
|
||||
}
|
||||
source
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let source = Cursor::new(crate::vfs::read(path, MAX_DESIGN_BYTES).ok()?);
|
||||
DesignContainerReader::new(source).ok()?.thumbnail()
|
||||
}
|
||||
@@ -3,7 +3,12 @@
|
||||
//! the legacy folder is copied over. The legacy folder is never changed or deleted, so an older
|
||||
//! release installed side by side keeps working.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The Linux app id (Flatpak, desktop entry, AppStream, Wayland `app_id`).
|
||||
pub const APP_ID: &str = "ai.storyteller.solidcraft";
|
||||
/// The app id of releases before 0.8 (compatibility shim, docs/compatibility.md).
|
||||
pub const LEGACY_APP_ID: &str = "ai.storyteller.solvecraft";
|
||||
|
||||
/// Most files and nesting copied from a legacy folder (it holds preferences, autosaves and
|
||||
/// small caches; anything bigger is not ours).
|
||||
@@ -22,6 +27,25 @@ pub fn adopt_legacy_dir(current: &Path, legacy: &Path) -> std::io::Result<bool>
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// In the Flatpak sandbox, the same `path` under the pre-rename app's data folder: Flatpak keeps
|
||||
/// each app's XDG folders under `~/.var/app/<app id>`, so after the app id changed, settings and
|
||||
/// autosaves of earlier Flatpak releases sit under the old id. `None` outside the sandbox or for
|
||||
/// paths outside our own `~/.var/app` folder. Flatpak moves that folder itself when an install
|
||||
/// follows the end-of-life rebase; this covers installs made next to the old app, which reach it
|
||||
/// read-only through the manifest's `--filesystem` grant.
|
||||
pub fn legacy_flatpak_path(path: &Path) -> Option<PathBuf> {
|
||||
flatpak_twin(path, &std::env::var_os("FLATPAK_ID")?, Path::new(&std::env::var_os("HOME")?))
|
||||
}
|
||||
|
||||
fn flatpak_twin(path: &Path, flatpak_id: &std::ffi::OsStr, home: &Path) -> Option<PathBuf> {
|
||||
if flatpak_id != APP_ID {
|
||||
return None;
|
||||
}
|
||||
let apps = home.join(".var").join("app");
|
||||
let rel = path.strip_prefix(apps.join(APP_ID)).ok()?;
|
||||
Some(apps.join(LEGACY_APP_ID).join(rel))
|
||||
}
|
||||
|
||||
fn copy_tree(from: &Path, to: &Path, depth: usize, budget: &mut usize) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
if depth >= MAX_DEPTH {
|
||||
@@ -75,6 +99,17 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatpak_twin_maps_our_sandbox_data_to_the_old_app_id() {
|
||||
let home = Path::new("/home/u");
|
||||
let ours = home.join(".var/app/ai.storyteller.solidcraft/data/solidcraft");
|
||||
let id = std::ffi::OsStr::new(APP_ID);
|
||||
assert_eq!(flatpak_twin(&ours, id, home), Some(home.join(".var/app/ai.storyteller.solvecraft/data/solidcraft")));
|
||||
// Outside our sandbox folder, or in another app's sandbox, there is nothing to map.
|
||||
assert_eq!(flatpak_twin(&home.join(".local/share/solidcraft"), id, home), None);
|
||||
assert_eq!(flatpak_twin(&ours, std::ffi::OsStr::new("org.example.Other"), home), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_adopt() {
|
||||
let d = scratch("none");
|
||||
|
||||
@@ -10,8 +10,10 @@ mod design;
|
||||
mod design_metadata;
|
||||
mod dxf;
|
||||
pub use design::{
|
||||
DesignCompression, DesignContainerReader, DesignPreview, is_design_container, read_design_preview, write_design_container,
|
||||
write_design_container_with_compression, write_design_container_with_preview,
|
||||
DesignCompression, DesignContainerReader, DesignGeometry, DesignPreview, THUMBNAIL_SIZE, is_design_container, read_design_file,
|
||||
read_design_geometry, read_design_preview, read_design_thumbnail, read_design_thumbnail_file, write_design_container,
|
||||
write_design_container_with_cache, write_design_container_with_compression, write_design_container_with_preview,
|
||||
write_design_container_with_thumbnail,
|
||||
};
|
||||
mod legacy_dir;
|
||||
pub mod safe_write;
|
||||
@@ -21,7 +23,7 @@ mod threemf;
|
||||
pub mod vfs;
|
||||
|
||||
pub use dxf::{read_dxf, write_dxf};
|
||||
pub use legacy_dir::adopt_legacy_dir;
|
||||
pub use legacy_dir::{APP_ID, LEGACY_APP_ID, adopt_legacy_dir, legacy_flatpak_path};
|
||||
pub use safe_write::{backup_path, write_atomic};
|
||||
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
|
||||
pub use svg::read_svg;
|
||||
|
||||
@@ -114,7 +114,10 @@ fn attrs(e: &BytesStart) -> Vec<(String, String)> {
|
||||
.flatten()
|
||||
.map(|a| {
|
||||
let k = String::from_utf8_lossy(a.key.local_name().as_ref()).to_string();
|
||||
let v = a.unescape_value().map(|v| v.to_string()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
|
||||
let v = a
|
||||
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
|
||||
(k, v)
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -319,7 +319,10 @@ fn attrs(e: &BytesStart) -> HashMap<String, String> {
|
||||
let mut m = HashMap::new();
|
||||
for a in e.attributes().flatten() {
|
||||
let k = String::from_utf8_lossy(a.key.local_name().as_ref()).to_string();
|
||||
let v = a.unescape_value().map(|v| v.to_string()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
|
||||
let v = a
|
||||
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
|
||||
m.insert(k, v);
|
||||
}
|
||||
m
|
||||
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
solidcraft-geom = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
truck-modeling = { workspace = true }
|
||||
|
||||
+515
-43
@@ -198,6 +198,124 @@ pub(crate) fn extension_fits(face: &mt::Face, p: Vec3, q: Vec3, tol: f64) -> boo
|
||||
inside
|
||||
}
|
||||
|
||||
/// Parameters along `x + λ d` where the line meets `surf`, within `reach` of `x` (Newton on
|
||||
/// (u, v, λ) from several starts; nearest first).
|
||||
fn line_surface_hits(surf: &mt::Surface, x: Vec3, d: Vec3, reach: f64, tol: f64) -> Vec<f64> {
|
||||
use mt::{ParametricSurface, SearchNearestParameter};
|
||||
let mut out: Vec<f64> = Vec::new();
|
||||
for k in [0i32, 1, -1, 2, -2, 4, -4] {
|
||||
let mut l = reach * f64::from(k) / 4.0;
|
||||
let Some((mut u, mut v)) = surf.search_nearest_parameter(p3(x + d * l), None, 100) else { continue };
|
||||
for _ in 0..60 {
|
||||
let f = from_p3(surf.subs(u, v)) - (x + d * l);
|
||||
if !f.is_finite() {
|
||||
break;
|
||||
}
|
||||
if f.len() <= tol {
|
||||
if l.abs() <= reach && !out.iter().any(|o| (o - l).abs() <= tol * 10.0) {
|
||||
out.push(l);
|
||||
}
|
||||
break;
|
||||
}
|
||||
let (su, sv) = (surf.uder(u, v), surf.vder(u, v));
|
||||
let m = [[su.x, sv.x, -d.x], [su.y, sv.y, -d.y], [su.z, sv.z, -d.z]];
|
||||
let Some([du, dv, dl]) = crate::polyhedron::solve3_pub(m, [-f.x, -f.y, -f.z]) else { break };
|
||||
if ![du, dv, dl].iter().all(|z| z.is_finite()) {
|
||||
break;
|
||||
}
|
||||
// Damp long steps: the start may be far from the root.
|
||||
let step = (Vec3::new(su.x, su.y, su.z) * du + Vec3::new(sv.x, sv.y, sv.z) * dv).len().max(dl.abs());
|
||||
let k = if step > reach { reach / step } else { 1.0 };
|
||||
u += du * k;
|
||||
v += dv * k;
|
||||
l += dl * k;
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.abs().total_cmp(&b.abs()));
|
||||
out
|
||||
}
|
||||
|
||||
/// A circle through the curve of `e` (centre, axis, radius), when it is one.
|
||||
fn edge_circle(e: &mt::Edge, tol: f64) -> Option<(Vec3, Vec3, f64)> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.curve();
|
||||
let (lo, hi) = c.range_tuple();
|
||||
let at = |f: f64| from_p3(c.subs(lo + (hi - lo) * f));
|
||||
let (a, b, m) = (at(0.0), at(1.0), at(0.5));
|
||||
let (u, w) = (b - a, m - a);
|
||||
let n = u.cross(w);
|
||||
let n2 = n.len2();
|
||||
if !(n2 > 1e-30) {
|
||||
return None;
|
||||
}
|
||||
// Circumcentre of a, b, m.
|
||||
let centre = a + (n.cross(u) * w.len2() + w.cross(n) * u.len2()) / (2.0 * n2);
|
||||
let radius = centre.dist(a);
|
||||
let axis = n.normalized()?;
|
||||
let ok = (1..16).all(|i| {
|
||||
let q = at(f64::from(i) / 16.0);
|
||||
(q.dist(centre) - radius).abs() <= tol && (q - centre).dot(axis).abs() <= tol
|
||||
});
|
||||
(ok && radius.is_finite()).then_some((centre, axis, radius))
|
||||
}
|
||||
|
||||
/// Where `q` lies on the edge's curve: its parameter, if within `tol` of the curve and inside
|
||||
/// the edge's range.
|
||||
pub(crate) fn on_edge(e: &mt::Edge, q: Vec3, tol: f64) -> Option<f64> {
|
||||
use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter};
|
||||
let c = e.curve();
|
||||
let (lo, hi) = c.range_tuple();
|
||||
let hint =
|
||||
(0..=32).map(|i| lo + (hi - lo) * f64::from(i) / 32.0).min_by(|x, y| from_p3(c.subs(*x)).dist(q).total_cmp(&from_p3(c.subs(*y)).dist(q)))?;
|
||||
let t = c.search_nearest_parameter(p3(q), hint, 100)?;
|
||||
let margin = (hi - lo) * 1e-9;
|
||||
(t.is_finite() && t >= lo - margin && t <= hi + margin && from_p3(c.subs(t)).dist(q) <= tol).then_some(t.clamp(lo, hi))
|
||||
}
|
||||
|
||||
/// Whether `q` lies on the edge's curve continued past its ends (a line or a circle).
|
||||
pub(crate) fn on_extension(e: &mt::Edge, q: Vec3, tol: f64) -> bool {
|
||||
match e.curve() {
|
||||
mt::Curve::Line(_) => {
|
||||
let (a, b) = (vtx(e.absolute_front()), vtx(e.absolute_back()));
|
||||
(b - a).normalized().is_some_and(|u| (q - a - u * (q - a).dot(u)).len() <= tol)
|
||||
}
|
||||
_ => edge_circle(e, tol).is_some_and(|(c, n, r)| ((q - c).dot(n)).abs() <= tol && ((q - c - n * (q - c).dot(n)).len() - r).abs() <= tol),
|
||||
}
|
||||
}
|
||||
|
||||
/// The edge `e` with its end at `v` moved to `nv`, along its own curve: shortened (any curve),
|
||||
/// or continued past `v` (a line or a circle). In the stored orientation of `e`.
|
||||
pub(crate) fn retarget(e: &mt::Edge, v: &mt::Vertex, nv: &mt::Vertex, tol: f64) -> Result<mt::Edge> {
|
||||
let (f, bk) = (e.absolute_front(), e.absolute_back());
|
||||
if f == bk {
|
||||
return Err(unsupported("a closed edge where the blend ends"));
|
||||
}
|
||||
if matches!(e.curve(), mt::Curve::Line(_)) {
|
||||
return Ok(if f == v { builder::line(nv, bk) } else { builder::line(f, nv) });
|
||||
}
|
||||
use mt::Cut;
|
||||
let q = vtx(nv);
|
||||
if let Some(t) = on_edge(e, q, tol) {
|
||||
use mt::BoundedCurve;
|
||||
let mut c = e.curve();
|
||||
// The piece kept must not shrink to nothing.
|
||||
let (lo, hi) = c.range_tuple();
|
||||
let margin = (hi - lo) * 1e-6;
|
||||
if (f == v && t >= hi - margin) || (f != v && t <= lo + margin) {
|
||||
return Err(too_large("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
let rest = c.cut(t);
|
||||
let (piece, a, b) = if f == v { (rest, nv, bk) } else { (c, f, nv) };
|
||||
return mt::Edge::try_new(a, b, piece).map_err(|err| KernelError::Failed(format!("blend: corner edge: {err}")));
|
||||
}
|
||||
// Continued past v: the arc from the new end through v to the far end.
|
||||
if edge_circle(e, tol).is_some() && on_extension(e, q, tol) {
|
||||
let through = p3(vtx(v));
|
||||
return Ok(if f == v { builder::circle_arc(nv, bk, through) } else { builder::circle_arc(f, nv, through) });
|
||||
}
|
||||
Err(too_large("the blend is larger than the neighbouring faces"))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn blend_geometry(
|
||||
solid: &Solid,
|
||||
@@ -237,6 +355,10 @@ fn blend_geometry(
|
||||
planes: Vec<(Vec3, Vec3)>,
|
||||
ca: Option<mt::Vertex>,
|
||||
cb: Option<mt::Vertex>,
|
||||
/// Points of the end's section on a curved end face, from A to B.
|
||||
cap: Option<Vec<Vec3>>,
|
||||
/// How far along the edge the section reaches from the end vertex.
|
||||
reach: f64,
|
||||
}
|
||||
let mut ends: Vec<End> = Vec::new();
|
||||
for (v, p) in [(&v0, p0), (&v1, p1)] {
|
||||
@@ -249,10 +371,98 @@ fn blend_geometry(
|
||||
};
|
||||
let (ga, gb) = match others[..] {
|
||||
[g] => (g, g),
|
||||
[x, y] if shape == Shape::Round && shared(i1, x).is_some() && shared(i2, y).is_some() => (x, y),
|
||||
[x, y] if shape == Shape::Round && shared(i1, y).is_some() && shared(i2, x).is_some() => (y, x),
|
||||
[x, y] if shared(i1, x).is_some() && shared(i2, y).is_some() => (x, y),
|
||||
[x, y] if shared(i1, y).is_some() && shared(i2, x).is_some() => (y, x),
|
||||
_ => return Err(unsupported("each end of the edge must touch exactly one more face")),
|
||||
};
|
||||
// An end face that is not a plane, or meets the side faces in curves: the rails end where
|
||||
// they reach its surface, and the blend's end is where its surface meets the end face
|
||||
// (the cross-section through `p` slid along d onto it, point by point).
|
||||
let is_line = |e: &mt::Edge| matches!(e.curve(), mt::Curve::Line(_));
|
||||
if ga == gb
|
||||
&& let (Some(e1), Some(e2)) = (shared(i1, ga), shared(i2, gb))
|
||||
&& (faces.get(ga).and_then(|f| plane_normal(f)).is_none() || !is_line(&e1) || !is_line(&e2))
|
||||
{
|
||||
let g = faces.get(ga).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
let surf = g.surface();
|
||||
let on_tol = (size * 1e-5).max(1e-6);
|
||||
let reach = 3.0 * (s + r);
|
||||
let (sa, sb) = (p + t1 * s, p + t2 * s);
|
||||
let c0 = centre(p);
|
||||
let theta = std::f64::consts::PI - phi;
|
||||
let profile = |f: f64| -> Vec3 {
|
||||
match shape {
|
||||
Shape::Flat => sa + (sb - sa) * f,
|
||||
Shape::Round => c0 + ((sa - c0) * ((1.0 - f) * theta).sin() + (sb - c0) * (f * theta).sin()) / theta.sin(),
|
||||
}
|
||||
};
|
||||
// A rail's end: on the side edge, else on its continuation within the side face.
|
||||
let rail_end = |x: Vec3, e: &mt::Edge, fi: usize| -> Result<(Vec3, f64)> {
|
||||
let hits = line_surface_hits(&surf, x, d, reach, tol);
|
||||
if let Some(l) = hits.iter().find(|l| on_edge(e, x + d * **l, on_tol).is_some()) {
|
||||
return Ok((x + d * *l, *l));
|
||||
}
|
||||
let side = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
hits.iter()
|
||||
.find(|l| on_extension(e, x + d * **l, on_tol) && extension_fits(side, p, x + d * **l, tol))
|
||||
.map(|l| (x + d * *l, *l))
|
||||
.ok_or_else(|| too_large("the blend is larger than the neighbouring faces"))
|
||||
};
|
||||
let ((a, la), (b, lb)) = (rail_end(sa, &e1, i1)?, rail_end(sb, &e2, i2)?);
|
||||
let flat_plane = shape == Shape::Flat && plane_normal(g).is_some();
|
||||
let mut cap: Option<Vec<Vec3>> = None;
|
||||
let mut far = la.abs().max(lb.abs());
|
||||
for n in [16usize, 32, 64] {
|
||||
if flat_plane {
|
||||
cap = Some(vec![a, b]);
|
||||
break;
|
||||
}
|
||||
// The section's points on the end face, each continuing from the last.
|
||||
let mut pts = vec![a];
|
||||
let mut last = la;
|
||||
let mut ok = true;
|
||||
for i in 1..n {
|
||||
let x = profile(i as f64 / n as f64);
|
||||
match line_surface_hits(&surf, x, d, reach, tol).into_iter().min_by(|u, v| (u - last).abs().total_cmp(&(v - last).abs())) {
|
||||
Some(l) => {
|
||||
pts.push(x + d * l);
|
||||
last = l;
|
||||
far = far.max(l.abs());
|
||||
}
|
||||
None => {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
break;
|
||||
}
|
||||
pts.push(b);
|
||||
// The fitted curve must stay on both surfaces between the points.
|
||||
let Some(bs) = crate::build::interpolate_cubic(&pts) else { break };
|
||||
let fits = (0..n).all(|i| {
|
||||
use mt::{ParametricCurve, ParametricSurface, SearchNearestParameter};
|
||||
let q = from_p3(bs.subs((i as f64 + 0.5) / n as f64));
|
||||
let on_g = surf.search_nearest_parameter(p3(q), None, 100).is_some_and(|(u, v)| from_p3(surf.subs(u, v)).dist(q) <= on_tol);
|
||||
let on_blend = match shape {
|
||||
Shape::Round => {
|
||||
let w = q - c0;
|
||||
((w - d * w.dot(d)).len() - r).abs() <= on_tol
|
||||
}
|
||||
Shape::Flat => (sb - sa).cross(d).normalized().is_some_and(|m| (q - sa).dot(m).abs() <= on_tol),
|
||||
};
|
||||
on_g && on_blend
|
||||
});
|
||||
if fits {
|
||||
cap = Some(pts);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let cap = cap.ok_or_else(|| unsupported("the blend's end on a curved face"))?;
|
||||
ends.push(End { v: v.clone(), g: vec![ga], e1, e2, e3: None, a, b, planes: Vec::new(), ca: None, cb: None, cap: Some(cap), reach: far });
|
||||
continue;
|
||||
}
|
||||
let normal = |g: usize| -> Result<Vec3> {
|
||||
let n = faces.get(g).and_then(|f| plane_normal(f)).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
|
||||
if n.dot(d).abs() < 1e-9 {
|
||||
@@ -311,6 +521,33 @@ fn blend_geometry(
|
||||
let (len3, u) = (far.dist(p), (far - p).normalized().ok_or_else(|| unsupported("zero-length corner edge"))?);
|
||||
let c0 = centre(p);
|
||||
let perp = |x: Vec3| x - d * x.dot(d);
|
||||
if shape == Shape::Flat {
|
||||
// The chamfer plane through both rails crosses the edge between them.
|
||||
let (sa, sb) = (p + t1 * s, p + t2 * s);
|
||||
let m = (sb - sa).cross(d).normalized().ok_or_else(|| KernelError::Failed("chamfer plane".into()))?;
|
||||
let den = m.dot(u);
|
||||
let root = if den.abs() > 1e-12 { m.dot(sa - p) / den } else { f64::NAN };
|
||||
let x = perp(p + u * root - sa);
|
||||
let along = x.dot(sb - sa) / (sb - sa).len2();
|
||||
if !(root > tol && root < len3 - tol && along > 1e-9 && along < 1.0 - 1e-9) {
|
||||
return Err(unsupported("the blend does not cross the edge between the end faces"));
|
||||
}
|
||||
ends.push(End {
|
||||
v: v.clone(),
|
||||
g: vec![ga, gb],
|
||||
e1: e1.clone(),
|
||||
e2: e2.clone(),
|
||||
e3: Some((e3.clone(), p + u * root)),
|
||||
a,
|
||||
b,
|
||||
planes: vec![(p, na), (p, nb)],
|
||||
ca: None,
|
||||
cb: None,
|
||||
cap: None,
|
||||
reach: 0.0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let (w, up) = (perp(p - c0), perp(u));
|
||||
// |w + λ up|² = r²
|
||||
let (qa, qb, qc) = (up.len2(), 2.0 * w.dot(up), w.len2() - r * r);
|
||||
@@ -330,7 +567,7 @@ fn blend_geometry(
|
||||
};
|
||||
let planes = if ga == gb { vec![(p, na)] } else { vec![(p, na), (p, nb)] };
|
||||
let [ca, cb] = consumed;
|
||||
ends.push(End { v: v.clone(), g: if ga == gb { vec![ga] } else { vec![ga, gb] }, e1, e2, e3, a, b, planes, ca, cb });
|
||||
ends.push(End { v: v.clone(), g: if ga == gb { vec![ga] } else { vec![ga, gb] }, e1, e2, e3, a, b, planes, ca, cb, cap: None, reach: 0.0 });
|
||||
}
|
||||
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
|
||||
if end0.g.iter().any(|g| end1.g.contains(g)) {
|
||||
@@ -393,6 +630,16 @@ fn blend_geometry(
|
||||
let (va, vb) = (vertex(&end.ca, end.a), vertex(&end.cb, end.b));
|
||||
let (sa, sb) = (p + t1 * s, p + t2 * s);
|
||||
let (chain, vq) = match (&end.e3, &end.planes[..]) {
|
||||
_ if end.cap.is_some() => {
|
||||
let pts = end.cap.as_deref().unwrap_or_default();
|
||||
let curve = if pts.len() == 2 {
|
||||
builder::line(&va, &vb)
|
||||
} else {
|
||||
let bs = crate::build::interpolate_cubic(pts).ok_or_else(|| unsupported("the blend's end on a curved face"))?;
|
||||
mt::Edge::try_new(&va, &vb, mt::Curve::BSplineCurve(bs)).map_err(|e| KernelError::Failed(format!("blend end: {e}")))?
|
||||
};
|
||||
(vec![curve], None)
|
||||
}
|
||||
(None, [plane]) => (vec![section(p, &va, &vb, sa, sb, *plane)?], None),
|
||||
(Some((_, q)), [pa, pb]) => {
|
||||
let vq = builder::vertex(p3(*q));
|
||||
@@ -411,20 +658,18 @@ fn blend_geometry(
|
||||
// between two end faces stops where the blend crosses it; the blended edge moves to A or B.
|
||||
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
let mut dropped: Vec<mt::EdgeID> = Vec::new();
|
||||
let shorten = |e: &mt::Edge, v: &mt::Vertex, nv: &mt::Vertex| {
|
||||
let (f, bk) = (e.absolute_front(), e.absolute_back());
|
||||
if f == v { builder::line(nv, bk) } else { builder::line(f, nv) }
|
||||
};
|
||||
let on_tol = (size * 1e-5).max(1e-6);
|
||||
let shorten = |e: &mt::Edge, v: &mt::Vertex, nv: &mt::Vertex| retarget(e, v, nv, on_tol);
|
||||
for (end, a, b, q) in [(end0, a0, b0, q0), (end1, a1, b1, q1)] {
|
||||
for (e, nv, used) in [(&end.e1, a, &end.ca), (&end.e2, b, &end.cb)] {
|
||||
if used.is_some() {
|
||||
dropped.push(e.id());
|
||||
continue;
|
||||
}
|
||||
subst.insert(e.id(), shorten(e, &end.v, nv));
|
||||
subst.insert(e.id(), shorten(e, &end.v, nv)?);
|
||||
}
|
||||
if let (Some((e3, _)), Some(q)) = (&end.e3, q) {
|
||||
subst.insert(e3.id(), shorten(e3, &end.v, q));
|
||||
subst.insert(e3.id(), shorten(e3, &end.v, q)?);
|
||||
}
|
||||
}
|
||||
// The side edges at the ends are pieces of themselves; the rails and corner curves are new.
|
||||
@@ -479,6 +724,7 @@ fn blend_geometry(
|
||||
let reach = ends
|
||||
.iter()
|
||||
.flat_map(|e| e.planes.iter().map(|(_, n)| (s + r) * (1.0 - n.dot(d).powi(2)).max(0.0).sqrt() / n.dot(d).abs()))
|
||||
.chain(ends.iter().map(|e| e.reach))
|
||||
.fold(0.0, f64::max)
|
||||
+ r;
|
||||
let start = p0 - d * reach;
|
||||
@@ -601,32 +847,45 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z])));
|
||||
}
|
||||
let edge = Body::unique_edges(&solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?;
|
||||
let attempt = match guard(what, || blend_one(&solid, &edge, size, r, shape)) {
|
||||
Ok(o) => Ok((o, vec![j])),
|
||||
// A curved edge, or one between curved faces: roll a ball along its smooth chain.
|
||||
Err(e) if !curved_case(&solid, &edge) => Err(e),
|
||||
Err(e) => match smooth_chain(&solid, &edge) {
|
||||
Some(ids) => {
|
||||
let on_chain: Vec<usize> = todo
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, q)| {
|
||||
cur.nearest_edge(**q, tol)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|(i, _)| Body::unique_edges(&solid).into_iter().nth(i))
|
||||
.is_some_and(|x| ids.contains(&x.id()))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
guard(what, || crate::curveblend::curve_blend(&solid, &ids, size, r, shape == Shape::Round))
|
||||
.map(|o| (o, if on_chain.is_empty() { vec![j] } else { on_chain }))
|
||||
.map_err(|e2| match e2 {
|
||||
KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m} ({e})")),
|
||||
e2 => KernelError::Failed(format!("{e2} ({e})")),
|
||||
})
|
||||
}
|
||||
None => Err(e),
|
||||
// Roll a ball along the edge's smooth chain (the points of `todo` on it go with it).
|
||||
let roll = |ids: &[mt::EdgeID]| {
|
||||
let on_chain: Vec<usize> = todo
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, q)| {
|
||||
cur.nearest_edge(**q, tol)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|(i, _)| Body::unique_edges(&solid).into_iter().nth(i))
|
||||
.is_some_and(|x| ids.contains(&x.id()))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
guard(what, || crate::curveblend::curve_blend(&solid, ids, size, r, shape == Shape::Round))
|
||||
.map(|o| (o, if on_chain.is_empty() { vec![j] } else { on_chain }))
|
||||
};
|
||||
// A straight edge running on smoothly into curved ones (a flange into its bend, a
|
||||
// side into a rounded corner): the blend follows the whole smooth chain, as the
|
||||
// rolling ball would.
|
||||
let chain = smooth_chain(&solid, &edge);
|
||||
let runs_on = !curved_case(&solid, &edge)
|
||||
&& chain.as_ref().is_some_and(|ids| {
|
||||
ids.len() > 1 && Body::unique_edges(&solid).iter().any(|x| ids.contains(&x.id()) && !matches!(x.curve(), mt::Curve::Line(_)))
|
||||
});
|
||||
let attempt = match chain.as_deref().filter(|_| runs_on).map(roll) {
|
||||
Some(Ok(o)) => Ok(o),
|
||||
Some(Err(e2)) => guard(what, || blend_one(&solid, &edge, size, r, shape)).map(|o| (o, vec![j])).map_err(|_| e2),
|
||||
None => match guard(what, || blend_one(&solid, &edge, size, r, shape)) {
|
||||
Ok(o) => Ok((o, vec![j])),
|
||||
// A curved edge, or one between curved faces: roll a ball along its smooth chain.
|
||||
Err(e) if !curved_case(&solid, &edge) => Err(e),
|
||||
Err(e) => match chain.as_deref() {
|
||||
Some(ids) => roll(ids).map_err(|e2| match e2 {
|
||||
KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m} ({e})")),
|
||||
e2 => KernelError::Failed(format!("{e2} ({e})")),
|
||||
}),
|
||||
None => Err(e),
|
||||
},
|
||||
},
|
||||
};
|
||||
match attempt.and_then(|((o, rec), js)| record::rebuilt(o, &[&solid], &rec).map(|b| (b, js))) {
|
||||
@@ -761,8 +1020,9 @@ fn curved_case(solid: &Solid, edge: &mt::Edge) -> bool {
|
||||
fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option<Vec<mt::EdgeID>> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let all = Body::unique_edges(solid);
|
||||
// Along the edge as it runs from its front to its back (an edge may run against its curve).
|
||||
let dir = |e: &mt::Edge, at_end: bool| -> Option<Vec3> {
|
||||
let c = e.curve();
|
||||
let c = e.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let d = c.der(if at_end { t1 } else { t0 });
|
||||
Vec3::new(d.x, d.y, d.z).normalized()
|
||||
@@ -828,22 +1088,28 @@ fn smooth_loop(w: &mt::Wire) -> bool {
|
||||
|
||||
/// The B-rep edges nearest the points (each once).
|
||||
fn edge_ids(cur: &Body, solid: &Solid, pts: &[Vec3]) -> Result<Vec<mt::EdgeID>> {
|
||||
let size = cur.size();
|
||||
let tol = (size * 2e-3).max(1e-3);
|
||||
let all = Body::unique_edges(solid);
|
||||
let mut ids = Vec::new();
|
||||
for p in pts {
|
||||
if let Some((idx, dist)) = cur.nearest_edge(*p, tol)?
|
||||
&& dist <= size * 0.05 + 1e-3
|
||||
&& let Some(e) = all.get(idx)
|
||||
&& !ids.contains(&e.id())
|
||||
if let Some(id) = nearest_edge_id(cur, &all, *p)?
|
||||
&& !ids.contains(&id)
|
||||
{
|
||||
ids.push(e.id());
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// The edge of `all` (`cur`'s edges, in their order) a point picks: the nearest, if near enough.
|
||||
fn nearest_edge_id(cur: &Body, all: &[mt::Edge], p: Vec3) -> Result<Option<mt::EdgeID>> {
|
||||
let size = cur.size();
|
||||
let tol = (size * 2e-3).max(1e-3);
|
||||
Ok(match cur.nearest_edge(p, tol)? {
|
||||
Some((idx, dist)) if dist <= size * 0.05 + 1e-3 => all.get(idx).map(|e| e.id()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// When the edges make up whole boundary loops of planar faces: the edge points per loop.
|
||||
fn loop_groups(cur: &Body, pts: &[Vec3]) -> Result<Option<Vec<Vec<Vec3>>>> {
|
||||
Ok(loop_split(cur, pts)?.and_then(|(g, rest)| rest.is_empty().then_some(g)))
|
||||
@@ -955,6 +1221,116 @@ pub fn fillet_max(body: &Body, edges: &[Vec3], radius: f64) -> Option<(f64, Body
|
||||
best.map(|b| (lo as f64 * q, b))
|
||||
}
|
||||
|
||||
/// [`fillet_max`] for each group of the edges that may limit each other's radius (sharing a
|
||||
/// face or a vertex, directly or through other edges in the group), so a round that fits isn't
|
||||
/// reduced for a tight edge elsewhere. The radius per edge (in `edges`' order) and the result;
|
||||
/// one radius for all (as [`fillet_max`]) when a group is refused for another reason than its
|
||||
/// size, the groups don't build together, or there are too many to search (each search is up
|
||||
/// to 16 fillets, and this runs in the live preview).
|
||||
pub fn fillet_max_each(body: &Body, edges: &[Vec3], radius: f64) -> Option<(Vec<f64>, Body)> {
|
||||
const MAX_GROUPS: usize = 8;
|
||||
const MAX_SEARCHES: usize = 2;
|
||||
let one = || fillet_max(body, edges, radius).map(|(r, b)| (vec![r; edges.len()], b));
|
||||
let Some(groups) = interacting_groups(body, edges).filter(|g| g.len() > 1 && g.len() <= MAX_GROUPS) else { return one() };
|
||||
let mut radii = vec![radius; edges.len()];
|
||||
let mut searches = 0;
|
||||
for g in &groups {
|
||||
let pts: Vec<Vec3> = g.iter().filter_map(|i| edges.get(*i).copied()).collect();
|
||||
let r = match fillet(body, &pts, radius) {
|
||||
Ok(_) => continue,
|
||||
Err(KernelError::TooLarge(_)) if searches < MAX_SEARCHES => {
|
||||
searches += 1;
|
||||
match fillet_max(body, &pts, radius) {
|
||||
Some((r, _)) => r,
|
||||
None => return one(),
|
||||
}
|
||||
}
|
||||
Err(_) => return one(),
|
||||
};
|
||||
for i in g {
|
||||
if let Some(x) = radii.get_mut(*i) {
|
||||
*x = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
match fillet_each(body, edges, &radii) {
|
||||
Ok(b) => Some((radii, b)),
|
||||
Err(_) => one(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The fillet of each edge at its own radius (`radii` in `edges`' order): built together where
|
||||
/// the corner build takes it, else the edges of each radius together, the largest first.
|
||||
pub fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64]) -> Result<Body> {
|
||||
if radii.len() != edges.len() {
|
||||
return Err(KernelError::Invalid("one radius per edge".into()));
|
||||
}
|
||||
if let Some(r) = radii.first().filter(|r| radii.iter().all(|x| x.to_bits() == r.to_bits())) {
|
||||
return fillet(body, edges, *r);
|
||||
}
|
||||
let sel: Vec<(Vec3, f64)> = edges.iter().copied().zip(radii.iter().copied()).collect();
|
||||
if let Ok(b) = crate::blend_corner::fillet_radii(body, &sel) {
|
||||
return Ok(b);
|
||||
}
|
||||
let mut by_radius: Vec<(f64, Vec<Vec3>)> = Vec::new();
|
||||
for (p, r) in sel {
|
||||
match by_radius.iter_mut().find(|(x, _)| x.to_bits() == r.to_bits()) {
|
||||
Some((_, pts)) => pts.push(p),
|
||||
None => by_radius.push((r, vec![p])),
|
||||
}
|
||||
}
|
||||
by_radius.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
let mut cur = body.clone();
|
||||
for (r, pts) in &by_radius {
|
||||
cur = fillet(&cur, pts, *r)?;
|
||||
}
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
/// The points' edges (found as [`edge_ids`] finds them, on the body as the blends see it) grouped
|
||||
/// where they share a face or a vertex, directly or through other points' edges: indices into
|
||||
/// `pts`. `None` when a point has no edge near it.
|
||||
fn interacting_groups(body: &Body, pts: &[Vec3]) -> Option<Vec<Vec<usize>>> {
|
||||
let cur = crate::boolean_record::healed(body).ok()?;
|
||||
let all = Body::unique_edges(&cur.solid);
|
||||
// What each point's edge touches: its faces and its ends.
|
||||
let mut groups: Vec<(Vec<mt::FaceID>, Vec<mt::VertexID>, Vec<usize>)> = Vec::new();
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
let id = nearest_edge_id(&cur, &all, *p).ok()??;
|
||||
let edge = all.iter().find(|e| e.id() == id)?;
|
||||
let mut faces: Vec<mt::FaceID> = cur.solid.face_iter().filter(|f| f.edge_iter().any(|e| e.id() == id)).map(|f| f.id()).collect();
|
||||
let mut ends = vec![edge.front().id(), edge.back().id()];
|
||||
let mut members = vec![i];
|
||||
// Join every group touching one of these faces or ends.
|
||||
let (meet, rest): (Vec<_>, Vec<_>) =
|
||||
groups.into_iter().partition(|(fs, vs, _)| fs.iter().any(|f| faces.contains(f)) || vs.iter().any(|v| ends.contains(v)));
|
||||
for (fs, vs, ms) in meet {
|
||||
for f in fs {
|
||||
if !faces.contains(&f) {
|
||||
faces.push(f);
|
||||
}
|
||||
}
|
||||
for v in vs {
|
||||
if !ends.contains(&v) {
|
||||
ends.push(v);
|
||||
}
|
||||
}
|
||||
members.extend(ms);
|
||||
}
|
||||
groups = rest;
|
||||
groups.push((faces, ends, members));
|
||||
}
|
||||
Some(
|
||||
groups
|
||||
.into_iter()
|
||||
.map(|(_, _, mut ms)| {
|
||||
ms.sort_unstable();
|
||||
ms
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Equal-distance chamfer of the edges nearest to the given points. Each edge is cut with a
|
||||
/// prism whose only face inside the body is the chamfer plane (so neighbouring chamfers meet in
|
||||
/// a mitre); the local operation is the fallback.
|
||||
@@ -1236,6 +1612,102 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A box with a cylinder joined across one side (#556): the bottom edge of that side runs
|
||||
/// into the cylinder, which is curved where the edge ends.
|
||||
fn box_and_boss() -> Body {
|
||||
let b = crate::build::box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap();
|
||||
let c = crate::build::cylinder(Vec3::new(40.0, 20.0, 0.0), Vec3::Z, 12.0, 40.0).unwrap();
|
||||
crate::ops::boolean(&b, &c, crate::BoolOp::Union).unwrap().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blend_runs_into_a_cylinder_at_its_end() {
|
||||
let body = box_and_boss();
|
||||
let vol = |b: &Body| b.tessellate(1e-4).unwrap().measure().volume;
|
||||
let v0 = vol(&body);
|
||||
// The edge (40, 0..8, 0) ends in the cylinder wall at y = 8.
|
||||
let len = 20.0 - (144.0f64 - 0.0).sqrt();
|
||||
for (shape, size, area) in [(Shape::Round, 0.5, (1.0 - std::f64::consts::PI / 4.0) * 0.25), (Shape::Flat, 0.5, 0.125)] {
|
||||
let out = blend(&body, &[Vec3::new(40.0, 4.0, 0.0)], size, shape, "blend").unwrap();
|
||||
assert!(out.validity().is_empty(), "{:?}", out.validity());
|
||||
let removed = v0 - vol(&out);
|
||||
// The section runs on a little past y = 8 under the cylinder wall.
|
||||
assert!(removed > area * len * 0.99 && removed < area * (len + 0.02), "{removed} vs {}", area * len);
|
||||
assert_eq!(out.face_count(), body.face_count() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blend_ends_on_a_hole_wall() {
|
||||
// A plate with a hole cutting through the middle of its top front edge: each piece of
|
||||
// that edge ends on the hole's wall.
|
||||
let plate = crate::build::box_solid(Vec3::ZERO, Vec3::new(40.0, 20.0, 10.0)).unwrap();
|
||||
let hole = crate::build::cylinder(Vec3::new(20.0, 0.0, -1.0), Vec3::Z, 5.0, 12.0).unwrap();
|
||||
let body = crate::ops::boolean(&plate, &hole, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
let vol = |b: &Body| b.tessellate(1e-4).unwrap().measure().volume;
|
||||
let v0 = vol(&body);
|
||||
for (shape, area) in [(Shape::Round, (1.0 - std::f64::consts::PI / 4.0)), (Shape::Flat, 0.5)] {
|
||||
let out = blend(&body, &[Vec3::new(5.0, 0.0, 10.0)], 1.0, shape, "blend").unwrap();
|
||||
assert!(out.validity().is_empty(), "{:?}", out.validity());
|
||||
let removed = v0 - vol(&out);
|
||||
assert!(removed > area * 14.0 && removed < area * 15.2, "{removed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chamfer_ends_across_two_faces() {
|
||||
// A plate's outline chamfered, then the mitre at a corner chamfered: its low end meets
|
||||
// both side faces, and the chamfer plane cuts back the vertical edge between them.
|
||||
let k = 5.0;
|
||||
let b = crate::build::box_solid(Vec3::ZERO, Vec3::new(100.0, 50.0, 20.0)).unwrap();
|
||||
let outline = [Vec3::new(50.0, 0.0, 20.0), Vec3::new(100.0, 25.0, 20.0), Vec3::new(50.0, 50.0, 20.0), Vec3::new(0.0, 25.0, 20.0)];
|
||||
let c = chamfer(&b, &outline, k).unwrap();
|
||||
let out = blend(&c, &[Vec3::new(k / 2.0, k / 2.0, 20.0 - k / 2.0)], 1.0, Shape::Flat, "chamfer").unwrap();
|
||||
assert!(out.validity().is_empty(), "{:?}", out.validity());
|
||||
assert_eq!(out.lumps().unwrap().len(), 1);
|
||||
assert_eq!(out.face_count(), c.face_count() + 1);
|
||||
let vol = |b: &Body| b.tessellate(1e-4).unwrap().measure().volume;
|
||||
// About the triangle (sides 1 and 1 at 120°) along the mitre; its ends slant.
|
||||
let removed = vol(&c) - vol(&out);
|
||||
let along = 0.5 * 120f64.to_radians().sin() * k * 3f64.sqrt();
|
||||
assert!(removed > along * 0.8 && removed < along * 1.2, "{removed} vs {along}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curved_edge_blend_ends_in_a_face_at_an_angle() {
|
||||
// A groove of radius 8 along y with its axis 4 below the top: the arcs where it meets the
|
||||
// front and back faces end in the top face at 60° to it.
|
||||
let b = crate::build::box_solid(Vec3::ZERO, Vec3::new(40.0, 20.0, 20.0)).unwrap();
|
||||
let c = crate::build::cylinder(Vec3::new(20.0, -1.0, 16.0), Vec3::Y, 8.0, 22.0).unwrap();
|
||||
let body = crate::ops::boolean(&b, &c, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
let vol = |b: &Body| b.tessellate(1e-4).unwrap().measure().volume;
|
||||
let v0 = vol(&body);
|
||||
for shape in [Shape::Round, Shape::Flat] {
|
||||
let out = blend(&body, &[Vec3::new(20.0, 0.0, 8.0)], 0.5, shape, "blend").unwrap();
|
||||
assert!(out.validity().is_empty(), "{:?}", out.validity());
|
||||
// One blend face per piece of the arc (the groove's surface is in two faces).
|
||||
assert_eq!(out.face_count(), body.face_count() + 2);
|
||||
// About the section along the arc (240°, at the section's distance from the axis).
|
||||
let area = if shape == Shape::Round { (1.0 - std::f64::consts::PI / 4.0) * 0.25 } else { 0.125 };
|
||||
let along = 8.2 * 240f64.to_radians();
|
||||
let removed = v0 - vol(&out);
|
||||
assert!(removed > area * along * 0.9 && removed < area * along * 1.1, "{removed} vs {}", area * along);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blend_of_an_edge_running_into_a_rounded_corner_follows_it() {
|
||||
// A box with one vertical edge rounded: a top edge meeting that round smoothly is blended
|
||||
// with the round's top arc and the next top edge, as one smooth chain.
|
||||
let b = crate::build::box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||
let rounded = blend(&b, &[Vec3::new(40.0, 30.0, 10.0)], 5.0, Shape::Round, "fillet").unwrap();
|
||||
for shape in [Shape::Round, Shape::Flat] {
|
||||
let out = blend(&rounded, &[Vec3::new(20.0, 30.0, 20.0)], 1.0, shape, "blend").unwrap();
|
||||
assert!(out.validity().is_empty(), "{:?}", out.validity());
|
||||
assert_eq!(out.face_count(), rounded.face_count() + 3);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_chamfer_intersects_sloped_end_planes() {
|
||||
for slope in [-0.5, 0.5] {
|
||||
|
||||
+81
-13
@@ -40,7 +40,7 @@ pub struct Body {
|
||||
pub(crate) history: Option<std::sync::Arc<crate::history::History>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FaceSource {
|
||||
ExtrudeSide {
|
||||
segment: solidcraft_geom::Seg2,
|
||||
@@ -110,7 +110,7 @@ impl FaceSource {
|
||||
}
|
||||
|
||||
/// How a body looks beyond its colour, for export: its opacity and its faces' own colours.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Paint {
|
||||
/// 1 = opaque.
|
||||
pub opacity: f32,
|
||||
@@ -118,7 +118,7 @@ pub struct Paint {
|
||||
}
|
||||
|
||||
/// A face's own colour: the B-rep face index (`face_iter` order), sRGB as 0..1, opacity.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FacePaint {
|
||||
pub face: usize,
|
||||
pub color: [f32; 3],
|
||||
@@ -148,17 +148,38 @@ pub struct FaceInfo {
|
||||
|
||||
type MeshedShell = truck_topology::Shell<mt::Point3, PolylineCurve<mt::Point3>, Option<PolygonMesh>>;
|
||||
|
||||
/// The most one facet of the display mesh turns (radians): a curved face or edge is divided
|
||||
/// until no facet spans more of it than this, however small its radius is beside the body.
|
||||
/// 20 degrees: a quarter turn is drawn with at least five facets, a full circle with at least 18.
|
||||
pub const DISPLAY_ANGLE: f64 = std::f64::consts::PI / 9.0;
|
||||
|
||||
/// The sag, as a fraction of the chord tolerance, below which the angle no longer divides a
|
||||
/// facet: at [`DISPLAY_ANGLE`] and the application's tolerance of 1/1000 of the body, a radius
|
||||
/// under about 1/15000 of the body gets fewer facets than the angle asks for, and one under
|
||||
/// a millionth of it only those of the chord tolerance.
|
||||
const DISPLAY_ANGLE_FLOOR: f64 = 1e-3;
|
||||
|
||||
/// The finest angle a display mesh is divided by (one degree).
|
||||
const MIN_DISPLAY_ANGLE: f64 = std::f64::consts::PI / 180.0;
|
||||
|
||||
/// Triangulate each shell (not the solid: truck's solid constructor panics on the open or
|
||||
/// non-manifold shells an import can produce). Boundaries that lie on their surface only within
|
||||
/// tolerance (imports, boolean results) are projected onto it.
|
||||
fn mesh_shells(solid: &Solid, tol: f64) -> Vec<MeshedShell> {
|
||||
mesh_shells_within(solid, Deflection::chord(tol))
|
||||
}
|
||||
|
||||
/// [`mesh_shells`] divided by a chord tolerance and, when it has one, a bound on the angle a
|
||||
/// facet spans. Each edge is divided once for both its faces, so the faces still share its
|
||||
/// points.
|
||||
fn mesh_shells_within(solid: &Solid, deflection: Deflection) -> Vec<MeshedShell> {
|
||||
solid
|
||||
.boundaries()
|
||||
.iter()
|
||||
// The robust search tries the exact one first, so exact geometry costs nothing extra; a
|
||||
// face can also come back as an empty mesh rather than none (boolean results with
|
||||
// approximate intersection curves), so always use it.
|
||||
.map(|sh| sh.robust_triangulation(tol))
|
||||
.map(|sh| sh.robust_triangulation_within(deflection))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -456,13 +477,26 @@ impl Body {
|
||||
if lo.x.is_finite() { (hi - lo).len() } else { 1.0 }
|
||||
};
|
||||
let tol = (span * 0.01).clamp(1e-3, 1.0);
|
||||
let (v, missing) = guard("orient", || {
|
||||
let shells = mesh_shells(&solid, tol);
|
||||
let volume = shells.iter().map(|s| s.to_polygon().volume()).sum::<f64>();
|
||||
let missing =
|
||||
shells.iter().flat_map(|s| s.face_iter()).filter(|f| f.surface().is_none_or(|m| m.faces().triangle_iter().next().is_none())).count();
|
||||
Ok((volume, missing))
|
||||
})?;
|
||||
let orient = |tol: f64| {
|
||||
guard("orient", || {
|
||||
let shells = mesh_shells(&solid, tol);
|
||||
let volume = shells.iter().map(|s| s.to_polygon().volume()).sum::<f64>();
|
||||
let missing = shells
|
||||
.iter()
|
||||
.flat_map(|s| s.face_iter())
|
||||
.filter(|f| f.surface().is_none_or(|m| m.faces().triangle_iter().next().is_none()))
|
||||
.count();
|
||||
Ok((volume, missing))
|
||||
})
|
||||
};
|
||||
let (mut v, mut missing) = orient(tol)?;
|
||||
// A long thin round part (a through hole's tool: a cylinder whose vertices all lie on
|
||||
// its seam, so they span its length only) meshed that coarsely can come out with a
|
||||
// negative volume when it is outward already: check finely before turning it inside out.
|
||||
let fine = (tol * 0.02).max(1e-4);
|
||||
if v < 0.0 && fine < tol {
|
||||
(v, missing) = orient(fine)?;
|
||||
}
|
||||
if v < 0.0 {
|
||||
solid.not();
|
||||
}
|
||||
@@ -838,13 +872,24 @@ impl Body {
|
||||
/// Like [`Body::tessellate`]; with `refine`, triangles that cut through curved faces are
|
||||
/// split until within `tol` of the surface (for measuring).
|
||||
pub fn tessellate_with(&self, tol: f64, refine: bool) -> Result<Mesh> {
|
||||
self.tessellate_within(tol, f64::INFINITY, refine)
|
||||
}
|
||||
|
||||
/// [`Body::tessellate_with`] in which no facet turns more than `angle` radians either
|
||||
/// (infinite: the chord tolerance alone).
|
||||
fn tessellate_within(&self, tol: f64, angle: f64, refine: bool) -> Result<Mesh> {
|
||||
let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.05 };
|
||||
let tol = tol.max(self.size() * 1e-6);
|
||||
if let Some(m) = &self.mesh {
|
||||
return Ok(m.to_mesh());
|
||||
}
|
||||
let deflection = if angle.is_finite() && angle > 0.0 {
|
||||
Deflection { chord: tol, angle: angle.max(MIN_DISPLAY_ANGLE), floor: tol * DISPLAY_ANGLE_FLOOR }
|
||||
} else {
|
||||
Deflection::chord(tol)
|
||||
};
|
||||
guard("tessellate", || {
|
||||
let meshed = mesh_shells(&self.solid, tol);
|
||||
let meshed = mesh_shells_within(&self.solid, deflection);
|
||||
let mut out = Mesh::default();
|
||||
let solid_faces: Vec<&mt::Face> = self.solid.face_iter().collect();
|
||||
for (fi, face) in meshed.iter().flat_map(|s| s.face_iter()).enumerate() {
|
||||
@@ -941,7 +986,20 @@ impl Body {
|
||||
|
||||
/// Display mesh: like [`Body::tessellate`], with seam edges flagged.
|
||||
pub fn display_mesh(&self, tol: f64) -> Result<Mesh> {
|
||||
let mut m = self.tessellate(tol)?;
|
||||
self.flagged(self.tessellate(tol)?)
|
||||
}
|
||||
|
||||
/// Display mesh whose curved faces and edges look curved whatever the body's size: like
|
||||
/// [`Body::display_mesh`], and no facet turns more than `angle` radians (see
|
||||
/// [`DISPLAY_ANGLE`]). A chord tolerance that follows the body's size is larger than the
|
||||
/// sagitta of a small radius on a large body (a sheet-metal bend, a small hole), which the
|
||||
/// tolerance alone leaves as one flat facet.
|
||||
pub fn display_mesh_angular(&self, tol: f64, angle: f64) -> Result<Mesh> {
|
||||
self.flagged(self.tessellate_within(tol, angle, false)?)
|
||||
}
|
||||
|
||||
/// A tessellation with its edges flagged for display (seams, tangent joins, protected creases).
|
||||
fn flagged(&self, mut m: Mesh) -> Result<Mesh> {
|
||||
if self.is_mesh() {
|
||||
return Ok(m);
|
||||
}
|
||||
@@ -1101,6 +1159,12 @@ impl Body {
|
||||
self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3))
|
||||
}
|
||||
|
||||
/// A union's pieces must be clear of each other by the contact distance of [`Body::meets`];
|
||||
/// closer shells are touching material that was never fused (#396).
|
||||
pub(crate) fn union_lumps(&self) -> Result<Vec<Body>> {
|
||||
self.lumps_with_clearance(2.0 * (self.size() * 1e-4).max(1e-6))
|
||||
}
|
||||
|
||||
fn lumps_with_clearance(&self, clearance: f64) -> Result<Vec<Body>> {
|
||||
if self.mesh.is_some() || self.solid.boundaries().len() < 2 {
|
||||
return Ok(vec![self.clone()]);
|
||||
@@ -1221,6 +1285,10 @@ mod lump_tests;
|
||||
#[path = "refine_cache_tests.rs"]
|
||||
mod refine_cache_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "display_mesh_tests.rs"]
|
||||
mod display_mesh_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "regularization_protection_tests.rs"]
|
||||
mod regularization_protection_tests;
|
||||
@@ -303,3 +303,19 @@ fn grouping_lumps_does_not_infer_absent_or_misaligned_sources() {
|
||||
let malformed = body.with_face_sources(Some(std::sync::Arc::new(vec![None])));
|
||||
assert!(malformed.lumps().unwrap_err().to_string().contains("face source table"));
|
||||
}
|
||||
|
||||
/// A long thin cylinder (a through hole's tool) far from the origin, its seam turned: its
|
||||
/// vertices span only its length, so the coarse orientation mesh can give it a negative volume.
|
||||
/// It must still come out outward, or a Cut with it removes the wrong side (#559).
|
||||
#[test]
|
||||
fn a_long_thin_cylinder_comes_out_outward() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
|
||||
let plane = Plane::from_normal(Vec3::new(0., 24., 30.), Vec3::Z).unwrap();
|
||||
for (lo, seam) in [(-181.0, 0.613), (-100.0, 0.613), (-10.0, 0.613), (-181.0, 0.0)] {
|
||||
let disc = Region2 { outer: Loop2::circle_from(Vec2::ZERO, 0.5, seam), holes: vec![] };
|
||||
let tool = crate::extrude(&plane, &[disc], lo, 0.9).unwrap().pop().unwrap();
|
||||
let volume = tool.tessellate(0.01).unwrap().measure().volume;
|
||||
let want = std::f64::consts::PI * 0.25 * (0.9 - lo);
|
||||
assert!((volume - want).abs() < want * 0.02, "{lo} {seam}: {volume} vs {want}");
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ pub enum BooleanStrategy {
|
||||
PlanarFallback,
|
||||
ToolNudge,
|
||||
Piecewise,
|
||||
/// No retry gave a result, and the answer from sample points was kept although the bodies'
|
||||
/// facetings disagree with it.
|
||||
UnverifiedShortcut,
|
||||
}
|
||||
|
||||
/// Numerical adjustments used by a successful retry, in millimetres/radians.
|
||||
@@ -47,6 +50,10 @@ impl BooleanFallback {
|
||||
BooleanStrategy::PlanarFallback => "exact planar fallback (no displacement)".into(),
|
||||
BooleanStrategy::ToolNudge => format!("tool displacement {:.6e} mm, rotation {:.6e} rad", self.translation_mm, self.rotation_rad),
|
||||
BooleanStrategy::Piecewise => "piecewise retry (no displacement)".into(),
|
||||
BooleanStrategy::UnverifiedShortcut => format!(
|
||||
"not verified: the bodies' facets pass through each other by more than {:.6e} mm, but they could not be intersected there; that part is ignored",
|
||||
self.tolerance_mm
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +73,12 @@ impl BooleanDiagnostics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the result is not verified, when it ignores a place where the bodies seem to pass
|
||||
/// through each other ([`BooleanStrategy::UnverifiedShortcut`]).
|
||||
pub fn unverified(&self) -> Option<String> {
|
||||
self.events.iter().find(|event| event.strategy == BooleanStrategy::UnverifiedShortcut).map(BooleanFallback::description)
|
||||
}
|
||||
|
||||
pub fn warning(&self) -> Option<String> {
|
||||
if self.events.is_empty() && !self.truncated {
|
||||
return None;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! The check every Boolean retry's result must pass before it is returned (issue #616).
|
||||
//!
|
||||
//! A retry moves things about (the tool nudged, faces pushed apart, pieces cut off and glued
|
||||
//! back), so its result is held to what a join or a cut can do at all:
|
||||
//! - it is closed;
|
||||
//! - a join never loses volume and a cut never gains any;
|
||||
//! - outside the tool's box nothing changes: there, the result's surface is the target's and
|
||||
//! the target's surface is the result's. A join or a cut can only change the target where the
|
||||
//! tool is, so geometry anywhere else (a slit through a wall, a face moved) is wrong.
|
||||
//!
|
||||
//! A result that fails is refused with an error: a wrong result that looks right is worse.
|
||||
|
||||
use solidcraft_geom::{Aabb3, Mesh, Vec3};
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::{BoolOp, KernelError, Result};
|
||||
|
||||
/// Most triangles sampled from each surface (evenly spaced through the mesh).
|
||||
const MAX_SAMPLES: usize = 20_000;
|
||||
|
||||
/// Distance from `p` to the triangle `abc`.
|
||||
fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
let n = (b - a).cross(c - a);
|
||||
let Some(nn) = n.normalized() else {
|
||||
return [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min);
|
||||
};
|
||||
let h = (p - a).dot(nn);
|
||||
let q = p - nn * h;
|
||||
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= 0.0);
|
||||
if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) }
|
||||
}
|
||||
|
||||
/// The triangles of a mesh in a uniform grid of cells, each listing the triangles within `reach`
|
||||
/// of it, to answer "is this point within `reach` of the surface?".
|
||||
struct Near<'a> {
|
||||
mesh: &'a Mesh,
|
||||
min: Vec3,
|
||||
cell: f64,
|
||||
dims: [usize; 3],
|
||||
cells: Vec<Vec<u32>>,
|
||||
reach: f64,
|
||||
}
|
||||
|
||||
impl<'a> Near<'a> {
|
||||
fn new(mesh: &'a Mesh, reach: f64) -> Option<Self> {
|
||||
let bounds = mesh.bounds();
|
||||
if bounds.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let min = bounds.min - Vec3::new(reach, reach, reach);
|
||||
let size = bounds.size() + Vec3::new(2.0 * reach, 2.0 * reach, 2.0 * reach);
|
||||
// About a few triangles a cell, at most 64^3 cells.
|
||||
let n = (mesh.triangles.len().max(1) as f64).cbrt().clamp(1.0, 64.0);
|
||||
let cell = (size.x.max(size.y).max(size.z) / n).max(reach * 2.0).max(1e-9);
|
||||
let dims = [size.x, size.y, size.z].map(|s| ((s / cell).ceil() as usize).clamp(1, 256));
|
||||
let mut cells = vec![Vec::new(); dims[0] * dims[1] * dims[2]];
|
||||
for (i, t) in mesh.triangles.iter().enumerate() {
|
||||
let Some(tri) = mesh.tri(t) else { continue };
|
||||
let mut bx = Aabb3::EMPTY;
|
||||
for p in tri {
|
||||
bx.add(p);
|
||||
}
|
||||
let lo = Self::index(min, cell, dims, bx.min - Vec3::new(reach, reach, reach));
|
||||
let hi = Self::index(min, cell, dims, bx.max + Vec3::new(reach, reach, reach));
|
||||
for x in lo[0]..=hi[0] {
|
||||
for y in lo[1]..=hi[1] {
|
||||
for z in lo[2]..=hi[2] {
|
||||
if let Some(c) = cells.get_mut((x * dims[1] + y) * dims[2] + z) {
|
||||
c.push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Near { mesh, min, cell, dims, cells, reach })
|
||||
}
|
||||
|
||||
fn index(min: Vec3, cell: f64, dims: [usize; 3], p: Vec3) -> [usize; 3] {
|
||||
let d = p - min;
|
||||
let f = |v: f64, n: usize| ((v / cell).floor().max(0.0) as usize).min(n - 1);
|
||||
[f(d.x, dims[0]), f(d.y, dims[1]), f(d.z, dims[2])]
|
||||
}
|
||||
|
||||
fn close(&self, p: Vec3) -> bool {
|
||||
let d = p - self.min;
|
||||
let s = [d.x, d.y, d.z];
|
||||
if s.iter().zip(self.dims).any(|(v, n)| *v < 0.0 || *v > self.cell * n as f64) {
|
||||
return false;
|
||||
}
|
||||
let [x, y, z] = Self::index(self.min, self.cell, self.dims, p);
|
||||
let Some(list) = self.cells.get((x * self.dims[1] + y) * self.dims[2] + z) else { return false };
|
||||
list.iter().any(|&t| {
|
||||
self.mesh.triangles.get(t as usize).and_then(|t| self.mesh.tri(t)).is_some_and(|[a, b, c]| point_triangle_dist(p, a, b, c) <= self.reach)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A point of `from`'s surface outside `region` that is not on `onto`'s surface (within
|
||||
/// `reach`), if there is one.
|
||||
fn stray_point(from: &Mesh, onto: &Near, region: &Aabb3) -> Option<Vec3> {
|
||||
let step = from.triangles.len().div_ceil(MAX_SAMPLES).max(1);
|
||||
from.triangles.iter().step_by(step).filter_map(|t| from.tri(t)).find_map(|[a, b, c]| {
|
||||
let p = (a + b + c) / 3.0;
|
||||
let outside =
|
||||
p.x < region.min.x || p.y < region.min.y || p.z < region.min.z || p.x > region.max.x || p.y > region.max.y || p.z > region.max.z;
|
||||
(outside && !onto.close(p)).then_some(p)
|
||||
})
|
||||
}
|
||||
|
||||
fn bad(op: BoolOp, what: String) -> KernelError {
|
||||
let verb = match op {
|
||||
BoolOp::Union => "join",
|
||||
BoolOp::Cut => "cut",
|
||||
BoolOp::Intersect => "intersect",
|
||||
};
|
||||
KernelError::Failed(format!(
|
||||
"the {verb} could not be computed reliably: {what}. The result was refused rather than kept wrong; \
|
||||
where the bodies only touch (faces flush with each other), overlapping them slightly usually helps"
|
||||
))
|
||||
}
|
||||
|
||||
/// Check the result of a Boolean that needed a retry (see the module docs). `result` is `None`
|
||||
/// for an empty result.
|
||||
pub(crate) fn check_retry(a: &Body, b: &Body, op: BoolOp, result: Option<&Body>) -> Result<()> {
|
||||
if op == BoolOp::Intersect || a.is_mesh() || b.is_mesh() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(r) = result else {
|
||||
// Nothing left: only a cut whose tool covers the whole target (to within the meshes'
|
||||
// chords: a tool touching a round side of the target).
|
||||
let chord = a.size().max(b.size()) * 1e-3;
|
||||
let (Ok(ma), Ok(mb)) = (a.tessellate(chord), b.tessellate(chord)) else { return Ok(()) };
|
||||
let (ba, bb) = (ma.bounds(), mb.bounds());
|
||||
let tol = 2.0 * chord;
|
||||
let within = ba.min.x >= bb.min.x - tol
|
||||
&& ba.min.y >= bb.min.y - tol
|
||||
&& ba.min.z >= bb.min.z - tol
|
||||
&& ba.max.x <= bb.max.x + tol
|
||||
&& ba.max.y <= bb.max.y + tol
|
||||
&& ba.max.z <= bb.max.z + tol;
|
||||
return if op == BoolOp::Cut && within { Ok(()) } else { Err(bad(op, "it came out empty".into())) };
|
||||
};
|
||||
if r.solid.boundaries().iter().any(|s| s.shell_condition() != truck_modeling::ShellCondition::Closed) {
|
||||
return Err(bad(op, "the result is not closed".into()));
|
||||
}
|
||||
let size = a.size().max(b.size());
|
||||
let t = (size * 2e-4).max(1e-5);
|
||||
let (Ok(ma), Ok(mb), Ok(mr)) = (a.tessellate_with(t, true), b.tessellate_with(t, true), r.tessellate_with(t, true)) else {
|
||||
return Err(bad(op, "the result could not be checked".into()));
|
||||
};
|
||||
let (va, vb, vr) = (ma.measure().volume, mb.measure().volume, mr.measure().volume);
|
||||
let slack = 2e-4 * (va.abs() + vb.abs()) + 1e-9;
|
||||
let volume_ok = vr.is_finite()
|
||||
&& match op {
|
||||
BoolOp::Union => vr >= va.max(vb) - slack && vr <= va + vb + slack,
|
||||
BoolOp::Cut => vr <= va + slack && vr >= va - vb - slack,
|
||||
BoolOp::Intersect => true,
|
||||
};
|
||||
if !volume_ok {
|
||||
let what = match op {
|
||||
BoolOp::Union => format!("the joined body's volume would be {vr:.4} mm³ (the target is {va:.4} mm³, the tool {vb:.4} mm³)"),
|
||||
_ => format!("the cut body's volume would be {vr:.4} mm³ (the target is {va:.4} mm³, the tool {vb:.4} mm³)"),
|
||||
};
|
||||
return Err(bad(op, what));
|
||||
}
|
||||
// Outside the tool's box (with room for the meshes' chords and a nudge) the result's
|
||||
// surface and the target's must be the same.
|
||||
let reach = 3.0 * t + size * 1e-5;
|
||||
let mut region = mb.bounds();
|
||||
region.min -= Vec3::new(reach, reach, reach) * 2.0;
|
||||
region.max += Vec3::new(reach, reach, reach) * 2.0;
|
||||
let (Some(near_a), Some(near_r)) = (Near::new(&ma, reach), Near::new(&mr, reach)) else {
|
||||
return Err(bad(op, "the result could not be checked".into()));
|
||||
};
|
||||
let at = |p: Vec3| format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z);
|
||||
if let Some(p) = stray_point(&mr, &near_a, ®ion) {
|
||||
return Err(bad(op, format!("it would change the body away from the tool, at {} (outside the tool's region)", at(p))));
|
||||
}
|
||||
if let Some(p) = stray_point(&ma, &near_r, ®ion) {
|
||||
return Err(bad(op, format!("it would remove part of the body away from the tool, at {} (outside the tool's region)", at(p))));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -494,7 +494,8 @@ pub fn torus(center: Vec3, major: f64, minor: f64) -> Result<Body> {
|
||||
}
|
||||
|
||||
/// Offset every segment of a loop to its right by `d` (outward for a region's outer loop and
|
||||
/// holes alike). Loops of lines (mitred corners) and loops of arcs on one circle are supported.
|
||||
/// holes alike). Loops of lines (mitred corners), loops of arcs on one circle, and loops of lines
|
||||
/// and arcs that meet the arcs tangentially are supported.
|
||||
fn offset_loop(lp: &Loop2, d: f64) -> Result<Loop2> {
|
||||
if d == 0.0 {
|
||||
return Ok(lp.clone());
|
||||
@@ -518,6 +519,9 @@ fn offset_loop(lp: &Loop2, d: f64) -> Result<Loop2> {
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
return Ok(Loop2 { segs });
|
||||
}
|
||||
if lp.segs.iter().any(|s| matches!(s, Seg2::Arc { .. })) {
|
||||
return offset_lines_and_arcs(lp, d);
|
||||
}
|
||||
let n = lp.segs.len();
|
||||
let mut lines: Vec<(Vec2, Vec2)> = Vec::with_capacity(n);
|
||||
for s in &lp.segs {
|
||||
@@ -544,6 +548,75 @@ fn offset_loop(lp: &Loop2, d: f64) -> Result<Loop2> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`offset_loop`] for a loop of lines and arcs. Each arc keeps its centre and angles and its
|
||||
/// radius moves by `d` (so its side stays an exact cone), each line moves `d` to its right, and
|
||||
/// two lines meeting at a corner are mitred. An arc has to meet its neighbours tangentially: at
|
||||
/// a corner its side would meet the next one along a conic, not a straight ruling.
|
||||
fn offset_lines_and_arcs(lp: &Loop2, d: f64) -> Result<Loop2> {
|
||||
let closes = || KernelError::Invalid("the taper closes the profile".into());
|
||||
let right = |v: Vec2| Vec2::new(v.y, -v.x);
|
||||
let n = lp.segs.len();
|
||||
let moved: Vec<Option<Seg2>> = lp
|
||||
.segs
|
||||
.iter()
|
||||
.map(|s| match *s {
|
||||
Seg2::Line { .. } => Ok(None),
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
let r = radius + sweep.signum() * d;
|
||||
if r > 1e-6 { Ok(Some(Seg2::Arc { center, radius: r, start, sweep })) } else { Err(closes()) }
|
||||
}
|
||||
_ => Err(KernelError::Failed("not supported yet: tapered profiles with splines or conics".into())),
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
// Where each segment starts after the offset.
|
||||
let mut joints = Vec::with_capacity(n);
|
||||
for (i, s) in lp.segs.iter().enumerate() {
|
||||
let k = (i + n - 1) % n;
|
||||
let (Some(prev), Some(prev_moved), Some(this_moved)) = (lp.segs.get(k), moved.get(k), moved.get(i)) else {
|
||||
return Err(KernelError::Failed("taper: profile loop".into()));
|
||||
};
|
||||
let (tin, tout) = (prev.end_tangent(), s.start_tangent());
|
||||
let tangent = tin.cross(tout).abs() < 1e-6 && tin.dot(tout) > 0.0;
|
||||
joints.push(match (prev_moved, this_moved) {
|
||||
(Some(a), _) if tangent => a.end(),
|
||||
(None, Some(b)) if tangent => b.start(),
|
||||
(None, None) => {
|
||||
let v = s.start();
|
||||
let (p0, p1) = (v + right(tin) * d, v + right(tout) * d);
|
||||
let den = tin.cross(tout);
|
||||
if den.abs() < 1e-12 { p1 } else { p0 + tin * ((p1 - p0).cross(tout) / den) }
|
||||
}
|
||||
_ => return Err(KernelError::Failed("not supported yet: tapered profiles where an arc meets another edge at a corner".into())),
|
||||
});
|
||||
}
|
||||
let mut segs = Vec::with_capacity(n);
|
||||
for (i, s) in lp.segs.iter().enumerate() {
|
||||
let (Some(&a), Some(&b), Some(m)) = (joints.get(i), joints.get((i + 1) % n), moved.get(i)) else {
|
||||
return Err(KernelError::Failed("taper: profile loop".into()));
|
||||
};
|
||||
match m {
|
||||
Some(arc) => {
|
||||
if arc.start().dist(a) > 1e-6 || arc.end().dist(b) > 1e-6 {
|
||||
return Err(KernelError::Failed("not supported yet: tapered profiles where an arc meets another edge at a corner".into()));
|
||||
}
|
||||
segs.push(*arc);
|
||||
}
|
||||
None => {
|
||||
// A line that turned around has been swallowed by its neighbours.
|
||||
if (b - a).dot(s.end() - s.start()) <= 0.0 {
|
||||
return Err(closes());
|
||||
}
|
||||
segs.push(Seg2::Line { a, b });
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = Loop2 { segs };
|
||||
if out.signed_area().signum() != lp.signed_area().signum() || out.signed_area().abs() < 1e-9 {
|
||||
return Err(closes());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Extrude one region by `length` along `dir_sign` × the plane normal with a taper angle
|
||||
/// (radians; positive grows the profile).
|
||||
pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f64, taper: f64) -> Result<Body> {
|
||||
|
||||
+196
-66
@@ -335,19 +335,16 @@ pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
|
||||
}
|
||||
for (n, d) in &back {
|
||||
// a's face points along n; b's face points along −n at the same plane (−d).
|
||||
let shift = if op == BoolOp::Union && crate::ops::narrowing_bores() {
|
||||
let shift = if op == BoolOp::Union {
|
||||
// Pushed into `a`, b's face must have a's material behind it all over and that
|
||||
// deep: over a hole in a's face (a narrowed bore's rim over the other body's
|
||||
// bore) it would fill the hole, through a thin wall it would stick out the far
|
||||
// side. Only checked inside `bore_union`, which then tries its other way; a
|
||||
// general check turns some joins that come out slightly wrong today (a pad
|
||||
// half past an edge) into failures, until such faces can be split first.
|
||||
// bore, a rib standing half over a window) it would fill the hole, through a
|
||||
// thin wall it would stick out the far side (#616). The other retries take
|
||||
// such a join instead.
|
||||
[d2, d2 * 0.25, d2 * 0.0625]
|
||||
.into_iter()
|
||||
.find(|depth| backed(a, b, -*n, -*d, *depth))
|
||||
.ok_or_else(|| KernelError::Failed("pushing a face: part of it lies over a hole or past the edge of the other body".into()))?
|
||||
} else if op == BoolOp::Union {
|
||||
d2
|
||||
} else {
|
||||
-d2
|
||||
};
|
||||
@@ -424,6 +421,30 @@ fn trimmed(x: &Body, h: History, slab: &Body, operands: [&Body; 2], n: Vec3, d:
|
||||
Some(cut)
|
||||
}
|
||||
|
||||
/// Do the bodies touch over an area, back to back on a common plane: has `a` material just
|
||||
/// behind some part of a face of `b` lying in a plane of a face of `a` that faces the other way?
|
||||
/// (Contact along a line or at a point is not this.)
|
||||
pub(crate) fn faces_touch(a: &Body, b: &Body) -> bool {
|
||||
let size = a.size().max(b.size());
|
||||
let tol = (size * 1e-7).max(1e-9) * 10.0;
|
||||
let pa = planes(a);
|
||||
let faces: Vec<(usize, Vec3)> = planes(b)
|
||||
.into_iter()
|
||||
.filter(|(_, y)| pa.iter().any(|(_, x)| x.n.dot(y.n) < -1.0 + 1e-9 && (x.d + y.d).abs() < tol))
|
||||
.map(|(i, y)| (i, y.n))
|
||||
.collect();
|
||||
if faces.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let (Ok(ma), Ok(mb)) = (a.tessellate_with(size * 1e-3, true), b.tessellate_with(size * 1e-3, true)) else { return false };
|
||||
let inside = ma.inside_index();
|
||||
let depth = (size * 1e-4).max(1e-6);
|
||||
mb.triangles.iter().zip(&mb.tri_face).take(100_000).any(|(t, fi)| {
|
||||
let Some((_, n)) = faces.iter().find(|(i, _)| *i == *fi as usize) else { return false };
|
||||
mb.tri(t).is_some_and(|[p, q, r]| inside.contains((p + q + r) / 3.0 + *n * depth))
|
||||
})
|
||||
}
|
||||
|
||||
/// Has `a` material behind every part of b's faces in the plane (m, d) (m their outward
|
||||
/// normal, pointing into `a`), to `depth`? Checked at points inside the faces' triangles.
|
||||
fn backed(a: &Body, b: &Body, m: Vec3, d: f64, depth: f64) -> bool {
|
||||
@@ -447,9 +468,10 @@ fn backed(a: &Body, b: &Body, m: Vec3, d: f64, depth: f64) -> bool {
|
||||
!points.is_empty() && points.iter().all(|p| [0.05, 0.5, 1.0].iter().all(|k| inside.contains(*p + m * (depth * k))))
|
||||
}
|
||||
|
||||
/// Union of bodies that touch along one identical planar face, back to back (a body joined
|
||||
/// with its mirror image): the two faces go and the shells are stitched along their edges.
|
||||
/// `None` when no such pair of faces exists.
|
||||
/// Union of bodies that touch along identical planar faces, back to back (a body joined with
|
||||
/// its mirror image, or the pieces of a body cut apart by a plane, which may meet in several
|
||||
/// faces): every such pair of faces goes and the shells are stitched along their edges. `None`
|
||||
/// when no such pair of faces exists.
|
||||
pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let size = a.size().max(b.size());
|
||||
@@ -460,13 +482,18 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
let (t0, t1) = c.range_tuple();
|
||||
from_p3(c.subs((t0 + t1) * 0.5))
|
||||
};
|
||||
// Matching faces: same plane, opposite normals, the same edges.
|
||||
// Matching faces: same plane, opposite normals, the same edges. Every pair is glued: a
|
||||
// pair left behind would stay inside the result as a zero-thickness slit (issue #616).
|
||||
let (pa, pb) = (planes(a), planes(b));
|
||||
let fa_list: Vec<&mt::Face> = a.solid.face_iter().collect();
|
||||
let fb_list: Vec<&mt::Face> = b.solid.face_iter().collect();
|
||||
// B edge → the matching A edge; B vertex → the matching A vertex.
|
||||
let mut map: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
let mut vmap: HashMap<mt::VertexID, mt::Vertex> = HashMap::new();
|
||||
let (mut glued_a, mut glued_b): (Vec<usize>, Vec<usize>) = (Vec::new(), Vec::new());
|
||||
for (ia, x) in &pa {
|
||||
for (ib, y) in &pb {
|
||||
if x.n.dot(y.n) > -1.0 + 1e-9 || (x.d + y.d).abs() > tol {
|
||||
if glued_a.contains(ia) || glued_b.contains(ib) || x.n.dot(y.n) > -1.0 + 1e-9 || (x.d + y.d).abs() > tol {
|
||||
continue;
|
||||
}
|
||||
let (Some(fa), Some(fb)) = (fa_list.get(*ia), fb_list.get(*ib)) else { continue };
|
||||
@@ -475,9 +502,7 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
if ea.len() != eb.len() {
|
||||
continue;
|
||||
}
|
||||
// B edge → the matching A edge.
|
||||
let mut map: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
let mut vmap: HashMap<mt::VertexID, mt::Vertex> = HashMap::new();
|
||||
let (mut pair_map, mut pair_vmap) = (Vec::new(), Vec::new());
|
||||
let mut ok = true;
|
||||
for e in &eb {
|
||||
let ends = (key(from_p3(e.absolute_front().point())), key(from_p3(e.absolute_back().point())));
|
||||
@@ -491,71 +516,176 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
break;
|
||||
};
|
||||
let same = key(from_p3(f.absolute_front().point())) == ends.0;
|
||||
map.insert(e.id(), if same { f.absolute_clone() } else { f.absolute_clone().inverse() });
|
||||
pair_map.push((e.id(), if same { f.absolute_clone() } else { f.absolute_clone().inverse() }));
|
||||
let (fa_v, fb_v) = if same {
|
||||
(f.absolute_front().clone(), f.absolute_back().clone())
|
||||
} else {
|
||||
(f.absolute_back().clone(), f.absolute_front().clone())
|
||||
};
|
||||
vmap.insert(e.absolute_front().id(), fa_v);
|
||||
vmap.insert(e.absolute_back().id(), fb_v);
|
||||
pair_vmap.push((e.absolute_front().id(), fa_v));
|
||||
pair_vmap.push((e.absolute_back().id(), fb_v));
|
||||
}
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
return Some(guard("glue", || {
|
||||
let fail = |m: &str| KernelError::Failed(format!("joining touching faces: {m}"));
|
||||
// B's other edges, rebuilt on A's vertices where they meet the seam.
|
||||
let mut edges: HashMap<mt::EdgeID, mt::Edge> = map.clone();
|
||||
for e in b.solid.edge_iter() {
|
||||
if edges.contains_key(&e.id()) {
|
||||
continue;
|
||||
}
|
||||
let (f0, f1) = (e.absolute_front(), e.absolute_back());
|
||||
if !vmap.contains_key(&f0.id()) && !vmap.contains_key(&f1.id()) {
|
||||
edges.insert(e.id(), e.absolute_clone());
|
||||
continue;
|
||||
}
|
||||
let v0 = vmap.get(&f0.id()).cloned().unwrap_or_else(|| f0.clone());
|
||||
let v1 = vmap.get(&f1.id()).cloned().unwrap_or_else(|| f1.clone());
|
||||
edges.insert(e.id(), mt::Edge::new(&v0, &v1, e.curve()));
|
||||
map.extend(pair_map);
|
||||
vmap.extend(pair_vmap);
|
||||
glued_a.push(*ia);
|
||||
glued_b.push(*ib);
|
||||
}
|
||||
}
|
||||
if glued_a.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(guard("glue", || {
|
||||
let fail = |m: &str| KernelError::Failed(format!("joining touching faces: {m}"));
|
||||
// B's other edges, rebuilt on A's vertices where they meet a seam.
|
||||
let mut edges: HashMap<mt::EdgeID, mt::Edge> = map.clone();
|
||||
for e in b.solid.edge_iter() {
|
||||
if edges.contains_key(&e.id()) {
|
||||
continue;
|
||||
}
|
||||
let (f0, f1) = (e.absolute_front(), e.absolute_back());
|
||||
if !vmap.contains_key(&f0.id()) && !vmap.contains_key(&f1.id()) {
|
||||
edges.insert(e.id(), e.absolute_clone());
|
||||
continue;
|
||||
}
|
||||
let v0 = vmap.get(&f0.id()).cloned().unwrap_or_else(|| f0.clone());
|
||||
let v1 = vmap.get(&f1.id()).cloned().unwrap_or_else(|| f1.clone());
|
||||
edges.insert(e.id(), mt::Edge::new(&v0, &v1, e.curve()));
|
||||
}
|
||||
let mut faces: Vec<mt::Face> = fa_list.iter().enumerate().filter(|(i, _)| !glued_a.contains(i)).map(|(_, f)| (*f).clone()).collect();
|
||||
// Every face but the glued ones is kept; an edge along a seam is an edge of both.
|
||||
let kept = |operand: usize, n: usize, skip: &[usize]| {
|
||||
(0..n).filter(|i| !skip.contains(i)).map(move |i| record::from_face(operand, i, Change::Kept)).collect::<Vec<_>>()
|
||||
};
|
||||
let from: Vec<FaceProv> = kept(0, fa_list.len(), &glued_a).into_iter().chain(kept(1, fb_list.len(), &glued_b)).collect();
|
||||
let mut origins = record::edge_origins(&a.solid, 0);
|
||||
for (old, o) in record::edge_origins(&b.solid, 1) {
|
||||
if let Some(new) = edges.get(&old) {
|
||||
origins.entry(new.id()).or_default().extend(o);
|
||||
}
|
||||
}
|
||||
for (i, f) in fb_list.iter().enumerate() {
|
||||
if glued_b.contains(&i) {
|
||||
continue;
|
||||
}
|
||||
let wires: Vec<mt::Wire> = f
|
||||
.absolute_boundaries()
|
||||
.iter()
|
||||
.map(|w| {
|
||||
w.edge_iter()
|
||||
.filter_map(|e| edges.get(&e.id()).map(|ne| if e.front() == e.absolute_front() { ne.clone() } else { ne.inverse() }))
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
let mut nf = mt::Face::try_new(wires, f.surface()).map_err(|e| fail(&e.to_string()))?;
|
||||
if !f.orientation() {
|
||||
nf.invert();
|
||||
}
|
||||
faces.push(nf);
|
||||
}
|
||||
let solid = Solid::try_new(vec![faces.into()]).map_err(|e| fail(&e.to_string()))?;
|
||||
// Each edge's origins in the solid's edge order.
|
||||
let origins = crate::body::edge_order(solid.face_iter()).0.iter().map(|e| origins.get(&e.id()).cloned()).collect();
|
||||
crate::boolean_record::heal_build(solid, size, &[], Some(from), origins, &record::counts(&[a, b]))
|
||||
}))
|
||||
}
|
||||
|
||||
/// Union of bodies touching back to back where a planar face of one lies wholly inside a
|
||||
/// coincident face of the other, clear of its edges (a loft or revolve standing on a box top,
|
||||
/// #396): the inner face goes, and the outer face takes its outline as a hole, sharing its
|
||||
/// edges. `None` when no such pair of faces exists. The caller checks that the bodies don't
|
||||
/// also overlap elsewhere.
|
||||
pub(crate) fn inset(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
inset_into(a, b, 0).or_else(|| inset_into(b, a, 1))
|
||||
}
|
||||
|
||||
/// [`inset`] with a face of `inner` inside one of `outer`, `outer` being operand `outer_operand`.
|
||||
fn inset_into(outer: &Body, inner: &Body, outer_operand: usize) -> Option<Result<Body>> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
const SAMPLES: usize = 64;
|
||||
const MAX_WORK: usize = 4_000_000;
|
||||
let size = outer.size().max(inner.size());
|
||||
let tol = (size * 1e-7).max(1e-9) * 10.0;
|
||||
// Clear of the outer face's edges by more than the samples' chord error.
|
||||
let clear = (size * 2e-3).max(1e-6);
|
||||
let sample = |w: &mt::Wire| -> Option<Vec<Vec3>> {
|
||||
if w.len() > 1000 {
|
||||
return None;
|
||||
}
|
||||
let mut pts = Vec::with_capacity(w.len() * SAMPLES);
|
||||
for e in w.edge_iter() {
|
||||
let c = e.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
pts.extend((0..SAMPLES).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / SAMPLES as f64))));
|
||||
}
|
||||
(!pts.is_empty() && pts.iter().all(|p| p.is_finite())).then_some(pts)
|
||||
};
|
||||
let fo: Vec<&mt::Face> = outer.solid.face_iter().collect();
|
||||
let fi: Vec<&mt::Face> = inner.solid.face_iter().collect();
|
||||
for (io, x) in &planes(outer) {
|
||||
for (ii, y) in &planes(inner) {
|
||||
if x.n.dot(y.n) > -1.0 + 1e-9 || (x.d + y.d).abs() > tol {
|
||||
continue;
|
||||
}
|
||||
let (Some(face_o), Some(face_i)) = (fo.get(*io), fi.get(*ii)) else { continue };
|
||||
let outline = match face_i.boundaries().as_slice() {
|
||||
[w] => w.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
let (Some(inner_pts), Some(outer_loops)) = (sample(&outline), face_o.boundaries().iter().map(sample).collect::<Option<Vec<_>>>()) else {
|
||||
continue;
|
||||
};
|
||||
let outer_count: usize = outer_loops.iter().map(Vec::len).sum();
|
||||
if inner_pts.len().saturating_mul(outer_count) > MAX_WORK {
|
||||
continue;
|
||||
}
|
||||
let u = x.n.any_perp();
|
||||
let w = x.n.cross(u);
|
||||
let flat = |pts: &[Vec3]| {
|
||||
solidcraft_geom::Loop2::polygon(&pts.iter().map(|p| solidcraft_geom::Vec2::new(p.dot(u), p.dot(w))).collect::<Vec<_>>())
|
||||
};
|
||||
let flat_point = |p: Vec3| solidcraft_geom::Vec2::new(p.dot(u), p.dot(w));
|
||||
let outer_polys: Vec<_> = outer_loops.iter().map(|l| flat(l)).collect();
|
||||
let inner_poly = flat(&inner_pts);
|
||||
let in_outer = |p: Vec3| outer_polys.iter().filter(|l| l.contains(flat_point(p))).count() % 2 == 1;
|
||||
let near_outer =
|
||||
|p: Vec3| outer_loops.iter().any(|l| l.iter().zip(l.iter().cycle().skip(1)).any(|(s, t)| p.dist_to_segment(*s, *t) < clear));
|
||||
let fits = inner_pts.iter().all(|p| in_outer(*p) && !near_outer(*p))
|
||||
&& outer_loops.iter().flatten().all(|p| !inner_poly.contains(flat_point(*p)));
|
||||
if !fits {
|
||||
continue;
|
||||
}
|
||||
return Some(guard("inset", || {
|
||||
let fail = |m: &str| KernelError::Failed(format!("joining a face standing inside another: {m}"));
|
||||
// The outline runs round the inner face's outward normal, the reverse of the
|
||||
// outer face's: the way a hole in the outer face runs.
|
||||
let mut wires = face_o.absolute_boundaries().clone();
|
||||
wires.push(if face_o.orientation() { outline.clone() } else { outline.inverse() });
|
||||
let mut holed = mt::Face::try_new(wires, face_o.surface()).map_err(|e| fail(&e.to_string()))?;
|
||||
if !face_o.orientation() {
|
||||
holed.invert();
|
||||
}
|
||||
let mut faces: Vec<mt::Face> = fa_list.iter().enumerate().filter(|(i, _)| i != ia).map(|(_, f)| (*f).clone()).collect();
|
||||
// Every face but the two glued is kept; an edge along the seam is an edge of both.
|
||||
let kept = |operand: usize, n: usize, skip: usize| {
|
||||
(0..n).filter(move |i| *i != skip).map(move |i| record::from_face(operand, i, Change::Kept))
|
||||
};
|
||||
let from: Vec<FaceProv> = kept(0, fa_list.len(), *ia).chain(kept(1, fb_list.len(), *ib)).collect();
|
||||
let mut origins = record::edge_origins(&a.solid, 0);
|
||||
for (old, o) in record::edge_origins(&b.solid, 1) {
|
||||
if let Some(new) = edges.get(&old) {
|
||||
origins.entry(new.id()).or_default().extend(o);
|
||||
}
|
||||
let inner_operand = 1 - outer_operand;
|
||||
let mut faces = Vec::with_capacity(fo.len() + fi.len());
|
||||
let mut from = Vec::with_capacity(fo.len() + fi.len());
|
||||
for (i, f) in fo.iter().enumerate() {
|
||||
let (face, change) = if i == *io { (holed.clone(), Change::Modified) } else { ((*f).clone(), Change::Kept) };
|
||||
faces.push(face);
|
||||
from.push(record::from_face(outer_operand, i, change));
|
||||
}
|
||||
for (i, f) in fb_list.iter().enumerate() {
|
||||
if i == *ib {
|
||||
continue;
|
||||
}
|
||||
let wires: Vec<mt::Wire> = f
|
||||
.absolute_boundaries()
|
||||
.iter()
|
||||
.map(|w| {
|
||||
w.edge_iter()
|
||||
.filter_map(|e| edges.get(&e.id()).map(|ne| if e.front() == e.absolute_front() { ne.clone() } else { ne.inverse() }))
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
let mut nf = mt::Face::try_new(wires, f.surface()).map_err(|e| fail(&e.to_string()))?;
|
||||
if !f.orientation() {
|
||||
nf.invert();
|
||||
}
|
||||
faces.push(nf);
|
||||
for (i, f) in fi.iter().enumerate().filter(|(i, _)| i != ii) {
|
||||
faces.push((*f).clone());
|
||||
from.push(record::from_face(inner_operand, i, Change::Kept));
|
||||
}
|
||||
let solid = Solid::try_new(vec![faces.into()]).map_err(|e| fail(&e.to_string()))?;
|
||||
// Each edge's origins in the solid's edge order.
|
||||
let mut origins = record::edge_origins(&outer.solid, outer_operand);
|
||||
origins.extend(record::edge_origins(&inner.solid, inner_operand));
|
||||
let origins = crate::body::edge_order(solid.face_iter()).0.iter().map(|e| origins.get(&e.id()).cloned()).collect();
|
||||
crate::boolean_record::heal_build(solid, size, &[], Some(from), origins, &record::counts(&[a, b]))
|
||||
let counts = if outer_operand == 0 { record::counts(&[outer, inner]) } else { record::counts(&[inner, outer]) };
|
||||
crate::boolean_record::heal_build(solid, size, &[], Some(from), origins, &counts)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+293
-25
@@ -24,6 +24,8 @@ fn unsupported(msg: &str) -> KernelError {
|
||||
const SAMPLES: usize = 24;
|
||||
const MAX_SAMPLES: usize = 369;
|
||||
const MAX_CHAIN_SAMPLES: usize = 100_000;
|
||||
/// Sections past a slanted end of an open chain.
|
||||
const EXTENSION: usize = 8;
|
||||
|
||||
struct Fit {
|
||||
surface: mt::Surface,
|
||||
@@ -72,21 +74,32 @@ impl Guide {
|
||||
/// One interpolation for both the face and its boundary curves. Fitting the contacts
|
||||
/// independently gives different end conditions and leaves gaps between the same faces.
|
||||
fn fit_sections(sections: &[Vec<Section>], k: usize, closed: bool, round: bool) -> Result<Fit> {
|
||||
use mt::Cut;
|
||||
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
|
||||
let n = sections.len();
|
||||
let list = sections.get(k).ok_or_else(|| fail("section"))?;
|
||||
let before = usize::from(closed || k > 0) * 2;
|
||||
let mut ext = Vec::with_capacity(list.len() + 4);
|
||||
if before > 0 {
|
||||
let mut before = Vec::new();
|
||||
if closed || k > 0 {
|
||||
let prev = sections.get((k + n - 1) % n).ok_or_else(|| fail("section"))?;
|
||||
ext.extend(prev.iter().rev().skip(1).take(2).rev().copied());
|
||||
before.extend(prev.iter().rev().skip(1).take(2).rev().copied());
|
||||
}
|
||||
ext.extend(list.iter().copied());
|
||||
let mut after = Vec::new();
|
||||
if closed || k + 1 < n {
|
||||
let next = sections.get((k + 1) % n).ok_or_else(|| fail("section"))?;
|
||||
ext.extend(next.iter().skip(1).take(2).copied());
|
||||
after.extend(next.iter().skip(1).take(2).copied());
|
||||
}
|
||||
fit_strip(list, &before, &after, round)
|
||||
}
|
||||
|
||||
/// [`fit_sections`] with the neighbouring sections given: best spaced as the strip's own, since
|
||||
/// the interpolation's parameters are uniform.
|
||||
fn fit_strip(list: &[Section], before: &[Section], after: &[Section], round: bool) -> Result<Fit> {
|
||||
use mt::Cut;
|
||||
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
|
||||
let mut ext = Vec::with_capacity(list.len() + before.len() + after.len());
|
||||
ext.extend(before.iter().copied());
|
||||
ext.extend(list.iter().copied());
|
||||
ext.extend(after.iter().copied());
|
||||
let before = before.len();
|
||||
let ctrl: Vec<[mt::Vector4; 3]> = ext.iter().map(|s| section_controls(s, round)).collect::<Option<_>>().ok_or_else(|| fail("section"))?;
|
||||
let mut rows = Vec::with_capacity(3);
|
||||
let mut vknots = None;
|
||||
@@ -161,6 +174,88 @@ fn section_fits(fit: &Fit, section: &Section, along: f64, round: bool, sigma: f6
|
||||
true
|
||||
}
|
||||
|
||||
/// Where a blend strip run on past a chain end meets the end face: the contacts' parameters
|
||||
/// there and the end curve from contact 1 to contact 2 (fitted through points where the
|
||||
/// strip's cross-section curves meet the face's surface, and checked against both).
|
||||
fn end_cap(fit: &Fit, g: &mt::Face, at: &Section, round: bool, tolerance: f64) -> Result<(f64, f64, mt::Curve)> {
|
||||
use mt::{ParametricCurve, ParametricSurface, SearchNearestParameter};
|
||||
let fail = || unsupported("the blend's end on its end face");
|
||||
let (surf, gs) = (&fit.surface, g.surface());
|
||||
let mid = if round { at.c + ((at.q1 - at.c) + (at.q2 - at.c)).normalized().ok_or_else(fail)? * at.c.dist(at.q1) } else { (at.q1 + at.q2) * 0.5 };
|
||||
let (_, v_start) = surf.search_nearest_parameter(p3(mid), None, 100).ok_or_else(fail)?;
|
||||
// S(u, v) = G(a, b) for v, a, b by Newton from v.
|
||||
let meet = |u: f64, v: f64| -> Option<f64> {
|
||||
let mut v = v;
|
||||
let (mut a, mut b) = gs.search_nearest_parameter(surf.subs(u, v), None, 100)?;
|
||||
for _ in 0..60 {
|
||||
let f = from_p3(surf.subs(u, v)) - from_p3(gs.subs(a, b));
|
||||
if !f.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if f.len() <= tolerance * 1e-3 {
|
||||
return (-1e-9..=1.0 + 1e-9).contains(&v).then_some(v.clamp(0.0, 1.0));
|
||||
}
|
||||
let (sv, ga, gb) = (surf.vder(u, v), gs.uder(a, b), gs.vder(a, b));
|
||||
let m = [[sv.x, -ga.x, -gb.x], [sv.y, -ga.y, -gb.y], [sv.z, -ga.z, -gb.z]];
|
||||
let [dv, da, db] = crate::polyhedron::solve3_pub(m, [-f.x, -f.y, -f.z])?;
|
||||
// Keep each step within the strip's parameters.
|
||||
let k = if dv.abs() > 0.1 { 0.1 / dv.abs() } else { 1.0 };
|
||||
v += dv * k;
|
||||
a += da * k;
|
||||
b += db * k;
|
||||
}
|
||||
None
|
||||
};
|
||||
for m in [16usize, 32, 64] {
|
||||
let mut pts = Vec::with_capacity(m + 1);
|
||||
let mut vs = Vec::with_capacity(m + 1);
|
||||
// From the middle out to both contacts, each search starting where the last ended.
|
||||
let half = m / 2;
|
||||
let mid_v = meet(0.5_f64.min(half as f64 / m as f64), v_start).ok_or_else(fail)?;
|
||||
let mut found = vec![None; m + 1];
|
||||
if let Some(slot) = found.get_mut(half) {
|
||||
*slot = Some(mid_v);
|
||||
}
|
||||
let mut last = mid_v;
|
||||
for i in (0..half).rev() {
|
||||
last = meet(i as f64 / m as f64, last).ok_or_else(fail)?;
|
||||
if let Some(slot) = found.get_mut(i) {
|
||||
*slot = Some(last);
|
||||
}
|
||||
}
|
||||
last = mid_v;
|
||||
for i in half + 1..=m {
|
||||
last = meet(i as f64 / m as f64, last).ok_or_else(fail)?;
|
||||
if let Some(slot) = found.get_mut(i) {
|
||||
*slot = Some(last);
|
||||
}
|
||||
}
|
||||
for (i, v) in found.iter().enumerate() {
|
||||
let v = v.ok_or_else(fail)?;
|
||||
vs.push(v);
|
||||
pts.push(from_p3(surf.subs(i as f64 / m as f64, v)));
|
||||
}
|
||||
let bs = crate::build::interpolate_cubic(&pts).ok_or_else(fail)?;
|
||||
let fits = (0..m).all(|i| {
|
||||
let q = p3(from_p3(bs.subs((i as f64 + 0.5) / m as f64)));
|
||||
let hint = vs.get(i).map(|v| ((i as f64 + 0.5) / m as f64, *v));
|
||||
let on_g = gs.search_nearest_parameter(q, None, 100).is_some_and(|(a, b)| from_p3(gs.subs(a, b)).dist(from_p3(q)) <= tolerance);
|
||||
let on_s = surf.search_nearest_parameter(q, hint, 100).is_some_and(|(u, v)| from_p3(surf.subs(u, v)).dist(from_p3(q)) <= tolerance);
|
||||
on_g && on_s
|
||||
});
|
||||
if fits {
|
||||
let (Some(t1), Some(t2)) = (vs.first(), vs.last()) else { return Err(fail()) };
|
||||
// Both contacts are cut there: strictly inside the strip's own part.
|
||||
let inside = |t: f64| t > fit.range.0 + 1e-6 && t < fit.range.1 - 1e-6;
|
||||
if !(inside(*t1) && inside(*t2)) {
|
||||
return Err(too_large("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
return Ok((*t1, *t2, mt::Curve::BSplineCurve(bs)));
|
||||
}
|
||||
}
|
||||
Err(fail())
|
||||
}
|
||||
|
||||
/// One cross-section of the blend: ball centre and its contacts with side 1 and side 2.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Section {
|
||||
@@ -409,6 +504,35 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
}
|
||||
let s1: Vec<&mt::Face> = side1.iter().filter_map(|i| faces.get(*i)).collect();
|
||||
let s2: Vec<&mt::Face> = side2.iter().filter_map(|i| faces.get(*i)).collect();
|
||||
// The faces the ball may touch near edges `ks` of the chain: their own, on each side. (The
|
||||
// surface of another face of a side, such as a wall's plane past the bend it runs into,
|
||||
// may pass nearer than the face the ball actually rolls on.)
|
||||
let edge_sides = |ks: &[usize]| -> (Vec<&mt::Face>, Vec<&mt::Face>) {
|
||||
let pick = |side: &[usize]| {
|
||||
let mut out: Vec<usize> = Vec::new();
|
||||
for k in ks {
|
||||
if let Some(fi) = side.get(*k)
|
||||
&& !out.contains(fi)
|
||||
{
|
||||
out.push(*fi);
|
||||
}
|
||||
}
|
||||
out.iter().filter_map(|i| faces.get(*i)).collect::<Vec<_>>()
|
||||
};
|
||||
(pick(&side1), pick(&side2))
|
||||
};
|
||||
// The chain's edges at vertex k.
|
||||
let vertex_edges = |k: usize| -> Vec<usize> {
|
||||
if closed {
|
||||
vec![(k + n - 1) % n, k % n]
|
||||
} else if k == 0 {
|
||||
vec![0]
|
||||
} else if k >= n {
|
||||
vec![n - 1]
|
||||
} else {
|
||||
vec![k - 1, k]
|
||||
}
|
||||
};
|
||||
// Convex or concave, from the first edge as its side-1 face runs it.
|
||||
let e0 = chain.first().ok_or_else(|| KernelError::Failed("chain".into()))?;
|
||||
let f1 = s1.first().ok_or_else(|| KernelError::Failed("side".into()))?;
|
||||
@@ -435,6 +559,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
let mut seams: Vec<Vec<(usize, mt::Edge)>> = Vec::with_capacity(nv);
|
||||
// At an open chain's ends: the end face (planar, square to the edge there).
|
||||
let mut end_faces: Vec<(usize, usize)> = Vec::new(); // (vertex index, face index)
|
||||
// Chain ends whose end face is not a plane square to the edge: (vertex, face, point, tangent).
|
||||
let mut slanted: Vec<(usize, usize, Vec3, Vec3)> = Vec::new();
|
||||
for (k, v) in verts.iter().enumerate() {
|
||||
let at_end = !closed && (k == 0 || k == nv - 1);
|
||||
let (k1, k2) = {
|
||||
@@ -491,19 +617,19 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
let here = seams.get(k).cloned().unwrap_or_default();
|
||||
let at_end = end_faces.iter().any(|(kk, _)| *kk == k);
|
||||
if at_end {
|
||||
// The end face must be square to the edge: the blend's end then lies in it.
|
||||
// A plane square to the edge holds the blend's end section. Any other end face: the
|
||||
// blend runs on past the end and is cut back where it meets that face.
|
||||
let (_, fi) = end_faces.iter().find(|(kk, _)| *kk == k).copied().unwrap_or_default();
|
||||
let g = faces.get(fi).ok_or_else(|| KernelError::Failed("end face".into()))?;
|
||||
let ng = match g.oriented_surface() {
|
||||
let square = match g.oriented_surface() {
|
||||
mt::Surface::Plane(pl) => {
|
||||
let m = pl.normal();
|
||||
Vec3::new(m.x, m.y, m.z).normalized()
|
||||
Vec3::new(m.x, m.y, m.z).normalized().is_some_and(|ng| ng.dot(t).abs() >= 1.0 - 1e-6)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
.ok_or_else(|| unsupported("the faces at the ends of the edges must be planar"))?;
|
||||
if ng.dot(t).abs() < 1.0 - 1e-6 {
|
||||
return Err(unsupported("the faces at the ends of the edges must be square to them"));
|
||||
_ => false,
|
||||
};
|
||||
if !square {
|
||||
slanted.push((k, fi, p, t));
|
||||
}
|
||||
}
|
||||
let extra = match here.first().cloned().filter(|_| !at_end) {
|
||||
@@ -516,12 +642,19 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
return Err(unsupported("a curved seam where the edge chain turns"));
|
||||
}
|
||||
let fs = if side == 1 { &s1 } else { &s2 };
|
||||
let nn =
|
||||
fs.iter().filter_map(|f| project(f, p, None)).map(|x| x.1).next().ok_or_else(|| KernelError::Failed("seam normal".into()))?;
|
||||
// The side's normal at the joint: from its face nearest the joint (any face of a
|
||||
// longer side is no guide here).
|
||||
let nn = fs
|
||||
.iter()
|
||||
.filter_map(|f| project(f, p, None))
|
||||
.min_by(|a, b| a.0.dist(p).total_cmp(&b.0.dist(p)))
|
||||
.map(|x| x.1)
|
||||
.ok_or_else(|| KernelError::Failed("seam normal".into()))?;
|
||||
Some((side, o, d.cross(nn).normalized().ok_or_else(|| KernelError::Failed("seam plane".into()))?))
|
||||
}
|
||||
};
|
||||
let s = solve(&s1, &s2, p, t, r, sigma, extra, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?;
|
||||
let (a1, a2) = edge_sides(&vertex_edges(k));
|
||||
let s = solve(&a1, &a2, p, t, r, sigma, extra, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?;
|
||||
// A seam on the other side too must pass through that side's contact.
|
||||
if let Some((side, seam)) = here.get(1).filter(|_| !at_end) {
|
||||
let q = if *side == 1 { s.q1 } else { s.q2 };
|
||||
@@ -532,6 +665,34 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
}
|
||||
vsec.push(s);
|
||||
}
|
||||
// Past a slanted end: sections of the same rolling ball beyond the end vertex (far to near
|
||||
// at the chain's start, near to far at its end), far enough to cross the end face.
|
||||
let mut beyond: Vec<(usize, Vec<Section>)> = Vec::new();
|
||||
for (k, fi, p, t) in &slanted {
|
||||
let g = faces.get(*fi).ok_or_else(|| KernelError::Failed("end face".into()))?;
|
||||
let at = vsec.get(*k).copied().ok_or_else(|| KernelError::Failed("section".into()))?;
|
||||
let (_, ng, _) = project(g, *p, None).ok_or_else(|| unsupported("the blend's end on its end face"))?;
|
||||
let cos = ng.dot(*t).abs();
|
||||
if cos < 0.15 {
|
||||
return Err(unsupported("an end face almost along the edge"));
|
||||
}
|
||||
let width = at.q1.dist(*p).max(at.q2.dist(*p)).max(at.c.dist(*p)) + r;
|
||||
let reach = 1.5 * width / cos;
|
||||
let out = if *k == 0 { -*t } else { *t };
|
||||
let (e1, e2) = edge_sides(&vertex_edges(*k));
|
||||
let mut list = Vec::with_capacity(EXTENSION);
|
||||
for j in 1..=EXTENSION {
|
||||
let anchor = *p + out * (reach * j as f64 / EXTENSION as f64);
|
||||
list.push(
|
||||
solve(&e1, &e2, anchor, *t, r, sigma, None, tol * 100.0)
|
||||
.ok_or_else(|| unsupported("the ball doesn't fit past the end of the edge"))?,
|
||||
);
|
||||
}
|
||||
if *k == 0 {
|
||||
list.reverse();
|
||||
}
|
||||
beyond.push((*k, list));
|
||||
}
|
||||
// Uniform refinement preserves a common spacing across neighbouring patches. Work is
|
||||
// capped: a difficult strip returns an error instead of accepting an inaccurate fit.
|
||||
let fit_tolerance = (size * 1e-5).clamp(1e-5, 0.001);
|
||||
@@ -554,7 +715,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
let (p, t) = tangent_at(edge, along).ok_or_else(|| KernelError::Failed("tangent".into()))?;
|
||||
let (start, end) = offsets.get(k).copied().ok_or_else(|| KernelError::Failed("section offset".into()))?;
|
||||
let anchor = p + t * (start * (1.0 - along) + end * along);
|
||||
solve(&s1, &s2, anchor, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))
|
||||
let (a1, a2) = edge_sides(&[k]);
|
||||
solve(&a1, &a2, anchor, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))
|
||||
};
|
||||
let mut samples = SAMPLES.min(max_samples);
|
||||
let (esec, fits) = loop {
|
||||
@@ -568,6 +730,13 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
list.push(section_at(k, e, j as f64 / (samples - 1) as f64)?);
|
||||
}
|
||||
list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?);
|
||||
for (kv, ext) in &beyond {
|
||||
if *kv == k && k == 0 {
|
||||
list.splice(0..0, ext.iter().copied());
|
||||
} else if *kv == k + 1 && k + 1 == nv - 1 {
|
||||
list.extend(ext.iter().copied());
|
||||
}
|
||||
}
|
||||
guides.push(Guide::new(list)?);
|
||||
}
|
||||
// Intersection curves inherit their leader's piecewise parameter speed. Fitting
|
||||
@@ -583,12 +752,41 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
return guide.sections.last().copied().ok_or_else(|| KernelError::Failed("section".into()));
|
||||
}
|
||||
let (anchor, tangent) = guide.anchor(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?;
|
||||
solve(&s1, &s2, anchor, tangent, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine"))
|
||||
let (a1, a2) = edge_sides(&[k]);
|
||||
solve(&a1, &a2, anchor, tangent, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine"))
|
||||
};
|
||||
let esec: Vec<Vec<Section>> = (0..n)
|
||||
.map(|k| (0..samples).map(|j| uniform_section(k, j as f64 / (samples - 1) as f64)).collect::<Result<Vec<_>>>())
|
||||
.collect::<Result<_>>()?;
|
||||
let fits = crate::guard("fit curve blend", || (0..n).map(|k| fit_sections(&esec, k, closed, round)).collect::<Result<Vec<_>>>())?;
|
||||
// Each strip's neighbouring sections at its own spacing (edges of different lengths
|
||||
// have differently spaced samples, which makes a uniform interpolation overshoot).
|
||||
let length = |k: usize| guides.get(k).and_then(|g| g.lengths.last().copied()).unwrap_or(0.0);
|
||||
let mut fits = Vec::with_capacity(n);
|
||||
for k in 0..n {
|
||||
let h = length(k) / (samples - 1) as f64;
|
||||
let spaced = |nb: usize, from_end: bool| -> Result<Option<Vec<Section>>> {
|
||||
let l = length(nb);
|
||||
if !(l > 2.0 * h && h > 0.0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut out = Vec::with_capacity(2);
|
||||
for j in [1.0, 2.0] {
|
||||
out.push(uniform_section(nb, if from_end { 1.0 - j * h / l } else { j * h / l })?);
|
||||
}
|
||||
if from_end {
|
||||
out.reverse();
|
||||
}
|
||||
Ok(Some(out))
|
||||
};
|
||||
let before = if closed || k > 0 { spaced((k + n - 1) % n, true)? } else { Some(Vec::new()) };
|
||||
let after = if closed || k + 1 < n { spaced((k + 1) % n, false)? } else { Some(Vec::new()) };
|
||||
let list = esec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?;
|
||||
let fit = match (before, after) {
|
||||
(Some(b), Some(a)) => crate::guard("fit curve blend", || fit_strip(list, &b, &a, round))?,
|
||||
_ => crate::guard("fit curve blend", || fit_sections(&esec, k, closed, round))?,
|
||||
};
|
||||
fits.push(fit);
|
||||
}
|
||||
let mut accurate = true;
|
||||
'edges: for k in 0..n {
|
||||
let fit = fits.get(k).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?;
|
||||
@@ -611,11 +809,37 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
}
|
||||
samples = (2 * samples - 1).min(max_samples);
|
||||
};
|
||||
// Where each extended blend meets its slanted end face: (vertex, edge, cut parameters of
|
||||
// the two contacts, the end curve from contact 1 to contact 2).
|
||||
let mut caps: Vec<(usize, usize, f64, f64, mt::Curve)> = Vec::new();
|
||||
for (k, fi, _, _) in &slanted {
|
||||
let ke = if *k == 0 { 0 } else { n - 1 };
|
||||
let fit = fits.get(ke).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?;
|
||||
let g = faces.get(*fi).ok_or_else(|| KernelError::Failed("end face".into()))?;
|
||||
let at = vsec.get(*k).copied().ok_or_else(|| KernelError::Failed("section".into()))?;
|
||||
let cap = crate::guard("curve blend end", || end_cap(fit, g, &at, round, fit_tolerance))?;
|
||||
caps.push((*k, ke, cap.0, cap.1, cap.2));
|
||||
}
|
||||
// Both ends of one edge cut back: the contacts must keep a piece between them.
|
||||
if let [(_, 0, s1, s2, _), (_, 0, e1, e2, _)] = &caps[..]
|
||||
&& n == 1
|
||||
&& !(s1 + 1e-6 < *e1 && s2 + 1e-6 < *e2)
|
||||
{
|
||||
return Err(too_large("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
crate::guard("curve blend", || {
|
||||
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
|
||||
// Vertices and arcs at the chain's vertices.
|
||||
let v1: Vec<mt::Vertex> = vsec.iter().map(|s| builder::vertex(p3(s.q1))).collect();
|
||||
let v2: Vec<mt::Vertex> = vsec.iter().map(|s| builder::vertex(p3(s.q2))).collect();
|
||||
let mut v1: Vec<mt::Vertex> = vsec.iter().map(|s| builder::vertex(p3(s.q1))).collect();
|
||||
let mut v2: Vec<mt::Vertex> = vsec.iter().map(|s| builder::vertex(p3(s.q2))).collect();
|
||||
for (k, _, _, _, curve) in &caps {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let (lo, hi) = curve.range_tuple();
|
||||
if let (Some(a), Some(b)) = (v1.get_mut(*k), v2.get_mut(*k)) {
|
||||
*a = builder::vertex(curve.subs(lo));
|
||||
*b = builder::vertex(curve.subs(hi));
|
||||
}
|
||||
}
|
||||
let mut arcs = Vec::with_capacity(nv);
|
||||
for (k, s) in vsec.iter().enumerate() {
|
||||
let (a, b) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?);
|
||||
@@ -625,6 +849,10 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
} else {
|
||||
builder::line(a, b)
|
||||
};
|
||||
let e = match caps.iter().find(|c| c.0 == k) {
|
||||
Some((_, _, _, _, curve)) => mt::Edge::try_new(a, b, curve.clone()).map_err(|e| fail(&e.to_string()))?,
|
||||
None => e,
|
||||
};
|
||||
arcs.push(e);
|
||||
}
|
||||
// Contact curves and blend faces per edge.
|
||||
@@ -637,8 +865,24 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
let (Some(a1), Some(b1), Some(a2), Some(b2)) = (a1, b1, a2, b2) else { return Err(fail("vertex")) };
|
||||
let fit = fits.get(k).ok_or_else(|| fail("fit"))?;
|
||||
let [cv1, cv2] = &fit.contacts;
|
||||
let e1 = mt::Edge::new(a1, b1, cv1.clone());
|
||||
let e2 = mt::Edge::new(a2, b2, cv2.clone());
|
||||
// Contacts past a slanted end stop at the end curve.
|
||||
let (mut cv1, mut cv2) = (cv1.clone(), cv2.clone());
|
||||
for (kv, _, t1, t2, _) in caps.iter().filter(|c| c.1 == k) {
|
||||
use mt::Cut;
|
||||
if *kv == k + 1 {
|
||||
let _ = cv1.cut(*t1);
|
||||
let _ = cv2.cut(*t2);
|
||||
}
|
||||
}
|
||||
for (kv, _, t1, t2, _) in caps.iter().filter(|c| c.1 == k) {
|
||||
use mt::Cut;
|
||||
if *kv == k && k == 0 {
|
||||
cv1 = cv1.cut(*t1);
|
||||
cv2 = cv2.cut(*t2);
|
||||
}
|
||||
}
|
||||
let e1 = mt::Edge::new(a1, b1, cv1);
|
||||
let e2 = mt::Edge::new(a2, b2, cv2);
|
||||
let mut surface = fit.surface.clone();
|
||||
// Outward: toward the ball centre on a concave edge, away on a convex one.
|
||||
{
|
||||
@@ -678,6 +922,30 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
}
|
||||
let mut seam_subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
for (id, (seam, a, b)) in ends {
|
||||
// At a slanted end the blend may stop past the seam's old end: the seam then runs
|
||||
// on along its line or circle, within its planar face.
|
||||
let one_end = match (&a, &b) {
|
||||
(Some(nv), None) => Some((seam.front().clone(), nv.clone())),
|
||||
(None, Some(nv)) => Some((seam.back().clone(), nv.clone())),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((old, nv)) = one_end
|
||||
&& slanted.iter().any(|(k, ..)| verts.get(*k) == Some(&old))
|
||||
{
|
||||
let on_tol = (size * 1e-5).max(1e-6);
|
||||
let q = from_p3(nv.point());
|
||||
if crate::blend::on_edge(&seam, q, on_tol).is_none() {
|
||||
let fits = faces
|
||||
.iter()
|
||||
.filter(|f| f.edge_iter().any(|e| e.id() == id))
|
||||
.any(|f| crate::blend::plane_normal(f).is_some() && crate::blend::extension_fits(f, from_p3(old.point()), q, tol));
|
||||
if !fits || !crate::blend::on_extension(&seam, q, on_tol) {
|
||||
return Err(too_large("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
}
|
||||
seam_subst.insert(id, crate::blend::retarget(&seam, &old, &nv, on_tol)?);
|
||||
continue;
|
||||
}
|
||||
if !matches!(seam.curve(), mt::Curve::Line(_)) {
|
||||
seam_subst.insert(id, trim_curved_seam(&seam, a, b, size)?);
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//! The display mesh of a large body with small radii: bends and holes look round, the mesh
|
||||
//! stays closed, and the cost stays bounded.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
|
||||
use super::{Body, DISPLAY_ANGLE};
|
||||
use crate::{FaceSurface, extrude, face_surface};
|
||||
|
||||
/// The chord tolerance the application draws a body with.
|
||||
fn display_tol(b: &Body) -> f64 {
|
||||
(b.size() * 1e-3).max(1e-3)
|
||||
}
|
||||
|
||||
fn rect(w: f64, h: f64) -> Loop2 {
|
||||
Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)])
|
||||
}
|
||||
|
||||
/// A `length` long angle of 2 mm sheet: a 100 mm leg along X, a 90 degree bend of inner
|
||||
/// radius 1 (outer 3) about the line x = y = 3, and a 40 mm leg along Y, extruded along Z.
|
||||
fn bent_sheet(length: f64) -> Body {
|
||||
let line = |a: (f64, f64), b: (f64, f64)| Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) };
|
||||
let c = Vec2::new(3.0, 3.0);
|
||||
let outline = Loop2 {
|
||||
segs: vec![
|
||||
line((100.0, 0.0), (100.0, 2.0)),
|
||||
line((100.0, 2.0), (3.0, 2.0)),
|
||||
Seg2::Arc { center: c, radius: 1.0, start: 1.5 * PI, sweep: -FRAC_PI_2 },
|
||||
line((2.0, 3.0), (2.0, 40.0)),
|
||||
line((2.0, 40.0), (0.0, 40.0)),
|
||||
line((0.0, 40.0), (0.0, 3.0)),
|
||||
Seg2::Arc { center: c, radius: 3.0, start: PI, sweep: FRAC_PI_2 },
|
||||
line((3.0, 0.0), (100.0, 0.0)),
|
||||
],
|
||||
};
|
||||
assert!(outline.signed_area() > 0.0);
|
||||
extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0.0, length).unwrap().pop().unwrap()
|
||||
}
|
||||
|
||||
/// A 3000 x 1000 x 2 plate with round holes of `radius` at `centres`.
|
||||
fn plate_with_holes(centres: &[Vec2], radius: f64) -> Body {
|
||||
let holes = centres.iter().map(|c| Loop2::circle(*c, radius).reversed()).collect();
|
||||
extrude(&Plane::XY, &[Region2 { outer: rect(3000.0, 1000.0), holes }], 0.0, 2.0).unwrap().pop().unwrap()
|
||||
}
|
||||
|
||||
/// The faces of a body on cylinders of this radius.
|
||||
fn cylinder_faces(b: &Body, radius: f64) -> Vec<(usize, Vec3, Vec3)> {
|
||||
(0..b.face_count())
|
||||
.filter_map(|i| match face_surface(b, i)? {
|
||||
FaceSurface::Cylinder { origin, axis, radius: r } if (r - radius).abs() < 1e-6 => Some((i, origin, axis)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Distance of a point from an axis.
|
||||
fn radial(p: Vec3, origin: Vec3, axis: Vec3) -> f64 {
|
||||
let d = p - origin;
|
||||
(d - axis * d.dot(axis)).len()
|
||||
}
|
||||
|
||||
/// How a face on a cylinder is divided around its axis: the angles (radians, sorted, from the
|
||||
/// first) at which it has vertices.
|
||||
fn angles_around(m: &Mesh, face: usize, origin: Vec3, axis: Vec3) -> Vec<f64> {
|
||||
let u = axis.any_perp();
|
||||
let v = axis.cross(u);
|
||||
let mut a: Vec<f64> = m
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, f)| **f as usize == face)
|
||||
.flat_map(|(t, _)| t.iter())
|
||||
.filter_map(|k| m.positions.get(*k as usize))
|
||||
.map(|p| (*p - origin).dot(v).atan2((*p - origin).dot(u)))
|
||||
.collect();
|
||||
a.sort_by(f64::total_cmp);
|
||||
// Start at the widest gap, so an arc that crosses ±π stays in one piece.
|
||||
let n = a.len();
|
||||
let gap = |i: usize| (a[(i + 1) % n] - a[i]).rem_euclid(TAU);
|
||||
let start = (0..n).max_by(|i, j| gap(*i).total_cmp(&gap(*j))).map_or(0, |i| (i + 1) % n);
|
||||
let first = a[start];
|
||||
let mut out: Vec<f64> = (0..n).map(|i| (a[(start + i) % n] - first).rem_euclid(TAU)).collect();
|
||||
out.dedup_by(|x, y| (*x - *y).abs() < 1e-7);
|
||||
out
|
||||
}
|
||||
|
||||
/// The most a triangle of `face` lies inside the cylinder it is on (its corners are on it).
|
||||
fn sag(m: &Mesh, face: usize, origin: Vec3, axis: Vec3, radius: f64) -> f64 {
|
||||
let mut worst = 0.0f64;
|
||||
for (t, _) in m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == face) {
|
||||
let [a, b, c] = m.tri(t).unwrap();
|
||||
for p in [a, b, c] {
|
||||
assert!((radial(p, origin, axis) - radius).abs() < 1e-6, "a corner off the cylinder");
|
||||
}
|
||||
for p in [(a + b) * 0.5, (b + c) * 0.5, (c + a) * 0.5, (a + b + c) / 3.0] {
|
||||
worst = worst.max((radius - radial(p, origin, axis)).abs());
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
/// Triangle sides that are not shared by exactly two triangles once corners closer than `eps`
|
||||
/// are joined (a closed mesh has none).
|
||||
fn open_sides(m: &Mesh, eps: f64) -> usize {
|
||||
let cell = |p: &Vec3| ((p.x / eps).floor() as i64, (p.y / eps).floor() as i64, (p.z / eps).floor() as i64);
|
||||
let mut grid: HashMap<(i64, i64, i64), Vec<usize>> = HashMap::new();
|
||||
let mut points: Vec<Vec3> = Vec::new();
|
||||
let ids: Vec<usize> = m
|
||||
.positions
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (cx, cy, cz) = cell(p);
|
||||
for (dx, dy, dz) in (-1..=1).flat_map(|x| (-1..=1).flat_map(move |y| (-1..=1).map(move |z| (x, y, z)))) {
|
||||
if let Some(i) = grid.get(&(cx + dx, cy + dy, cz + dz)).and_then(|v| v.iter().find(|i| points[**i].dist(*p) < eps)) {
|
||||
return *i;
|
||||
}
|
||||
}
|
||||
points.push(*p);
|
||||
grid.entry((cx, cy, cz)).or_default().push(points.len() - 1);
|
||||
points.len() - 1
|
||||
})
|
||||
.collect();
|
||||
let mut sides: HashMap<(usize, usize), i32> = HashMap::new();
|
||||
for t in &m.triangles {
|
||||
let [a, b, c] = t.map(|k| ids[k as usize]);
|
||||
// A triangle at a pole has two corners in one point: no area, no sides.
|
||||
if a == b || b == c || c == a {
|
||||
continue;
|
||||
}
|
||||
for (p, q) in [(a, b), (b, c), (c, a)] {
|
||||
*sides.entry((p.min(q), p.max(q))).or_default() += 1;
|
||||
}
|
||||
}
|
||||
sides.values().filter(|n| **n != 2).count()
|
||||
}
|
||||
|
||||
/// The polyline segments around a circle of `radius` about `centre` in the plane z = `z`,
|
||||
/// and the widest angle one of them spans.
|
||||
fn rim(m: &Mesh, centre: Vec2, radius: f64, z: f64) -> (usize, f64) {
|
||||
let on = |p: &Vec3| (p.z - z).abs() < 1e-6 && (Vec2::new(p.x, p.y).dist(centre) - radius).abs() < 1e-6;
|
||||
let (mut segments, mut widest) = (0, 0.0f64);
|
||||
for e in m.edges.iter().filter(|e| e.len() >= 2 && e.iter().all(on)) {
|
||||
for w in e.windows(2) {
|
||||
segments += 1;
|
||||
widest = widest.max(2.0 * (w[0].dist(w[1]) / (2.0 * radius)).clamp(0.0, 1.0).asin());
|
||||
}
|
||||
}
|
||||
(segments, widest)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_bend_on_a_large_sheet_is_meshed_round() {
|
||||
let b = bent_sheet(3000.0);
|
||||
let tol = display_tol(&b);
|
||||
// The tolerance is larger than either side's sagitta: 1 - cos 45° of the radius.
|
||||
assert!(tol > 3.0 * (1.0 - (PI / 4.0).cos()));
|
||||
let coarse = b.display_mesh(tol).unwrap();
|
||||
let round = b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap();
|
||||
// A quarter turn in facets of at most DISPLAY_ANGLE.
|
||||
let facets = (FRAC_PI_2 / DISPLAY_ANGLE).ceil() as usize;
|
||||
assert_eq!(facets, 5);
|
||||
for radius in [1.0, 3.0] {
|
||||
let faces = cylinder_faces(&b, radius);
|
||||
assert_eq!(faces.len(), 1, "one bend face of radius {radius}");
|
||||
let (face, origin, axis) = faces[0];
|
||||
// The chord tolerance alone: one flat strip from tangent line to tangent line.
|
||||
assert_eq!(angles_around(&coarse, face, origin, axis).len(), 2, "radius {radius}");
|
||||
assert!(sag(&coarse, face, origin, axis, radius) > 0.29 * radius);
|
||||
let a = angles_around(&round, face, origin, axis);
|
||||
assert!(a.len() > facets, "radius {radius}: {} facets across the bend", a.len() - 1);
|
||||
assert!((a[a.len() - 1] - FRAC_PI_2).abs() < 1e-6, "the bend is a quarter turn: {a:?}");
|
||||
let widest = a.windows(2).map(|w| w[1] - w[0]).fold(0.0, f64::max);
|
||||
assert!(widest <= DISPLAY_ANGLE * 1.001, "radius {radius}: a facet spans {} degrees", widest.to_degrees());
|
||||
// Within the chord tolerance of the cylinder, and by much: a facet that turns no more
|
||||
// than the angle sags no more than 1 - cos(angle / 2) of the radius.
|
||||
let s = sag(&round, face, origin, axis, radius);
|
||||
assert!(s <= radius * (1.0 - (DISPLAY_ANGLE / 2.0).cos()) * 1.001 && s < tol, "radius {radius}: sag {s}");
|
||||
}
|
||||
// The flat faces along the sheet meet the bends in straight lines and need nothing more:
|
||||
// only the bends, and the two ends where their arcs now have more points, gain triangles.
|
||||
let count = |m: &Mesh, face: usize| m.tri_face.iter().filter(|f| **f as usize == face).count();
|
||||
let mut along = 0;
|
||||
for face in 0..b.face_count() {
|
||||
if let Some(FaceSurface::Plane { normal, .. }) = face_surface(&b, face)
|
||||
&& normal.dot(Vec3::Z).abs() < 0.5
|
||||
{
|
||||
along += 1;
|
||||
assert_eq!(count(&round, face), count(&coarse, face), "face {face}");
|
||||
}
|
||||
}
|
||||
assert_eq!(along, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_round_display_mesh_stays_closed_and_keeps_its_edges_and_bounds() {
|
||||
let b = bent_sheet(3000.0);
|
||||
let tol = display_tol(&b);
|
||||
let coarse = b.display_mesh(tol).unwrap();
|
||||
let round = b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap();
|
||||
// Neighbouring faces share the points of the edge between them.
|
||||
assert_eq!(open_sides(&round, 1e-6), 0);
|
||||
// The same edges in the same order, between the same faces, with the same ends and flags.
|
||||
assert_eq!(round.edges.len(), coarse.edges.len());
|
||||
assert_eq!(round.edge_faces, coarse.edge_faces);
|
||||
assert_eq!((&round.seams, &round.smooth_edges, &round.sharp_edges), (&coarse.seams, &coarse.smooth_edges, &coarse.sharp_edges));
|
||||
for (fine, rough) in round.edges.iter().zip(&coarse.edges) {
|
||||
assert!(fine.len() >= rough.len());
|
||||
assert!(fine[0].dist(rough[0]) < 1e-9 && fine[fine.len() - 1].dist(rough[rough.len() - 1]) < 1e-9);
|
||||
}
|
||||
// The body's box: flat faces and tangent lines bound it, so nothing moves.
|
||||
let (r, c) = (round.bounds(), coarse.bounds());
|
||||
assert!(r.min.dist(c.min) < 1e-9 && r.max.dist(c.max) < 1e-9);
|
||||
// Closer to the solid: the flat strips cut the outer bend's corner off and fill the inner one.
|
||||
let exact = crate::measure(&b).unwrap().volume;
|
||||
assert!((round.measure().volume - exact).abs() < (coarse.measure().volume - exact).abs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_hole_on_a_large_plate_has_a_round_rim() {
|
||||
let centre = Vec2::new(30.0, 20.0);
|
||||
let b = plate_with_holes(&[centre], 2.0);
|
||||
let tol = display_tol(&b);
|
||||
assert!(tol > 2.0, "the hole's radius is below the chord tolerance");
|
||||
let coarse = b.display_mesh(tol).unwrap();
|
||||
let round = b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap();
|
||||
let segments = (TAU / DISPLAY_ANGLE).ceil() as usize;
|
||||
assert_eq!(segments, 18);
|
||||
for z in [0.0, 2.0] {
|
||||
// The chord tolerance alone: each half circle is one segment, the rim a line.
|
||||
assert_eq!(rim(&coarse, centre, 2.0, z).0, 2);
|
||||
let (n, widest) = rim(&round, centre, 2.0, z);
|
||||
assert!(n >= segments, "{n} segments around the rim at z = {z}");
|
||||
assert!(widest <= DISPLAY_ANGLE * 1.001, "a segment spans {} degrees", widest.to_degrees());
|
||||
}
|
||||
// The hole's wall follows its rims.
|
||||
for (face, origin, axis) in cylinder_faces(&b, 2.0) {
|
||||
let a = angles_around(&round, face, origin, axis);
|
||||
assert!(a.windows(2).map(|w| w[1] - w[0]).fold(0.0, f64::max) <= DISPLAY_ANGLE * 1.001);
|
||||
assert!(sag(&round, face, origin, axis, 2.0) <= 2.0 * (1.0 - (DISPLAY_ANGLE / 2.0).cos()) * 1.001);
|
||||
}
|
||||
assert_eq!(open_sides(&round, 1e-6), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_arc_the_chord_tolerance_already_divides_finely_is_left_alone() {
|
||||
// A 40 mm cylinder at a tolerance of 0.01 mm: 64 facets around, far inside the angle.
|
||||
let b = crate::cylinder(Vec3::ZERO, Vec3::Z, 20.0, 30.0).unwrap();
|
||||
let coarse = b.display_mesh(0.01).unwrap();
|
||||
let round = b.display_mesh_angular(0.01, DISPLAY_ANGLE).unwrap();
|
||||
assert_eq!(round.positions, coarse.positions);
|
||||
assert_eq!(round.triangles, coarse.triangles);
|
||||
assert_eq!(round.edges, coarse.edges);
|
||||
// And a body with nothing curved at any tolerance.
|
||||
let b = crate::box_solid(Vec3::ZERO, Vec3::new(3000.0, 1000.0, 2.0)).unwrap();
|
||||
let tol = display_tol(&b);
|
||||
assert_eq!(b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap().positions, b.display_mesh(tol).unwrap().positions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radius_far_below_the_tolerance_keeps_its_chord_facets() {
|
||||
// A half circle of radius 0.002 in a 3 m plate sags less than a thousandth of the chord
|
||||
// tolerance: below that floor nothing is divided for the angle, however far the body
|
||||
// outgrows its smallest radius.
|
||||
let centre = Vec2::new(30.0, 20.0);
|
||||
let b = plate_with_holes(&[centre], 0.002);
|
||||
let tol = display_tol(&b);
|
||||
let coarse = b.display_mesh(tol).unwrap();
|
||||
let round = b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap();
|
||||
assert_eq!(round.triangles.len(), coarse.triangles.len());
|
||||
assert_eq!(round.edges, coarse.edges);
|
||||
// Just above the floor the rim is divided, but coarsely: the angle stops where the sag
|
||||
// falls below the floor, not where the facets reach the angle.
|
||||
let b = plate_with_holes(&[centre], 0.01);
|
||||
let round = b.display_mesh_angular(display_tol(&b), DISPLAY_ANGLE).unwrap();
|
||||
let (n, _) = rim(&round, centre, 0.01, 2.0);
|
||||
assert!((3..18).contains(&n), "{n} segments");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_face_crowded_with_holes_gets_a_coarser_angle_and_bounded_work() {
|
||||
// 1000 holes of diameter 4 in a 3 m plate. At the full angle the top and bottom faces
|
||||
// would each be bounded by more than 20 000 segments, and the work of triangulating a
|
||||
// face grows faster than its boundary.
|
||||
let centres: Vec<Vec2> = (0..50).flat_map(|i| (0..20).map(move |j| Vec2::new(30.0 + 60.0 * i as f64, 25.0 + 50.0 * j as f64))).collect();
|
||||
let b = plate_with_holes(¢res, 2.0);
|
||||
let tol = display_tol(&b);
|
||||
let coarse = b.display_mesh(tol).unwrap();
|
||||
let round = b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap();
|
||||
let boundary = |m: &Mesh, z: f64| m.edges.iter().filter(|e| e.iter().all(|p| (p.z - z).abs() < 1e-6)).map(|e| e.len() - 1).sum::<usize>();
|
||||
let budget = truck_meshalgo::tessellation::Deflection::MAX_ANGLE_BOUNDARY;
|
||||
for z in [0.0, 2.0] {
|
||||
assert_eq!(boundary(&coarse, z), 4 + 2 * centres.len());
|
||||
let n = boundary(&round, z);
|
||||
assert!(n > boundary(&coarse, z), "the rims are still rounder than the chord tolerance leaves them: {n}");
|
||||
assert!(n <= budget, "{n} segments bound the face at z = {z}");
|
||||
}
|
||||
// Every rim got the same share, and the walls follow their rims.
|
||||
let per_rim: Vec<usize> = centres.iter().map(|c| rim(&round, *c, 2.0, 2.0).0).collect();
|
||||
assert!(per_rim.iter().all(|n| *n == per_rim[0] && *n >= 3), "{:?}", &per_rim[..4]);
|
||||
assert!(
|
||||
round.triangles.len() < 4 * coarse.triangles.len(),
|
||||
"{} triangles, {} at the chord tolerance",
|
||||
round.triangles.len(),
|
||||
coarse.triangles.len()
|
||||
);
|
||||
assert_eq!(open_sides(&round, 1e-6), 0);
|
||||
// A plate with few holes keeps the full angle.
|
||||
let few = plate_with_holes(¢res[..40], 2.0);
|
||||
let round = few.display_mesh_angular(display_tol(&few), DISPLAY_ANGLE).unwrap();
|
||||
assert!(rim(&round, centres[0], 2.0, 2.0).0 >= 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_faces_of_every_kind_are_divided_by_the_angle() {
|
||||
// A sphere, a torus and a cone a few millimetres across, drawn at the 3 mm tolerance of a
|
||||
// body 3 m long: more than any of their sagittas.
|
||||
let disc = Region2 { outer: Loop2::circle(Vec2::ZERO, 4.0), holes: vec![] };
|
||||
let bodies = [
|
||||
("sphere", crate::sphere(Vec3::ZERO, 4.0).unwrap()),
|
||||
("torus", crate::torus(Vec3::ZERO, 6.0, 2.0).unwrap()),
|
||||
("cone", crate::extrude_tapered(&Plane::XY, &disc, 6.0, 1.0, -0.3).unwrap()),
|
||||
];
|
||||
// The most the surface normal turns along one side of a triangle of a curved face.
|
||||
let turn = |b: &Body, m: &Mesh| {
|
||||
let flat: Vec<bool> = (0..b.face_count()).map(|i| matches!(face_surface(b, i), Some(FaceSurface::Plane { .. }))).collect();
|
||||
m.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, f)| !flat[**f as usize])
|
||||
.flat_map(|(t, _)| [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])])
|
||||
.filter(|(a, b)| m.positions[*a as usize].dist(m.positions[*b as usize]) > 1e-9)
|
||||
.map(|(a, b)| m.normals[a as usize].dot(m.normals[b as usize]).clamp(-1.0, 1.0).acos())
|
||||
.fold(0.0, f64::max)
|
||||
};
|
||||
let check = |name: &str, b: &Body| {
|
||||
let coarse = b.display_mesh(3.0).unwrap();
|
||||
let round = b.display_mesh_angular(3.0, DISPLAY_ANGLE).unwrap();
|
||||
assert!(turn(b, &coarse) > 2.0 * DISPLAY_ANGLE, "{name}: the chord tolerance leaves facets of {} degrees", turn(b, &coarse).to_degrees());
|
||||
// A side crosses at most one facet in each direction.
|
||||
assert!(turn(b, &round) <= 1.5 * DISPLAY_ANGLE, "{name}: a side turns {} degrees", turn(b, &round).to_degrees());
|
||||
assert_eq!(open_sides(&round, 1e-6), 0, "{name}");
|
||||
// Bounded: no more than a full turn of facets each way on every face.
|
||||
assert!(round.triangles.len() <= b.face_count() * 2 * 36 * 36, "{name}: {} triangles", round.triangles.len());
|
||||
};
|
||||
for (name, b) in &bodies {
|
||||
check(name, b);
|
||||
}
|
||||
}
|
||||
+166
-49
@@ -199,60 +199,129 @@ fn merge_coplanar(shell: &mt::Shell, tol: f64, keep: &[Vec<Vec3>]) -> Option<(mt
|
||||
Some((out.into(), from))
|
||||
}
|
||||
|
||||
/// Join pairs of collinear straight edges at vertices only they use.
|
||||
/// Join runs of collinear straight edges at vertices only they use: each run becomes one edge,
|
||||
/// every face it borders is rebuilt once (linear in the shell, however long the runs; a faceted
|
||||
/// body such as a modeled thread has thousands of such edges).
|
||||
fn merge_collinear(shell: &mt::Shell, trace: &mut Trace) -> Option<mt::Shell> {
|
||||
let mut faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
|
||||
let mut changed = false;
|
||||
for _round in 0..10_000 {
|
||||
let mut at: HashMap<mt::VertexID, Vec<mt::Edge>> = HashMap::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for f in &faces {
|
||||
for e in f.edge_iter() {
|
||||
if seen.insert(e.id()) {
|
||||
at.entry(e.front().id()).or_default().push(e.absolute_clone());
|
||||
at.entry(e.back().id()).or_default().push(e.absolute_clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let pair = at.iter().find_map(|(v, es)| {
|
||||
let [a, b] = &es[..] else { return None };
|
||||
if !matches!(a.curve(), mt::Curve::Line(_)) || !matches!(b.curve(), mt::Curve::Line(_)) {
|
||||
return None;
|
||||
}
|
||||
let other = |e: &mt::Edge| if e.front().id() == *v { e.back().clone() } else { e.front().clone() };
|
||||
let mid = if a.front().id() == *v { a.front().clone() } else { a.back().clone() };
|
||||
let (pa, pm, pb) = (from_p3(other(a).point()), from_p3(mid.point()), from_p3(other(b).point()));
|
||||
let (u, w) = ((pm - pa).normalized()?, (pb - pm).normalized()?);
|
||||
(u.dot(w) > 1.0 - 1e-10).then(|| (a.clone(), b.clone(), other(a), other(b)))
|
||||
});
|
||||
let Some((a, b, va, vb)) = pair else { break };
|
||||
let joined = builder::line(&va, &vb);
|
||||
trace.made(&joined, [a.id(), b.id()]);
|
||||
for f in &mut faces {
|
||||
if !f.edge_iter().any(|e| e.id() == a.id()) {
|
||||
let mut edges: Vec<mt::Edge> = Vec::new();
|
||||
let mut index: HashMap<mt::EdgeID, usize> = HashMap::new();
|
||||
let mut at: HashMap<mt::VertexID, Vec<usize>> = HashMap::new();
|
||||
for f in &faces {
|
||||
for e in f.edge_iter() {
|
||||
if index.contains_key(&e.id()) {
|
||||
continue;
|
||||
}
|
||||
let mut wires = Vec::new();
|
||||
for w in f.absolute_boundaries() {
|
||||
let mut es: Vec<mt::Edge> = Vec::new();
|
||||
for e in w.edge_iter() {
|
||||
if e.id() == b.id() {
|
||||
continue;
|
||||
}
|
||||
if e.id() == a.id() {
|
||||
// a+b (in either order) become the joined edge, in this wire's direction.
|
||||
es.push(if e.front() == &va { joined.clone() } else { joined.inverse() });
|
||||
continue;
|
||||
}
|
||||
es.push(e.clone());
|
||||
}
|
||||
wires.push(mt::Wire::from(es));
|
||||
}
|
||||
*f = absolute_face(f, wires)?;
|
||||
index.insert(e.id(), edges.len());
|
||||
at.entry(e.front().id()).or_default().push(edges.len());
|
||||
at.entry(e.back().id()).or_default().push(edges.len());
|
||||
edges.push(e.absolute_clone());
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
changed.then(|| faces.into())
|
||||
// Edges that continue each other in line through a vertex nothing else uses.
|
||||
let mut parent: Vec<usize> = (0..edges.len()).collect();
|
||||
let mut any = false;
|
||||
for (v, es) in &at {
|
||||
let [ia, ib] = es[..] else { continue };
|
||||
let (Some(a), Some(b)) = (edges.get(ia), edges.get(ib)) else { continue };
|
||||
if !matches!(a.curve(), mt::Curve::Line(_)) || !matches!(b.curve(), mt::Curve::Line(_)) {
|
||||
continue;
|
||||
}
|
||||
let other = |e: &mt::Edge| if e.front().id() == *v { e.back().clone() } else { e.front().clone() };
|
||||
let mid = if a.front().id() == *v { a.front().clone() } else { a.back().clone() };
|
||||
let (pa, pm, pb) = (from_p3(other(a).point()), from_p3(mid.point()), from_p3(other(b).point()));
|
||||
let (Some(u), Some(w)) = ((pm - pa).normalized(), (pb - pm).normalized()) else { continue };
|
||||
if u.dot(w) > 1.0 - 1e-10 {
|
||||
let (ra, rb) = (find(&mut parent, ia), find(&mut parent, ib));
|
||||
if ra != rb
|
||||
&& let Some(x) = parent.get_mut(ra)
|
||||
{
|
||||
*x = rb;
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return None;
|
||||
}
|
||||
let mut runs: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
for i in 0..edges.len() {
|
||||
let r = find(&mut parent, i);
|
||||
runs.entry(r).or_default().push(i);
|
||||
}
|
||||
// Each run's joined edge, and the position of its vertices along it.
|
||||
let mut joined: Vec<(mt::Edge, HashMap<mt::VertexID, usize>)> = Vec::new();
|
||||
let mut run_of: HashMap<mt::EdgeID, usize> = HashMap::new();
|
||||
for members in runs.values().filter(|m| m.len() > 1) {
|
||||
let mut ends: HashMap<mt::VertexID, (usize, mt::Vertex)> = HashMap::new();
|
||||
for i in members {
|
||||
let Some(e) = edges.get(*i) else { continue };
|
||||
for v in [e.front(), e.back()] {
|
||||
ends.entry(v.id()).or_insert((0, v.clone())).0 += 1;
|
||||
}
|
||||
}
|
||||
let mut ends = ends.into_values().filter(|(n, _)| *n == 1).map(|(_, v)| v);
|
||||
let (Some(first), Some(last), None) = (ends.next(), ends.next(), ends.next()) else { continue };
|
||||
// Walk the run from one end, collecting its vertices in order.
|
||||
let mut order = vec![first.id()];
|
||||
let (mut cur, mut prev) = (first.id(), usize::MAX);
|
||||
for _ in 0..members.len() {
|
||||
let next = at.get(&cur).and_then(|es| es.iter().copied().find(|i| *i != prev && members.contains(i)));
|
||||
let Some(e) = next.and_then(|i| edges.get(i).map(|e| (i, e))) else { break };
|
||||
cur = if e.1.front().id() == cur { e.1.back().id() } else { e.1.front().id() };
|
||||
prev = e.0;
|
||||
order.push(cur);
|
||||
}
|
||||
if order.last() != Some(&last.id()) || order.len() != members.len() + 1 {
|
||||
continue;
|
||||
}
|
||||
// Every piece must lie along the whole run, not just along its neighbour.
|
||||
let Some(dir) = (from_p3(last.point()) - from_p3(first.point())).normalized() else { continue };
|
||||
let straight = members
|
||||
.iter()
|
||||
.filter_map(|i| edges.get(*i))
|
||||
.all(|e| (from_p3(e.back().point()) - from_p3(e.front().point())).normalized().is_some_and(|d| d.dot(dir).abs() > 1.0 - 1e-10));
|
||||
if !straight {
|
||||
continue;
|
||||
}
|
||||
let line = builder::line(&first, &last);
|
||||
trace.made_of(&line, members.iter().filter_map(|i| edges.get(*i)).map(|e| e.id()));
|
||||
for i in members {
|
||||
if let Some(e) = edges.get(*i) {
|
||||
run_of.insert(e.id(), joined.len());
|
||||
}
|
||||
}
|
||||
joined.push((line, order.into_iter().enumerate().map(|(k, v)| (v, k)).collect()));
|
||||
}
|
||||
if joined.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for f in &mut faces {
|
||||
if !f.edge_iter().any(|e| run_of.contains_key(&e.id())) {
|
||||
continue;
|
||||
}
|
||||
let mut wires = Vec::new();
|
||||
for w in f.absolute_boundaries() {
|
||||
let mut done = std::collections::HashSet::new();
|
||||
let mut es: Vec<mt::Edge> = Vec::new();
|
||||
for e in w.edge_iter() {
|
||||
let Some(r) = run_of.get(&e.id()).copied() else {
|
||||
es.push(e.clone());
|
||||
continue;
|
||||
};
|
||||
// The run's pieces become its joined edge, in this wire's direction.
|
||||
if done.insert(r)
|
||||
&& let Some((line, pos)) = joined.get(r)
|
||||
{
|
||||
let forward = pos.get(&e.front().id()) < pos.get(&e.back().id());
|
||||
es.push(if forward { line.clone() } else { line.inverse() });
|
||||
}
|
||||
}
|
||||
wires.push(mt::Wire::from(es));
|
||||
}
|
||||
*f = absolute_face(f, wires)?;
|
||||
}
|
||||
Some(faces.into())
|
||||
}
|
||||
|
||||
/// The exact curve an approximate edge (a boolean's intersection curve) really is: a line or a
|
||||
@@ -504,10 +573,15 @@ impl Trace {
|
||||
|
||||
/// `edge` was made from the live edges `from`.
|
||||
fn made<const N: usize>(&mut self, edge: &mt::Edge, from: [mt::EdgeID; N]) {
|
||||
self.made_of(edge, from);
|
||||
}
|
||||
|
||||
/// `edge` was made from the live edges `from`, however many.
|
||||
fn made_of(&mut self, edge: &mt::Edge, from: impl IntoIterator<Item = mt::EdgeID>) {
|
||||
if !self.on {
|
||||
return;
|
||||
}
|
||||
let mut under: Vec<usize> = from.iter().flat_map(|id| self.of(id)).collect();
|
||||
let mut under: Vec<usize> = from.into_iter().flat_map(|id| self.of(&id)).collect();
|
||||
under.sort_unstable();
|
||||
under.dedup();
|
||||
self.made.insert(edge.id(), under);
|
||||
@@ -571,6 +645,49 @@ fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool) -> Op
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Body;
|
||||
|
||||
/// Distinct faces, edges and vertices of a shell (its iterators repeat shared ones).
|
||||
fn counts(shell: &mt::Shell) -> (usize, usize, usize) {
|
||||
let edges: std::collections::HashSet<_> = shell.edge_iter().map(|e| e.id()).collect();
|
||||
let vertices: std::collections::HashSet<_> = shell.vertex_iter().map(|v| v.id()).collect();
|
||||
(shell.face_iter().count(), edges.len(), vertices.len())
|
||||
}
|
||||
|
||||
/// A prism whose front face was cut into `n` strips along a single line: the strips merge
|
||||
/// into one face, which leaves `n` collinear pieces on each of its long edges.
|
||||
fn strip_prism(n: usize) -> Solid {
|
||||
let pts: Vec<mt::Vertex> = (0..=n)
|
||||
.map(|k| builder::vertex(mt::Point3::new(k as f64, 0.0, 0.0)))
|
||||
.chain([builder::vertex(mt::Point3::new(n as f64, 3.0, 0.0)), builder::vertex(mt::Point3::new(0.0, 3.0, 0.0))])
|
||||
.collect();
|
||||
let edges: Vec<mt::Edge> = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| builder::line(a, b)).collect();
|
||||
let face = builder::try_attach_plane(&[mt::Wire::from(edges)]).expect("a plane");
|
||||
builder::tsweep(&face, mt::Vector3::new(0.0, 0.0, 2.0))
|
||||
}
|
||||
|
||||
/// Runs of collinear edges join in one pass, however long: past the 10 000 pieces the old
|
||||
/// pairwise loop stopped at, and in time (a modeled thread has thousands of such pieces,
|
||||
/// and filleting beside one froze the app).
|
||||
#[test]
|
||||
fn long_collinear_runs_join_whole() {
|
||||
let n = 12_000;
|
||||
let started = std::time::Instant::now();
|
||||
let healed = heal(strip_prism(n), n as f64);
|
||||
let shell = healed.boundaries().first().expect("a shell").clone();
|
||||
assert_eq!(counts(&shell), (6, 12, 8));
|
||||
let body = Body::new(healed).expect("a valid body");
|
||||
let volume = crate::measure::measure(&body).expect("measures").volume;
|
||||
assert!((volume - n as f64 * 3.0 * 2.0).abs() < 1e-6 * volume, "{volume}");
|
||||
assert!(started.elapsed().as_secs() < 30, "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_collinear_runs_join_and_keep_the_corners() {
|
||||
let healed = heal(strip_prism(3), 3.0);
|
||||
let shell = healed.boundaries().first().expect("a shell").clone();
|
||||
assert_eq!(counts(&shell), (6, 12, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approximate_arcs_become_exact() {
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
//! the operation's own operand list, which each operation documents. A body made by an
|
||||
//! operation carries its history (see [`Body::history`]); [`History::substitute`] composes an
|
||||
//! operation with one that made one of its operands.
|
||||
//!
|
||||
//! This is what persistent names and face attachments stand on. The document composes each
|
||||
//! feature's histories into face names (`F5:side:l1`, `F7:blend:…`, `F4:[email protected]`, see
|
||||
//! `solidcraft_doc::history_names`); a name is exact only when every operation on the way
|
||||
//! reported its history. A flat face with an exact name takes a durable sketch attachment by that
|
||||
//! name (`FaceCertificate::Lineage`, docs/face-attachments.md), so an operation that records its
|
||||
//! history here gives its faces attachments with no attachment code of its own. An operation
|
||||
//! that can't should leave its result without one (`Body::without_history`), never guess.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
@@ -13,7 +21,7 @@ use crate::body::{Body, FaceSource, Solid};
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
/// Where a result face came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FaceOrigin {
|
||||
/// A face of input `operand` (its index in the operand's face order).
|
||||
Face { operand: usize, face: usize },
|
||||
@@ -28,7 +36,7 @@ pub enum FaceOrigin {
|
||||
}
|
||||
|
||||
/// How a result face relates to its origins.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Change {
|
||||
/// The same face as its origin (same region).
|
||||
Kept,
|
||||
@@ -42,7 +50,7 @@ pub enum Change {
|
||||
Generated,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FaceProv {
|
||||
pub origins: Vec<FaceOrigin>,
|
||||
pub change: Change,
|
||||
@@ -51,7 +59,7 @@ pub struct FaceProv {
|
||||
/// One side of a meeting: the origins of the face on that side of the edge. One origin, or
|
||||
/// several where that face was made from several (a merged face) and the edge lies along it
|
||||
/// as a whole. Never empty; sorted, without repeats, so equal sides compare equal.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Side(Vec<FaceOrigin>);
|
||||
|
||||
impl Side {
|
||||
@@ -99,7 +107,7 @@ impl From<FaceOrigin> for Side {
|
||||
}
|
||||
|
||||
/// Where a result edge came from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum EdgeOrigin {
|
||||
/// (Part of) edge `edge` of input `operand`.
|
||||
Edge { operand: usize, edge: usize },
|
||||
@@ -110,14 +118,14 @@ pub enum EdgeOrigin {
|
||||
|
||||
/// A result edge: the result faces it separates and what it came from (empty when it is new
|
||||
/// and lies between faces new to the operation).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct EdgeProv {
|
||||
pub faces: Vec<usize>,
|
||||
pub origins: Vec<EdgeOrigin>,
|
||||
}
|
||||
|
||||
/// History of an operation's result: one entry per face and per edge, in the result's orders.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct History {
|
||||
/// How many operands the operation had.
|
||||
pub operands: usize,
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Issue #616: small boxes joined flush to the walls and floor of a part never change it away
|
||||
//! from the box, and a retry that would is refused.
|
||||
|
||||
use solidcraft_geom::{Aabb3, Mesh, Vec3};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn bx(c: [f64; 3], s: [f64; 3]) -> Body {
|
||||
box_solid(Vec3::new(c[0], c[1], c[2]), Vec3::new(c[0] + s[0], c[1] + s[1], c[2] + s[2])).unwrap()
|
||||
}
|
||||
|
||||
fn volume(b: &Body) -> f64 {
|
||||
measure(b).unwrap().volume
|
||||
}
|
||||
|
||||
/// The lid of the issue (public dimensions): a plate with rounded corners, a skirt ring on
|
||||
/// it, a window and a slot cut through, and a notch cut in the skirt.
|
||||
fn lid() -> Body {
|
||||
let plate = bx([100., 0., 0.], [76.8, 89.3, 2.4]);
|
||||
let corners: Vec<Vec3> = [[100., 0.], [176.8, 0.], [100., 89.3], [176.8, 89.3]].iter().map(|p| Vec3::new(p[0], p[1], 1.2)).collect();
|
||||
let plate = fillet(&plate, &corners, 3.0).unwrap();
|
||||
let ring = boolean(&bx([102.65, 2.65, 2.4], [71.5, 84., 3.]), &bx([104.15, 4.15, 2.0], [68.5, 81., 4.]), BoolOp::Cut).unwrap().unwrap();
|
||||
let mut b = boolean(&plate, &ring, BoolOp::Union).unwrap().unwrap();
|
||||
for (c, s) in [([104.4, 5.4, -1.], [68., 61., 8.]), ([116.4, 72., -1.], [44., 10., 8.]), ([127.4, 84., 2.4], [22., 6., 4.])] {
|
||||
b = boolean(&b, &bx(c, s), BoolOp::Cut).unwrap().unwrap();
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Distance from `p` to the triangle `abc` (brute force, independent of the guard's grid).
|
||||
fn tri_dist(p: Vec3, [a, b, c]: [Vec3; 3]) -> f64 {
|
||||
let n = (b - a).cross(c - a);
|
||||
let Some(u) = n.normalized() else { return f64::INFINITY };
|
||||
let h = (p - a).dot(u);
|
||||
let q = p - u * h;
|
||||
if [(a, b), (b, c), (c, a)].iter().all(|(x, y)| (*y - *x).cross(q - *x).dot(n) >= 0.0) {
|
||||
h.abs()
|
||||
} else {
|
||||
[(a, b), (b, c), (c, a)].iter().map(|(x, y)| p.dist_to_segment(*x, *y)).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
}
|
||||
|
||||
/// A point of `from`'s surface outside `region` that is not on `onto`'s surface.
|
||||
fn moved_outside(from: &Mesh, onto: &Mesh, region: &Aabb3, tol: f64) -> Option<Vec3> {
|
||||
let tris: Vec<[Vec3; 3]> = onto.triangles.iter().filter_map(|t| onto.tri(t)).collect();
|
||||
from.triangles.iter().filter_map(|t| from.tri(t)).find_map(|[a, b, c]| {
|
||||
let p = (a + b + c) / 3.0;
|
||||
let inside = p.x > region.min.x && p.y > region.min.y && p.z > region.min.z && p.x < region.max.x && p.y < region.max.y && p.z < region.max.z;
|
||||
(!inside && !tris.iter().any(|t| tri_dist(p, *t) < tol)).then_some(p)
|
||||
})
|
||||
}
|
||||
|
||||
/// The join of `tool` onto `target` left `target` as it was away from the tool: no new surface
|
||||
/// (a slit, a moved face) and none lost, outside the tool's box.
|
||||
fn assert_local(target: &Body, tool: &Body, joined: &Body, what: &str) {
|
||||
let tol = 1e-3;
|
||||
let (mt, mtool, mj) = (target.tessellate(0.01).unwrap(), tool.tessellate(0.01).unwrap(), joined.tessellate(0.01).unwrap());
|
||||
let mut region = mtool.bounds();
|
||||
region.min -= Vec3::new(0.01, 0.01, 0.01);
|
||||
region.max += Vec3::new(0.01, 0.01, 0.01);
|
||||
assert_eq!(moved_outside(&mj, &mt, ®ion, tol), None, "{what}: new surface away from the tool");
|
||||
assert_eq!(moved_outside(&mt, &mj, ®ion, tol), None, "{what}: surface lost away from the tool");
|
||||
}
|
||||
|
||||
/// The four ribs of the issue, 1.6 × 6 × 11.7, sit on the plate (z = 2.4), over the skirt, flush
|
||||
/// with the window's sides. Each join adds exactly what lies outside the skirt, changes nothing
|
||||
/// else (it used to leave a slit through the far wall), and needs no displacement of the tool.
|
||||
#[test]
|
||||
fn ribs_joined_flush_to_the_lid_change_nothing_else() {
|
||||
let mut b = lid();
|
||||
assert!((volume(&b) - 6703.5222).abs() < 1e-3, "{}", volume(&b));
|
||||
for c in [[102.8, 14., 2.4], [102.8, 48., 2.4], [172.4, 14., 2.4], [172.4, 48., 2.4]] {
|
||||
let tool = bx(c, [1.6, 6., 11.7]);
|
||||
let (r, report) = capture_boolean_diagnostics(|| boolean(&b, &tool, BoolOp::Union));
|
||||
let r = r.unwrap().unwrap();
|
||||
// 1.6 × 6 × 11.7 less the 1.35 × 6 × 3 inside the skirt.
|
||||
assert!((volume(&r) - volume(&b) - 88.02).abs() < 1e-6, "{c:?}: {}", volume(&r) - volume(&b));
|
||||
assert!(!report.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{c:?}: {report:?}");
|
||||
assert_eq!(r.solid.boundaries().len(), 1);
|
||||
assert_local(&b, &tool, &r, &format!("{c:?}"));
|
||||
b = r;
|
||||
}
|
||||
let bounds = b.tessellate(0.01).unwrap().bounds();
|
||||
assert!((bounds.max.z - 14.1).abs() < 1e-9, "the ribs keep their height: {}", bounds.max.z);
|
||||
}
|
||||
|
||||
/// A plane cut through a frame meets it in two faces; gluing the halves back joins both (one
|
||||
/// left over was the slit of #616).
|
||||
#[test]
|
||||
fn glue_joins_every_face_the_pieces_share() {
|
||||
let frame = boolean(&bx([0., 0., 0.], [40., 30., 5.]), &bx([5., 5., -1.], [30., 20., 7.]), BoolOp::Cut).unwrap().unwrap();
|
||||
let low = boolean(&frame, &bx([-1., -1., -1.], [42., 16., 7.]), BoolOp::Intersect).unwrap().unwrap();
|
||||
let high = boolean(&frame, &bx([-1., 15., -1.], [42., 16., 7.]), BoolOp::Intersect).unwrap().unwrap();
|
||||
let glued = crate::coplanar::glue(&low, &high).unwrap().unwrap();
|
||||
assert_eq!(glued.solid.boundaries().len(), 1);
|
||||
assert!((volume(&glued) - volume(&frame)).abs() < 1e-6);
|
||||
assert!((measure(&glued).unwrap().area - measure(&frame).unwrap().area).abs() < 1e-6, "no faces left inside");
|
||||
}
|
||||
|
||||
/// The guard refuses a result that changed the target away from the tool, or lost volume in a
|
||||
/// join, and accepts the right one.
|
||||
#[test]
|
||||
fn the_retry_guard_refuses_changes_away_from_the_tool() {
|
||||
let target = bx([0., 0., 0.], [40., 30., 5.]);
|
||||
let tool = bx([0., 0., 5.], [4., 4., 4.]);
|
||||
let right = boolean(&target, &tool, BoolOp::Union).unwrap().unwrap();
|
||||
assert!(crate::boolean_guard::check_retry(&target, &tool, BoolOp::Union, Some(&right)).is_ok());
|
||||
// A notch far from the tool (volume still up: the tool adds more than the notch takes).
|
||||
let notched = boolean(&right, &bx([30., 10., 4.], [2., 2., 2.]), BoolOp::Cut).unwrap().unwrap();
|
||||
let e = crate::boolean_guard::check_retry(&target, &tool, BoolOp::Union, Some(¬ched)).unwrap_err().to_string();
|
||||
assert!(e.contains("away from the tool") && e.contains("refused"), "{e}");
|
||||
// A join that lost volume.
|
||||
let e = crate::boolean_guard::check_retry(&target, &tool, BoolOp::Union, Some(&bx([0., 0., 0.], [40., 30., 4.]))).unwrap_err().to_string();
|
||||
assert!(e.contains("volume"), "{e}");
|
||||
// A cut that comes out empty while the tool covers only part of the target.
|
||||
assert!(crate::boolean_guard::check_retry(&target, &tool, BoolOp::Cut, None).is_err());
|
||||
assert!(crate::boolean_guard::check_retry(&tool, &target, BoolOp::Cut, None).is_err());
|
||||
assert!(crate::boolean_guard::check_retry(&tool, &bx([-1., -1., 4.], [6., 6., 6.]), BoolOp::Cut, None).is_ok());
|
||||
}
|
||||
|
||||
/// Random small boxes joined flush to the walls and floor of a shelled box with rounded corners
|
||||
/// (sitting on the floor, against a wall, flush with a wall's top or into its corner), and of
|
||||
/// the same box with a window through its floor (on the ledge beside the window, flush with its
|
||||
/// side, as the ribs of #616), never lose volume and never change the part away from the box. A
|
||||
/// join that fails is an error, never a wrong result.
|
||||
#[test]
|
||||
fn boxes_joined_flush_to_a_shelled_box_stay_local() {
|
||||
let (w, d, h, t) = (60.0, 40.0, 20.0, 2.0);
|
||||
let outer = bx([0., 0., 0.], [w, d, h]);
|
||||
let corners: Vec<Vec3> = [[0., 0.], [w, 0.], [0., d], [w, d]].iter().map(|p| Vec3::new(p[0], p[1], h / 2.0)).collect();
|
||||
let outer = fillet(&outer, &corners, 3.0).unwrap();
|
||||
let shelled = boolean(&outer, &bx([t, t, t], [w - 2. * t, d - 2. * t, h]), BoolOp::Cut).unwrap().unwrap();
|
||||
// The window leaves a 1.5 ledge inside the walls.
|
||||
let (ledge, gap) = (t + 1.5, 6.0);
|
||||
let framed = boolean(&shelled, &bx([ledge, gap, -1.], [w - 2. * ledge, d - 2. * gap, t + 2.]), BoolOp::Cut).unwrap().unwrap();
|
||||
let mut seed = 0x616_u64;
|
||||
let mut next = |lo: f64, hi: f64| {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
lo + (hi - lo) * ((seed >> 11) as f64 / (1u64 << 53) as f64)
|
||||
};
|
||||
// Sizes and places on a 0.1 grid, like typed values.
|
||||
let q = |x: f64| (x * 10.0).round() / 10.0;
|
||||
let (mut joined, mut refused) = (0, 0);
|
||||
for (name, target) in [("shelled", &shelled), ("framed", &framed)] {
|
||||
let base = volume(target);
|
||||
for case in 0..20 {
|
||||
let s = [q(next(1.0, 6.0)), q(next(1.0, 6.0)), q(next(1.0, 12.0))];
|
||||
let (x, y) = (q(next(t + 1.0, w - t - 8.0)), q(next(gap, d - gap - 8.0)));
|
||||
let c = match (name, case % 4) {
|
||||
// On the floor.
|
||||
("shelled", 0) => [x, y, t],
|
||||
// On the floor against the -x wall.
|
||||
("shelled", 1) => [t, y, t],
|
||||
// Into the +y wall, flush with its top, overlapping it.
|
||||
("shelled", 2) => [x, d - t - s[1] / 2.0, h - s[2]],
|
||||
// On the floor in the corner of the -x and -y walls, sunk into both.
|
||||
("shelled", _) => [t - 0.5, t - 0.5, t],
|
||||
// On the ledge over the wall, flush with the window's -x or +x side.
|
||||
(_, 0) => [ledge - 1.6, y, t],
|
||||
(_, 1) => [w - ledge, y, t],
|
||||
// On the ledge against the -x wall, short of the window.
|
||||
(_, 2) => [t, y, t],
|
||||
// Into the +x wall from the ledge, flush with the window's side.
|
||||
_ => [w - ledge, y, t],
|
||||
};
|
||||
let s = if name == "framed" && case % 4 != 2 { [1.6, s[1], s[2]] } else { s };
|
||||
let tool = bx(c, s);
|
||||
let what = format!("{name} case {case}: corner {c:?} size {s:?}");
|
||||
match boolean(target, &tool, BoolOp::Union) {
|
||||
Ok(Some(r)) => {
|
||||
assert!(volume(&r) >= base - 1e-6, "{what}: the join lost volume ({} < {base})", volume(&r));
|
||||
assert!(volume(&r) <= base + volume(&tool) + 1e-6, "{what}");
|
||||
assert_local(target, &tool, &r, &what);
|
||||
joined += 1;
|
||||
}
|
||||
Ok(None) => panic!("{what}: a join came out empty"),
|
||||
Err(e) => {
|
||||
eprintln!("{what}: refused: {e}");
|
||||
refused += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!((joined, refused), (40, 0), "every one of these joins works");
|
||||
}
|
||||
@@ -17,6 +17,7 @@ mod blend_corner;
|
||||
mod blend_variable;
|
||||
mod body;
|
||||
mod boolean_diagnostics;
|
||||
mod boolean_guard;
|
||||
mod boolean_record;
|
||||
mod bore_guard;
|
||||
mod build;
|
||||
@@ -36,6 +37,8 @@ mod iges_in;
|
||||
mod iges_out;
|
||||
#[cfg(test)]
|
||||
mod intersection_curve_tests;
|
||||
#[cfg(test)]
|
||||
mod issue_616_tests;
|
||||
mod lip;
|
||||
mod loopblend;
|
||||
mod measure;
|
||||
@@ -43,6 +46,7 @@ mod mesh_convert;
|
||||
mod meshbody;
|
||||
#[cfg(test)]
|
||||
mod modify_history_tests;
|
||||
mod native;
|
||||
mod numbered;
|
||||
mod offset;
|
||||
mod ops;
|
||||
@@ -69,10 +73,10 @@ mod thread_tests;
|
||||
mod topo;
|
||||
mod tspline;
|
||||
|
||||
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_max};
|
||||
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_max, fillet_max_each};
|
||||
pub use blend_corner::fillet_radii;
|
||||
pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points};
|
||||
pub use body::{Body, CapSignature, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
|
||||
pub use build::{PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
|
||||
pub use delete_face::delete_faces;
|
||||
@@ -84,6 +88,7 @@ pub use lip::raise_band;
|
||||
pub use measure::{BodyMeasure, measure};
|
||||
pub use mesh_convert::convert_prismatic_mesh;
|
||||
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||
pub use native::{decode_native_body, encode_native_body, native_preview_mesh};
|
||||
pub use offset::with_same_surface;
|
||||
pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix, union_apart};
|
||||
pub use polybool::{faceted_boolean, planar_boolean};
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! SolidCraft native, versioned binary geometry. No STEP conversion or display meshes.
|
||||
//! A cache remains provisional until the document is rebuilt; hashes are not trust.
|
||||
use crate::{Body, KernelError, Result, guard};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Number, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
mod adapter;
|
||||
mod binary;
|
||||
mod metadata;
|
||||
mod preview;
|
||||
pub use preview::native_preview_mesh;
|
||||
|
||||
// 01/02 were development-only prototypes; independent metadata records start at 03.
|
||||
const MAGIC: &[u8; 8] = b"SCBREP03";
|
||||
const MAX_BYTES: usize = 64 << 20;
|
||||
const MAX_ITEMS: usize = 100_000;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SavedBody {
|
||||
solid: solidcraft_geom::brep::Brep,
|
||||
metadata: solidcraft_geom::brep::envelope::Metadata,
|
||||
}
|
||||
fn invalid(detail: impl std::fmt::Display) -> KernelError {
|
||||
KernelError::Invalid(format!("native B-rep: {detail}"))
|
||||
}
|
||||
|
||||
/// Explicit topology, spatial geometry, construction certificates and history.
|
||||
/// Procedural intersections use bounded fitting for provisional cache display.
|
||||
/// Failure is a cache miss at the IO boundary, never a failed document save.
|
||||
pub fn encode_native_body(body: &Body) -> Result<Vec<u8>> {
|
||||
guard("encode native B-rep", || {
|
||||
for source in body.face_sources.iter().flat_map(|s| s.iter()).flatten() {
|
||||
let mut at = source;
|
||||
let mut depth = 0usize;
|
||||
while let crate::FaceSource::ShellPlanar { source, .. } = at {
|
||||
depth += 1;
|
||||
if depth > 32 {
|
||||
return Err(invalid("face-source nesting"));
|
||||
}
|
||||
at = source;
|
||||
}
|
||||
}
|
||||
if body.face_count() > MAX_ITEMS
|
||||
|| body.solid.edge_iter().take(MAX_ITEMS + 1).count() > MAX_ITEMS
|
||||
|| body.mesh.as_ref().is_some_and(|m| m.positions.len() > MAX_ITEMS || m.triangles.len() > MAX_ITEMS)
|
||||
{
|
||||
return Err(invalid("native geometry budget"));
|
||||
}
|
||||
if body.mesh.is_some() {
|
||||
return Err(invalid("mesh bodies are source blobs, not B-rep"));
|
||||
}
|
||||
let saved = SavedBody { solid: adapter::encode(&body.solid)?, metadata: metadata::encode(body)? };
|
||||
// Structural validation does not invoke another geometric reconstruction.
|
||||
validate_topology(&saved)?;
|
||||
let value = serde_json::to_value(saved).map_err(invalid)?;
|
||||
validate_value(&value, 0)?;
|
||||
if value_nodes(&value) > 2_000_000 {
|
||||
return Err(invalid("value budget"));
|
||||
}
|
||||
let mut out = MAGIC.to_vec();
|
||||
binary::write(&value, &mut out, 0)?;
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode bounded untrusted data. Validate indices before topology extraction.
|
||||
/// Callers must not treat the decoded body as authoritative editing geometry.
|
||||
pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
|
||||
if bytes.len() > MAX_BYTES || bytes.get(..8) != Some(MAGIC.as_slice()) {
|
||||
return Err(invalid("version or size"));
|
||||
}
|
||||
guard("decode native B-rep", || {
|
||||
let mut input = bytes.get(8..).ok_or_else(|| invalid("header"))?;
|
||||
let mut remaining = 2_000_000usize;
|
||||
let value = binary::read(&mut input, 0, &mut remaining)?;
|
||||
if !input.is_empty() {
|
||||
return Err(invalid("trailing data"));
|
||||
}
|
||||
validate_value(&value, 0)?;
|
||||
let saved: SavedBody = serde_json::from_value(value).map_err(invalid)?;
|
||||
validate_topology(&saved)?;
|
||||
let solid = adapter::decode(saved.solid)?;
|
||||
let mut body = Body {
|
||||
solid: Arc::new(solid),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
color: None,
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
history: None,
|
||||
regularization_protected: false,
|
||||
};
|
||||
metadata::apply(&mut body, saved.metadata)?;
|
||||
Ok(body)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_topology(saved: &SavedBody) -> Result<()> {
|
||||
saved.solid.validate().map_err(invalid)?;
|
||||
let faces: usize = saved.solid.shells.iter().map(|s| s.faces.len()).sum();
|
||||
let edges: usize = saved.solid.shells.iter().map(|s| s.edges.len()).sum();
|
||||
let saved = &saved.metadata;
|
||||
if saved.version != solidcraft_geom::brep::envelope::VERSION {
|
||||
return Err(invalid("metadata version"));
|
||||
}
|
||||
if saved.face_sources.as_ref().is_some_and(|s| s.len() != faces) {
|
||||
return Err(invalid("face source count"));
|
||||
}
|
||||
if let Some(history) = &saved.history
|
||||
&& (history.faces.len() != faces
|
||||
|| history.edges.len() != edges
|
||||
|| history.operands as usize > MAX_ITEMS
|
||||
|| history.edges.iter().flat_map(|e| &e.faces).any(|&f| f as usize >= faces))
|
||||
{
|
||||
return Err(invalid("history topology"));
|
||||
}
|
||||
if saved.color.is_some_and(|c| c.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v))) {
|
||||
return Err(invalid("colour"));
|
||||
}
|
||||
if let Some(paint) = &saved.paint
|
||||
&& (!paint.opacity.is_finite()
|
||||
|| !(0.0..=1.0).contains(&paint.opacity)
|
||||
|| paint.faces.iter().any(|p| {
|
||||
p.face as usize >= faces
|
||||
|| !p.opacity.is_finite()
|
||||
|| !(0.0..=1.0).contains(&p.opacity)
|
||||
|| p.color.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v))
|
||||
}))
|
||||
{
|
||||
return Err(invalid("face paint"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// The binary reader bounds allocation before deserialization. Geometry semantics
|
||||
// are checked by the independent schema validator, not by backend serde internals.
|
||||
fn validate_value(v: &Value, depth: usize) -> Result<()> {
|
||||
if depth > 48 {
|
||||
return Err(invalid("nesting"));
|
||||
}
|
||||
match v {
|
||||
Value::String(s) if s.len() > 1 << 20 => return Err(invalid("string budget")),
|
||||
Value::Number(n) if n.is_f64() && n.as_f64().is_none_or(|x| !x.is_finite() || x.abs() > 1e12) => return Err(invalid("non-finite metadata")),
|
||||
Value::Array(a) => {
|
||||
if a.len() > MAX_ITEMS {
|
||||
return Err(invalid("array budget"));
|
||||
}
|
||||
for x in a {
|
||||
validate_value(x, depth + 1)?;
|
||||
}
|
||||
}
|
||||
Value::Object(o) => {
|
||||
for x in o.values() {
|
||||
validate_value(x, depth + 1)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn value_nodes(v: &Value) -> usize {
|
||||
match v {
|
||||
Value::Array(a) => a.iter().fold(1usize, |n, v| n.saturating_add(value_nodes(v))),
|
||||
Value::Object(o) => o.values().fold(1usize, |n, v| n.saturating_add(value_nodes(v))),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solidcraft_geom::Vec3;
|
||||
fn equal(a: &Body, b: &Body) {
|
||||
assert_eq!(a.face_count(), b.face_count());
|
||||
assert_eq!(a.face_sources, b.face_sources);
|
||||
assert_eq!(a.history, b.history);
|
||||
assert_eq!(a.paint, b.paint);
|
||||
assert_eq!(a.color, b.color);
|
||||
assert_eq!(a.splits, b.splits);
|
||||
assert_eq!(a.regularization_protected, b.regularization_protected);
|
||||
assert_eq!(encode_native_body(a).unwrap(), encode_native_body(b).unwrap());
|
||||
let am = crate::measure(a).unwrap();
|
||||
let bm = crate::measure(b).unwrap();
|
||||
assert!((am.volume - bm.volume).abs() < 1e-7);
|
||||
}
|
||||
#[test]
|
||||
fn native_solids_keep_geometry_metadata_and_later_modelling() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
|
||||
let rounded = crate::fillet(&cube, &[Vec3::new(20., 0., 10.)], 2.).unwrap();
|
||||
let bore = crate::cylinder(Vec3::new(10., 10., -1.), Vec3::Z, 3., 12.).unwrap();
|
||||
let cut = crate::boolean(&rounded, &bore, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
for body in [cube, rounded, cut] {
|
||||
let body = body.with_color(Some([0.1, 0.5, 0.9]));
|
||||
let bytes = encode_native_body(&body).unwrap();
|
||||
assert!(bytes.starts_with(MAGIC));
|
||||
let decoded = decode_native_body(&bytes).unwrap();
|
||||
equal(&body, &decoded);
|
||||
let a = crate::boolean(&body, &bore, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
let b = crate::boolean(&decoded, &bore, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
assert!((crate::measure(&a).unwrap().volume - crate::measure(&b).unwrap().volume).abs() < 1e-7);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn native_spheres_tori_and_open_faces_round_trip_without_conversion() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
|
||||
let surface = crate::copy_faces(&cube, &[Vec3::new(20., 15., 10.)]).unwrap();
|
||||
let sphere = crate::sphere(Vec3::ZERO, 12.).unwrap();
|
||||
let torus = crate::torus(Vec3::ZERO, 20., 4.).unwrap();
|
||||
for body in [surface, sphere, torus] {
|
||||
let encoded = encode_native_body(&body).unwrap();
|
||||
let restored = decode_native_body(&encoded).unwrap();
|
||||
assert_eq!(body.is_surface(), restored.is_surface());
|
||||
assert_eq!(body.face_count(), restored.face_count());
|
||||
assert_eq!(encoded, encode_native_body(&restored).unwrap());
|
||||
if !body.is_surface() {
|
||||
equal(&body, &restored);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_and_fillet_have_stable_wire_records() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
|
||||
let rounded = crate::fillet(&cube, &[Vec3::new(20., 0., 10.)], 2.).unwrap();
|
||||
let records: Vec<_> = [cube, rounded]
|
||||
.iter()
|
||||
.map(|body| {
|
||||
let bytes = encode_native_body(body).unwrap();
|
||||
assert_eq!(&bytes[..8], b"SCBREP03");
|
||||
let mut raw = &bytes[8..];
|
||||
let value = binary::read(&mut raw, 0, &mut 2_000_000).unwrap();
|
||||
assert!(raw.is_empty());
|
||||
value
|
||||
})
|
||||
.collect();
|
||||
let actual = serde_json::json!(records);
|
||||
let expected: Value = serde_json::from_str(include_str!("native/fixtures/box-fillet-v3.json")).unwrap();
|
||||
compare_wire(&expected, &actual);
|
||||
}
|
||||
|
||||
fn compare_wire(expected: &Value, actual: &Value) {
|
||||
match (expected, actual) {
|
||||
(Value::Number(a), Value::Number(b)) if a.is_f64() && b.is_f64() => {
|
||||
let (a, b) = (a.as_f64().unwrap(), b.as_f64().unwrap());
|
||||
assert!((a - b).abs() <= 1e-10 * a.abs().max(1.), "wire number changed: {a} vs {b}");
|
||||
}
|
||||
(Value::Array(a), Value::Array(b)) => {
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.iter().zip(b) {
|
||||
compare_wire(a, b);
|
||||
}
|
||||
}
|
||||
(Value::Object(a), Value::Object(b)) => {
|
||||
assert_eq!(a.keys().collect::<Vec<_>>(), b.keys().collect::<Vec<_>>());
|
||||
for (key, a) in a {
|
||||
compare_wire(a, &b[key]);
|
||||
}
|
||||
}
|
||||
_ => assert_eq!(expected, actual),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_native_geometry_is_bounded_and_never_panics() {
|
||||
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
|
||||
let bytes = encode_native_body(&body).unwrap();
|
||||
for n in [0, 1, 7, 8, 9, bytes.len() - 1] {
|
||||
assert!(decode_native_body(&bytes[..n]).is_err());
|
||||
}
|
||||
for i in (0..bytes.len()).step_by((bytes.len() / 256).max(1)) {
|
||||
let mut bad = bytes.clone();
|
||||
bad[i] ^= 0xff;
|
||||
assert!(std::panic::catch_unwind(|| decode_native_body(&bad)).is_ok());
|
||||
}
|
||||
let mut raw = &bytes[8..];
|
||||
let value = binary::read(&mut raw, 0, &mut 2_000_000).unwrap();
|
||||
for key in ["vertices", "index"] {
|
||||
let mut v = value.clone();
|
||||
if key == "vertices" {
|
||||
v["solid"]["shells"][0]["edges"][0]["vertices"] = serde_json::json!([u64::MAX, 0]);
|
||||
} else {
|
||||
v["solid"]["shells"][0]["faces"][0]["loops"][0][0]["edge"] = serde_json::json!(u64::MAX);
|
||||
}
|
||||
let mut bad = MAGIC.to_vec();
|
||||
binary::write(&v, &mut bad, 0).unwrap();
|
||||
assert!(decode_native_body(&bad).is_err());
|
||||
}
|
||||
let mut huge = MAGIC.to_vec();
|
||||
huge.push(7);
|
||||
huge.extend(u32::MAX.to_le_bytes());
|
||||
assert!(decode_native_body(&huge).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_preserves_face_appearance_and_authored_boundaries() {
|
||||
let mut body = crate::box_solid(Vec3::new(1., 2., 3.), Vec3::new(10., 20., 30.))
|
||||
.unwrap()
|
||||
.with_paint(Some(crate::Paint { opacity: 0.7, faces: vec![crate::FacePaint { face: 2, color: [0.25, 0.5, 0.75], opacity: 0.6 }] }));
|
||||
body.splits = Some(Arc::new(vec![vec![Vec3::new(1., 2., 3.), Vec3::new(11., 2., 3.)]]));
|
||||
body.regularization_protected = true;
|
||||
let restored = decode_native_body(&encode_native_body(&body).unwrap()).unwrap();
|
||||
equal(&body, &restored);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user