Compare commits

...

27 Commits

Author SHA1 Message Date
bherbruck 4ac803ab52 Release SolveCraft 0.1.4 2026-10-10 18:21:13 -04:00
bherbruck 63fd838f55 Fix hover highlight flicker during viewport navigation
Resolve hover before rendering and suppress it throughout navigation gestures and animated views. Closes #92.
2026-10-10 18:14:53 -04:00
bherbruck 71a4f3061b Fix document and toolbar tab styling 2026-10-10 18:00:37 -04:00
CypherNaugh_0x 1227fab860 Extrude's start_offset says which way it goes: along the sketch normal, also with direction: negative (where a positive offset starts on the far side of the sketch from the extrusion)
The params doc listed start_offset? with no description, and an extrude in direction negative with a positive start offset surprised a user. The behaviour is unchanged (an offset start plane, as in Fusion); the params doc of solid.extrude and the Extent field doc say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:57:34 -04:00
CypherNaugh_0x 9a2a3cef8b A sweep along a path with sharp corners follows the corners: sweeping a rim profile along three lines (an open rectangle) gave a body of volume -0.0006 mm3 with its centre of mass at x = -3.5e9, and no error
kernel::sweep swept segment by segment: a line translates the profile along it, an arc turns it. At a sharp corner the next line runs within the plane the profile has reached, so the profile slid sideways in its own plane and the walls folded into an inside-out, zero-volume shell. Paths whose direction changes at a joint (more than 1e-3 rad) are now swept as a polyline by sweep_path, which mitres the corners; arcs become points within 0.01 mm. Smooth paths (lines and tangent arcs) are swept as before. Pipe uses the same path.

Tests: sweep_round_a_sharp_corner_is_mitred sweeps a 2 x 2 square along an L of two 10 mm lines and checks the volume, 80 mm3 (fails on main).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:57:34 -04:00
CypherNaugh_0x 3086c32a9a A round hole elsewhere on a body no longer breaks a loft cut beside it: a loft cut from a pocket floor (556.68 x 99 down 4 mm to 80 x 80), with a 35 mm hole 66 mm away that it never touches, failed with a kernel panic in TroughFall, while the same cut without the hole, or with the hole made afterwards, worked
The loft's top lies in the pocket floor. truck's boolean cannot take coincident faces, and the exact polygon fallback (planar_boolean) only takes bodies whose faces are all planar, so the hole's cylinder ruled out the only path that handles this cut. planar_boolean now first tries a sliced form: when an axis-aligned slab holds the tool and none of the body's curved faces, the body is split on the slab's planes (planes clear of the body's own faces), the planar middle piece takes the exact boolean, and the pieces outside are glued back on along the cut faces (the middle piece is healed first so its edges match theirs). Pieces in several parts are refused rather than glued into one shell. split_by_plane's half-space code is now ops::half_space, which this uses.

Tests: loft_cut_beside_a_round_hole replays examples/regressions/loft_cut_beside_round_hole.json and checks the exact volume (slab - pocket - prismatoid - hole; fails on main). With the hole made after the loft, the body is the same (volume, area, 19 faces, 42 edges).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:57:34 -04:00
CypherNaugh_0x 5249ae84ef Moving a body that a boolean made no longer hangs: a boolean between a cylinder and a tilted plane (an elliptical edge) froze the app at 100 % CPU, because its retries move both bodies and the result back
Moving a body transforms its intersection-curve edges, and truck-geometry 0.5.0 scales each curve's tolerance by the operator norm of the matrix. matext4cgmath 0.1.0 computes it from eigenvalues through a quartic whose Newton refinement loops while the residual is above a tolerance and never gives up. For a translation matrix all four eigenvalues are 1, and near that quadruple root the iteration can cycle forever (about one translation in eight of the boolean retries' shifts, by a scan of sizes 0.1-2000 mm). matext4cgmath is vendored with both Newton loops stopped after 100 steps (vendor/README.md, ATTRIBUTION.md).

truck master no longer computes the norm in transform_by, so the vendored copy can go once a truck release after 0.5.0 is adopted.

Tests: moving_a_body_with_an_intersection_curve_edge_ends (kernel) cuts a cylinder with a tilted box and moves the result by one of the shifts that hung (hangs on main). examples/regressions/oblique_cut_of_cylinder_hang.json is the variant of the oblique cut that hung the app; it hangs on main, and since the previous commit it passes without reaching the shifted retries, so it is replayed by the engine test as a scenario only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:57:34 -04:00
CypherNaugh_0x f3a689f284 A thin part trimmed by a long one is no longer refused as an implausible result: a 437 mm bar with a 3 mm concave arc in its profile, cut by a 920 mm prism with a top tilted 21 degrees, failed with "implausible result volume 3870" (it was 3694 before the cut)
The boolean checks its result by meshing both operands and the result, then sampling membership densely where the operands' boxes overlap. The mesh step came from the larger body's size (0.46 mm here), which facets the bar's 3 mm arc so coarsely that two meshes of the same arc disagree over a few percent of the thin overlap box, and a correct cut failed the 0.4 % mismatch limit. The step now also follows the smallest side of the overlap box (2e-3 of it), never finer than 1e-5 of the size, so it only changes where the bodies meet in a region much thinner than they are. truck's first result for the cut is then accepted, and it matches a split of the bar by the tilted plane.

Tests: oblique_cut_through_a_small_arc_matches_a_split replays examples/regressions/oblique_cut_of_cylinder.json and compares with solid.split_body by the same plane (fails on main). The full suite runs no slower.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:57:34 -04:00
CypherNaugh_0x f17ff913d6 A panic while healing a boolean result no longer ends the boolean: a shallow, wide loft cut from a top face (556.68 x 99 down 4 mm to 80 x 80 in a 900 x 480 x 160 slab) failed with "called Option::unwrap() on a None value"
heal_keep evaluated the result's intersection curves outside any guard. truck panics where such a curve no longer evaluates, and the panic escaped boolean_whole, so the retries and fallbacks that would have succeeded never ran. heal_keep now runs under guard and returns the solid unhealed when healing panics (as its doc already promised for failures), and the boolean builds its result body under guard too, so a result that cannot be built counts as a failed attempt.

Tests: shallow_loft_cut_from_the_top_face replays examples/regressions/shallow_loft_cut_from_top_face.json and checks the exact prismatoid volume (fails on main with the panic).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:57:34 -04:00
bherbruck 6f16b31771 Fix viewport input leaking through dialogs
Use egui pointer ownership for viewport navigation and picking, view-cube
interaction, and sketch-dimension hover. Cover scrolling, model selection,
and cube interaction under floating dialogs, including after they close.
Shield constraint glyph and move-gizmo highlights from overlapping UI too.

Clear strict Clippy warnings and use the same saved path in the recent-file
scenario on Windows and Unix.

Fixes #87
2026-10-10 17:34:14 -04:00
CypherNaugh_0x ab23fc9906 A failed MCP batch goes back to where it started however long the undo history is: rollback undid as many steps as the undo history had grown, but the history is capped at 200 steps, so once it was full a failed batch reported undone: 0 and left its sketches behind (an empty Sketch1, two sketches named TapHole). New commands edit.checkpoint and edit.restore_checkpoint (forget: true drops one) remember and restore the design, the active sketch and the undo history; batch takes a checkpoint before its first step, restores it on a failure and forgets it on success. Against an app without checkpoints batch falls back to counting undo steps 2026-10-10 17:21:25 -04:00
CypherNaugh_0x 54a86dc6f5 Deleting a sketch deletes every feature built from it: a pipe whose path sketch was deleted stayed in the timeline as a broken feature ("unknown sketch"), and so did holes drilled at sketch points and patches filling sketch regions. Which sketches a feature uses now comes from one place, FeatureKind::input_sketches, a match over every kind with no wildcard arm, so a new feature kind does not compile until it says which sketches it uses; a test reads the FeatureKind definition and fails when a kind with a sketch field is listed as using none (it catches a sketch field added to an existing kind). timeline.dependents reports the same features 2026-10-10 17:21:25 -04:00
bherbruck acf4669f73 fix: select complete circular rims and curved surfaces in 3D
Group kernel pieces across circular rims and hidden surface seams for hovering, selection, dialogs, and measurement. Preserve physical references and cover full-item toggling and picking with regression tests.

Fixes #88
2026-10-10 17:13:36 -04:00
bherbruck e042a7b555 ci: cache native Linux test compilation with sccache (#93)
* ci: cache native test compilation with sccache

* ci: preserve workflow line endings
2026-10-10 17:07:18 -04:00
Dylan M. Taylor 96ee326424 ci: drop pages: write permission from Flatpak repository build job 2026-10-10 16:41:29 -04:00
bherbruck d6cdec40da Fix modelling regressions and reconcile WIP UI work 2026-10-10 15:37:00 -04:00
CypherNaugh_0x 49bd89de13 Parameter and feature edits report the features they restored from the evaluation cache: setting a parameter back (or a timeline.edit back) reused the earlier results for the same inputs, so the geometry changed back but recomputed said 0, which read as "nothing rebuilt". The evaluator now counts cache hits as last_restored, and parameters.change, timeline.edit, timeline.redefine and solid.press_pull on a fillet or chamfer face report it as restored next to recomputed (params docs, the MCP set_parameter description and docs/mcp.md say what each counts). Tests: setting a parameter back and a feature edit back report restored, in the evaluator and through the commands 2026-10-10 15:36:42 -04:00
CypherNaugh_0x 90ce5261b3 An angle dimension between two lines takes the reading nearest its value and reports degrees: without text_at the angle was measured between the lines' directions, so a riser pointing up and an outlet pointing down-left (meeting at 30°, directions 150° apart) asked for 30° swung the outlet round by 120°, which the fillets between them could not follow, and the dimension was refused as over-constraining with a degree of freedom left. Two lines make two angles, θ and 180° − θ; the dimension now takes the one nearer the value (the acute one without a value), and text_at still picks a sector. sketch.dimension reports measured in the dimension's unit (deg for angles, mm for lengths) with a unit field, instead of radians for angles 2026-10-10 14:57:56 -04:00
CypherNaugh_0x 81be4e74b8 browser.visibility hides and shows sketches, bodies, construction geometry and origin items (items: ids or names, or a whole folder; visible: bool), so agents can hide helper sketches over MCP. What is hidden moved from the egui UI state into the engine session (view state: not saved, not an undo step); the desktop eye toggles and menus use it, headless renders and fit leave hidden items out, and document.inspect, sketch.inspect and ui.inspect report visibility. ui.set still accepts hiddenBodies, hiddenOrigin, hiddenSketches and shownSketches. Tests: helper sketches hide and show in the engine (hostile input changes nothing) and through MCP only 2026-10-10 14:33:56 -04:00
CypherNaugh_0x 0321778d38 Perspective views no longer show hidden edges through solids: lines, decals and ghosts were pulled toward the eye by a fixed step in depth, and perspective depth is not linear in distance (near plane at about 0.1 % of the distance), so at the model that step spanned tens of millimetres and hole bottoms, the far side of bores and the back edges showed through the part (and through section caps). In perspective the point now moves toward the eye by a fraction of its distance (about 0.2 mm at 140 mm, like the orthographic step); orthographic is unchanged. Not covered by an automated test (no GPU in the test suite); checked in the app with before/after screenshots 2026-10-10 13:51:00 -04:00
CypherNaugh_0x b5fd55cce6 Section Analysis cut faces hide the inside of the body: the cap was drawn on the far walls' back faces at their own depth, so every edge between the cut and the far wall (hole and boss edges, corner seams) showed through it like a pane of glass. While a section is shown the solids mark in the stencil where the cap is the visible surface, then draw the cap again there at the depth where the view ray meets the section plane (orthographic and perspective), so holes stay open and the inside is hidden. The native app asks for an 8-bit stencil; without one (eframe's web painter has none) the cap draws as before. Not covered by an automated test (no GPU in the test suite); checked in the app with before/after screenshots 2026-10-10 13:51:00 -04:00
bherbruck b1ec9fc1ce Release: drop the riscv64 Linux build
The cross-built riscv64 binary can't run on the x86_64 runner, so the packaging step that runs the CLI
failed and blocked the whole release. RISC-V users can build from source; the opt-in RISC-V compile
check in ci.yml stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 17:24:10 +00:00
CypherNaugh_0x 29796762de cargo xtask test runs every test binary at once (cargo test runs them one after another): 323 s to 75 s on 12 cores. cargo xtask ci runs the quick gates (fmt, assets, licences, layers) first, then clippy, then the tests with the wasm check and doc tests alongside; the wasm check uses the ci profile to reuse clippy's host build scripts and proc macros. GitHub CI runs its tests through cargo xtask test 2026-10-10 13:24:08 -04:00
CypherNaugh_0x aa268f0f0e Faster tests: hostile_params_never_panic shares its commands across threads (154 s to 20 s), the design-file fuzz test tries its seeded cases on all cores (21 s to 7.5 s), the kernel's tangent-primitive boolean test runs its three seeds at once, and the headless scenario harness skips the software viewport render it threw away every frame (scenarios 63 s to 26 s). kill_mid_save waits for the saver to reach a varying round instead of sleeping a fixed time, so it holds on a busy machine 2026-10-10 13:24:08 -04:00
CypherNaugh_0x 4a41de72ec Autosave ids are unique within the process: an app restarted in the same second (the scenario harness crashes and restarts in one process) took the id {seconds}-{pid} of the crashed app, locked its entry again and so offered nothing to recover. The id now carries a per-process sequence number. Test: a restart in the same second still offers the crashed app's work 2026-10-10 13:24:08 -04:00
bherbruck 524ef6b8c2 The up axis preference (Preferences > Design, ui.prefs up_axis) turns the view: the camera, home view, ground grid and orbit follow it, at startup too
Fixes #3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 17:13:07 +00:00
bherbruck 74f70614cc Section Analysis: cut faces in their body's colour (tinted toward the cap colour) and hatched; snapshots show the section too (the cut part removed, hatched cut faces)
Fixes #51

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-10 17:05:23 +00:00
124 changed files with 4971 additions and 675 deletions
+14 -1
View File
@@ -98,16 +98,29 @@ jobs:
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 90
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-tests
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- name: Compile tests
run: cargo test --workspace --profile ci --locked --no-run
- name: Run tests
run: cargo test --workspace --profile ci --locked
# Every test binary at once (cargo test runs them one after another).
run: cargo xtask test --locked
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
# Preserve the existing check name for branch protection, including docs-only PRs.
ci:
-1
View File
@@ -23,7 +23,6 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: read
pages: write
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
-54
View File
@@ -169,60 +169,6 @@ jobs:
linux-riscv64:
name: Linux riscv64
needs: version
runs-on: ubuntu-24.04
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: riscv64gc-unknown-linux-gnu
- name: Install cross-compilation dependencies
run: |
# Ubuntu's regular archive serves amd64; RISC-V packages live in ports.
# The 24.04 runner uses deb822 sources, so scope every Ubuntu stanza.
sudo sed -i '/^Architectures:/d; /^Types:/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
sudo tee /etc/apt/sources.list.d/riscv64.sources >/dev/null <<'EOF'
Types: deb
URIs: http://ports.ubuntu.com/ubuntu-ports
Suites: noble noble-updates noble-backports noble-security
Components: main restricted universe multiverse
Architectures: riscv64
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
EOF
sudo dpkg --add-architecture riscv64
sudo apt-get update
sudo apt-get install -y gcc-riscv64-linux-gnu libasound2-dev:riscv64 libxkbcommon-dev:riscv64 \
libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 libxi-dev:riscv64 libgl1-mesa-dev:riscv64
- name: Build and package
env:
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: 1
run: |
. "$HOME/.cargo/env"
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
export CROSS_ARCH=riscv64
export CROSS_TARGET=riscv64gc-unknown-linux-gnu
export DEB_ARCH=riscv64
# We skip nfpm (deb/rpm) and AppImage for now on riscv64
bash packaging/linux/package.sh --formats "tar"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
+1
View File
@@ -49,3 +49,4 @@ Generated-in-code assets are original and have no file to list:
| 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-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; 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) |
+8 -5
View File
@@ -18,7 +18,7 @@ build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](htt
```sh
git clone https://github.com/bherbruck/solvecraft && cd solvecraft
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
cargo test --workspace --profile ci # unit, engine and UI-scenario tests
cargo xtask test # unit, engine and UI-scenario tests
cargo xtask ci # everything a PR must pass (see below)
```
@@ -81,7 +81,7 @@ the most complete description of the house style.
| What | Where | Run |
|---|---|---|
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo test --workspace --profile ci` |
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo xtask test` (or `cargo test --workspace --profile ci`) |
| UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | part of `cargo test` |
| Fusion oracle | `cargo xtask oracle` | needs the local oracle data (see below) |
@@ -105,7 +105,10 @@ put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the
**Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables,
no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It
builds several times faster than `--release`. If a test fails only in a plain debug build, say so
in the PR.
in the PR. `cargo xtask test` builds the tests with it and then runs every test binary at once
(`cargo test` runs them one after another); arguments after `--` go to each binary, e.g.
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
depend on wall-clock timing.
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
SolveCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's
@@ -120,8 +123,8 @@ install. If you work on geometry and want to check against it, ask in an issue.
comment on the issue so it gets the `in progress` label and two people don't build the same
thing. For anything bigger than a bug fix that has no issue yet, open one first.
2. Branch from `main` and keep the PR to one topic.
3. Run `cargo xtask ci` before pushing. It runs fmt, clippy (`-D warnings`), the tests in
release mode, the asset and layer checks, and the wasm build check. A PR has to pass it.
3. Run `cargo xtask ci` before pushing. It runs fmt, the asset, licence and layer checks,
clippy (`-D warnings`), then the tests with the wasm build check alongside. A PR has to pass it.
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
the expected ones).
Generated
+13 -15
View File
@@ -1753,8 +1753,6 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "matext4cgmath"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6541e181de37f70f0aceb25441823a3d0efa9cc1d23f475a0d3926678949178"
dependencies = [
"cgmath",
"katexit",
@@ -3059,7 +3057,7 @@ dependencies = [
[[package]]
name = "solvecraft"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"eframe",
"egui",
@@ -3073,7 +3071,7 @@ dependencies = [
[[package]]
name = "solvecraft-cli"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3082,7 +3080,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"log",
"serde",
@@ -3096,7 +3094,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"log",
"serde",
@@ -3112,7 +3110,7 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"serde",
"serde_json",
@@ -3120,7 +3118,7 @@ dependencies = [
[[package]]
name = "solvecraft-io"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
@@ -3132,7 +3130,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"log",
"serde",
@@ -3147,7 +3145,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3155,7 +3153,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"image",
"serde",
@@ -3164,7 +3162,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"serde",
"serde_json",
@@ -3175,7 +3173,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"egui",
"egui-wgpu",
@@ -3189,7 +3187,7 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"eframe",
"egui",
@@ -4488,7 +4486,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"serde_json",
]
+2 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.1.3"
version = "0.1.4"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -98,3 +98,4 @@ debug = 0
truck-shapeops = { path = "vendor/truck-shapeops" }
truck-stepio = { path = "vendor/truck-stepio" }
truck-meshalgo = { path = "vendor/truck-meshalgo" }
matext4cgmath = { path = "vendor/matext4cgmath" }
+9 -2
View File
@@ -1,6 +1,6 @@
# SolveCraft roadmap
Status as of **2026-10-09**. SolveCraft targets Fusion-style parametric modelling (sketch →
Status as of **2026-10-10**. SolveCraft targets Fusion-style parametric modelling (sketch →
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
## At a glance
@@ -10,12 +10,19 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
| Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) |
| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) |
| Tests | 596 (41 of them UI scenarios): solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
## Scope (owner decision, 2026-10-07)
The issue batch adds preservation of blind pockets in through cuts, associative projected
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
acceptance scenarios now include board resizes; STEP reopening is automated. Inspection in
Fusion remains an external acceptance step. See [WIP reconciliation](docs/wip-reconciliation.md).
In scope, in priority order:
1. SOLID and SKETCH (now).
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
+1 -1
View File
@@ -10,7 +10,7 @@ fn plane_param(pl: &Value) -> Value {
let origin_zero = pl.get("origin").and_then(Value::as_array).is_none_or(|o| o.iter().all(|x| x.as_f64().unwrap_or(0.0).abs() < 1e-12));
let std = match r.to_ascii_uppercase().as_str() {
"XY" => Some(([1.0, 0.0, 0.0], [0.0, 1.0, 0.0])),
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, -1.0])),
"YZ" => Some(([0.0, 1.0, 0.0], [0.0, 0.0, 1.0])),
_ => None,
};
+24 -9
View File
@@ -153,7 +153,8 @@ fn try_bytes(bytes: &[u8]) {
#[test]
fn damaged_and_hostile_design_files_never_panic() {
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
let mut failures = Vec::new();
// The cases come from one seeded sequence; trying them is independent, so threads share it.
let mut all_cases: Vec<(String, String, Vec<u8>)> = Vec::new();
for p in corpus() {
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let bytes = std::fs::read(&p).unwrap();
@@ -198,14 +199,28 @@ fn damaged_and_hostile_design_files_never_panic() {
cases.push(("features repeated".into(), v.to_string().into_bytes()));
cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes()));
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
for (what, b) in cases {
if std::env::var_os("CORPUS_TRACE").is_some() {
eprintln!("{name}: {what}");
}
if std::panic::catch_unwind(|| try_bytes(&b)).is_err() {
failures.push(format!("{name}: {what}"));
}
}
all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b)));
}
let next = std::sync::atomic::AtomicUsize::new(0);
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let failures: Vec<String> = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut failures = Vec::new();
while let Some((name, what, b)) = all_cases.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
if std::env::var_os("CORPUS_TRACE").is_some() {
eprintln!("{name}: {what}");
}
if std::panic::catch_unwind(|| try_bytes(b)).is_err() {
failures.push(format!("{name}: {what}"));
}
}
failures
})
})
.collect();
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
});
assert!(failures.is_empty(), "panics:\n{}", failures.join("\n"));
}
+12 -2
View File
@@ -22,10 +22,20 @@ fn killed_mid_save_the_old_file_survives() {
assert_eq!(round_of(&path).as_deref(), Some("0"));
let mut kills = 0;
let mut seen = std::collections::BTreeSet::new();
let modified = || std::fs::metadata(&path).and_then(|m| m.modified()).ok();
for i in 0..25u64 {
let before = modified();
let mut child = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "100000"]).spawn().unwrap();
// Let it get into its save loop (building the design takes a moment), then kill it.
std::thread::sleep(Duration::from_millis(150 + (i * 37) % 200));
// Wait until it is in its save loop (its first save replaced the file) and has reached a
// round that varies from kill to kill, however busy the machine is; then kill it a
// varying while later.
let target = i % 10;
let reached = || modified() != before && round_of(&path).and_then(|r| r.parse::<u64>().ok()).is_some_and(|r| r >= target);
let t0 = std::time::Instant::now();
while !reached() && t0.elapsed() < Duration::from_secs(20) && child.try_wait().unwrap().is_none() {
std::thread::sleep(Duration::from_millis(2));
}
std::thread::sleep(Duration::from_millis((i * 7) % 30));
if child.try_wait().unwrap().is_none() {
child.kill().unwrap();
kills += 1;
+2 -1
View File
@@ -376,7 +376,8 @@ mod web {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
app.set_wgpu(rs, DEPTH_BITS, 1);
// eframe's web painter has no stencil buffer.
app.set_wgpu(rs, DEPTH_BITS, 0, 1);
}
if query().contains("sample") {
if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) {
+22 -1
View File
@@ -61,6 +61,7 @@ fn services() -> Services {
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
.add_filter("All files", &["*"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
@@ -69,6 +70,23 @@ fn services() -> Services {
rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string())
})),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
show_in_folder: Some(Box::new(|path| {
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
"explorer"
} else if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
});
command.arg(folder);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000);
}
let _ = command.spawn();
})),
}
}
@@ -94,6 +112,8 @@ fn msaa() -> u16 {
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
const DEPTH_BITS: u8 = 24;
/// The section cap marks where it shows in the stencil.
const STENCIL_BITS: u8 = 8;
const MSAA: u16 = 4;
fn main() -> eframe::Result {
@@ -129,6 +149,7 @@ fn main() -> eframe::Result {
.with_title_shown(false)
.with_app_id("ai.storyteller.solvecraft"),
depth_buffer: DEPTH_BITS,
stencil_buffer: STENCIL_BITS,
multisampling: samples,
..Default::default()
};
@@ -155,7 +176,7 @@ fn main() -> eframe::Result {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
app.set_wgpu(rs, DEPTH_BITS, u32::from(samples));
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, u32::from(samples));
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
+22 -1
View File
@@ -34,6 +34,14 @@ pub struct Canvas {
pub flip: bool,
#[serde(default = "yes")]
pub visible: bool,
/// The component it belongs to (its plane is in that component's frame, so it moves with
/// the component's occurrence); 0 = the root.
#[serde(default, skip_serializing_if = "is_root")]
pub component: u64,
}
fn is_root(c: &u64) -> bool {
*c == 0
}
fn half() -> f64 {
@@ -57,7 +65,7 @@ impl Canvas {
let f = if self.flip { -1.0 } else { 1.0 };
[rot(-hw * f, -hh), rot(hw * f, -hh), rot(hw * f, hh), rot(-hw * f, hh)]
}
/// Corners in world coordinates.
/// Corners on `plane` (in whatever frame the plane is given).
pub fn world_corners(&self, plane: &Plane) -> [Vec3; 4] {
self.corners().map(|p| plane.to_world(p))
}
@@ -66,6 +74,19 @@ impl Canvas {
}
}
impl crate::Document {
/// A canvas's plane in the world: resolved in its component's frame, then placed where the
/// component's occurrence puts it.
pub fn canvas_plane(&self, vals: &std::collections::BTreeMap<String, crate::expr::Value>, c: &Canvas) -> crate::Result<Plane> {
let p = self.resolve_plane(vals, &c.plane, 0)?;
let m = self.component_transform(c.component);
if crate::is_identity(&m) {
return Ok(p);
}
Ok(Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)).unwrap_or(p))
}
}
/// Image kind and pixel size from the file header (PNG or JPEG).
pub fn image_info(b: &[u8]) -> Option<(&'static str, [u32; 2])> {
if b.len() > 24 && b.starts_with(b"\x89PNG\r\n\x1a\n") {
+64 -13
View File
@@ -125,7 +125,8 @@ pub struct Extent {
/// Optional second side distance (two-sided extrude), along the negative normal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub distance2: Option<String>,
/// Start offset from the sketch plane (expression).
/// Start offset from the sketch plane (expression), along the sketch normal whatever the
/// direction.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_offset: Option<String>,
/// Through all bodies in the direction(s) instead of `distance`.
@@ -939,6 +940,66 @@ fn z_axis() -> Vec3 {
}
impl FeatureKind {
/// Sketches the feature is built from (profiles, paths, points): deleting one of them
/// deletes the feature. Every kind is listed, so a new one has to say what it uses.
pub fn input_sketches(&self) -> Vec<u64> {
use FeatureKind as K;
match self {
K::Extrude { sketch, .. }
| K::Revolve { sketch, .. }
| K::Emboss { sketch, .. }
| K::Rib { sketch, .. }
| K::SheetBase { sketch, .. }
| K::SheetContour { sketch, .. } => vec![*sketch],
K::Sweep { sketch, path_sketch, .. } => vec![*sketch, *path_sketch],
K::Pipe { path_sketch, .. } | K::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } => vec![*path_sketch],
K::Loft { sections, .. } => sections.iter().map(|s| s.sketch).collect(),
K::SheetFold { sketch, .. } | K::SplitFace { sketch, .. } | K::Patch { sketch, .. } => sketch.iter().copied().collect(),
K::Hole { points, .. } => points.iter().map(|p| p.sketch).collect(),
K::Sketch { .. }
| K::Pattern { .. }
| K::Fillet { .. }
| K::Chamfer { .. }
| K::Box { .. }
| K::Cylinder { .. }
| K::Sphere { .. }
| K::Torus { .. }
| K::Combine { .. }
| K::Mirror { .. }
| K::Thread { .. }
| K::Shell { .. }
| K::Draft { .. }
| K::ConstructionPlane { .. }
| K::Split { .. }
| K::ConstructionAxis { .. }
| K::ConstructionPoint { .. }
| K::Scale { .. }
| K::OffsetFace { .. }
| K::DeleteFace { .. }
| K::BoundingSolid { .. }
| K::ReplaceFace { .. }
| K::Align { .. }
| K::Coil { .. }
| K::SheetFlange { .. }
| K::SheetHem { .. }
| K::SheetUnfold { .. }
| K::SheetConvert { .. }
| K::Boss { .. }
| K::Lip { .. }
| K::SnapFit { .. }
| K::Rest { .. }
| K::BoundaryFill { .. }
| K::Remove { .. }
| K::Stitch { .. }
| K::Thicken { .. }
| K::SurfaceExtend { .. }
| K::SurfaceTrim { .. }
| K::Move { .. }
| K::Import { .. }
| K::MeshImport { .. } => Vec::new(),
}
}
/// A sheet metal feature (its result depends on the sheet metal rules).
pub fn is_plastic(&self) -> bool {
matches!(self, FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. })
@@ -1115,6 +1176,7 @@ impl FeatureKind {
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
FeatureKind::Thread { face, .. } => vec![face],
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
_ => Vec::new(),
}
}
@@ -1619,18 +1681,7 @@ impl Document {
pub fn delete_feature(&mut self, id: u64) -> Result<Vec<u64>> {
let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?;
let mut gone = vec![id];
for f in &self.features {
match &f.kind {
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::Sweep { sketch, path_sketch, .. } if *sketch == id || *path_sketch == id => gone.push(f.id),
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.sketch == id) => gone.push(f.id),
FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id),
FeatureKind::Emboss { sketch, .. } | FeatureKind::Rib { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::SheetFold { sketch: Some(s), .. } | FeatureKind::SplitFace { sketch: Some(s), .. } if *s == id => gone.push(f.id),
_ => {}
}
}
gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id));
self.features.retain(|f| !gone.contains(&f.id));
// Configuration columns of deleted features go with them.
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect();
+97 -6
View File
@@ -377,6 +377,10 @@ pub struct Model {
empty: Arc<ModelState>,
/// Features recomputed by the last `evaluate`.
pub last_recomputed: usize,
/// Features whose result the last `evaluate` took from the cache instead of recomputing: the
/// same feature on the same inputs was evaluated before (an edit set back, undo, redo, a
/// configuration row). Their geometry did change since the previous evaluation.
pub last_restored: usize,
/// Results by inputs, shared with this model's copies.
pub cache: Arc<std::sync::Mutex<EvalCache>>,
/// Evaluate everything from scratch (no reuse; tests compare the two).
@@ -467,6 +471,22 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat
h.finish()
}
/// Where components are placed, for a sketch that sees other components' geometry (on a face,
/// or with projections): it is resolved in its own component's view of them.
fn placement_digest(doc: &Document, f: &Feature) -> u64 {
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 };
if doc.occurrences.is_empty() || (!matches!(plane, PlaneRef::Face { .. }) && sketch.links.is_empty()) {
return 0;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
for o in &doc.occurrences {
o.id.hash(&mut h);
o.transform.iter().flatten().for_each(|x| x.to_bits().hash(&mut h));
}
serde_json::to_string(&doc.body_offsets).unwrap_or_default().hash(&mut h);
h.finish()
}
fn now() -> web_time::Instant {
web_time::Instant::now()
}
@@ -505,11 +525,12 @@ impl Model {
let mut out: Vec<FeatureResult> = Vec::with_capacity(doc.features.len());
let mut reuse = !self.no_cache;
let mut recomputed = 0;
let mut restored = 0;
let marker = doc.marker.unwrap_or(usize::MAX);
let ctx = context_digest(doc, &vals);
for (i, f) in doc.features.iter().enumerate() {
let rolled_back = i >= marker;
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back);
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back) ^ placement_digest(doc, f);
// Sheet metal features also depend on the rules (and the parameters they use).
if f.kind.is_sheet() {
let mut h = std::collections::hash_map::DefaultHasher::new();
@@ -565,10 +586,11 @@ impl Model {
continue;
}
// The same feature on the same inputs: its earlier result.
let key = cache_key(f, fp, fingerprint(0, f, &vals, false), ctx, &state);
let key = cache_key(f, fp, fingerprint(0, f, &vals, false) ^ placement_digest(doc, f), ctx, &state);
if !self.no_cache
&& let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key))
{
restored += 1;
if c.error.is_none() {
state = c.state.clone();
}
@@ -652,6 +674,7 @@ impl Model {
}
self.results = out;
self.last_recomputed = recomputed;
self.last_restored = restored;
}
}
@@ -1398,6 +1421,20 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
}
let mut tools = Vec::new();
for r in &regions {
if *operation == Operation::Join {
let exact = kernel::extrude(&ss.plane, std::slice::from_ref(r), lo + off, hi + off)?;
if exact.iter().all(|tool| {
st.bodies
.iter()
.filter(|b| targets.is_empty() || targets.contains(&b.name))
.any(|b| kernel::can_join_touching_faces(&b.body, tool))
}) {
// Preserve a whole touching face: extending into a chamfer replaces
// the sewable seam with a coincident curved intersection.
tools.extend(exact);
continue;
}
}
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets);
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
@@ -2281,17 +2318,25 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::Align { .. }
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st),
FeatureKind::Sketch { plane, sketch } => {
let fst = state_in_frame(doc, st, f.component);
let (plane, face_warning) = match plane {
PlaneRef::Face { plane: picked, at, name } => face_plane(st, picked, *at, name.as_deref()),
other => (doc.resolve_plane_in(vals, other, 0, Some(st))?, None),
PlaneRef::Face { plane: picked, at, name } => face_plane(&fst, picked, *at, name.as_deref()),
other => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
let mut sk = sketch.clone();
let mut warns = crate::project::refresh_links(doc, vals, st, &plane, &mut sk);
let mut warns = crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk);
drop(fst);
sk.refresh_offsets()?;
if let Some(w) = face_warning {
warns.insert(0, w);
}
doc.apply_dimension_values(vals, &mut sk)?;
let report = solve(&mut sk);
let mut report = solve(&mut sk);
if !sk.offsets.is_empty() {
// Source dimensions may have moved curves during the solve.
sk.refresh_offsets()?;
report = solve(&mut sk);
}
if !report.ok() {
warns.insert(0, format!("the sketch constraints conflict ({})", report.failing.join(", ")));
}
@@ -2811,6 +2856,52 @@ fn unique_body_name(st: &ModelState, base: &str) -> String {
(2..).map(|i| format!("{base} ({i})")).find(|n| st.body(n).is_none()).unwrap_or_else(|| base.to_string())
}
/// The model as a component sees it: bodies and sketches of components placed differently from
/// 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).
pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
use std::borrow::Cow;
if doc.occurrences.is_empty() && doc.body_offsets.is_empty() {
return Cow::Borrowed(st);
}
let Some(inv) = crate::mat_inverse(&doc.component_transform(comp)) else { return Cow::Borrowed(st) };
let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0);
let rel = |c: u64, body: Option<&str>| {
let m = crate::mat_mul(&inv, &doc.component_transform(c));
match body.and_then(|b| doc.body_offsets.get(b)) {
Some(o) => crate::mat_mul(&m, o),
None => m,
}
};
let body_m: Vec<Mat> = st.bodies.iter().map(|b| rel(doc.body_component(&b.name, b.feature), Some(&b.name))).collect();
let sketch_m: Vec<Mat> = st.sketches.iter().map(|s| rel(comp_of_feature(s.feature), None)).collect();
if body_m.iter().chain(&sketch_m).all(crate::is_identity) {
return Cow::Borrowed(st);
}
let mut out = st.clone();
for (b, m) in out.bodies.iter_mut().zip(&body_m) {
if !crate::is_identity(m)
&& let Ok(t) = kernel::transform_matrix(&b.body, *m)
{
// Moving a body keeps its faces (and their order): it keeps their names.
let names = crate::naming::face_names(b);
let mut nb = ModelBody::new(b.name.clone(), t, b.feature);
nb.component = b.component;
nb.names = crate::naming::NameCell::known(names);
*b = nb;
}
}
for (s, m) in out.sketches.iter_mut().zip(&sketch_m) {
if !crate::is_identity(m)
&& let Some(np) = Plane::new(crate::apply_point(m, s.plane.origin), crate::apply_vector(m, s.plane.x), crate::apply_vector(m, s.plane.y))
{
s.plane = np;
}
}
Cow::Owned(out)
}
/// The model placed in the world: each component's bodies, sketches and threads through every
/// occurrence of it (bodies of the first placement keep their names; further instances get the
/// occurrence path in brackets). Without components this is the model itself.
+6 -1
View File
@@ -613,7 +613,12 @@ fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, le
/// The body a placed plastic feature joins: by name, else the one under the point.
fn body_under(st: &ModelState, body: &Option<String>, p: Vec3) -> Result<String> {
let i = body_at(st, body, &[p])?;
st.bodies.get(i).map(|b| b.name.clone()).ok_or_else(|| DocError::Invalid("there is no body".into()))
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("there is no body".into()))?;
let tolerance = (b.body.size() * 1e-5).max(1e-4);
if crate::project::nearest_face(&b.mesh(), p).is_none_or(|(_, _, distance)| distance > tolerance) {
return Err(DocError::Invalid("the plastic feature's placement is no longer on its body face".into()));
}
Ok(b.name.clone())
}
/// Axes with z along `z` (and x along `x` when given).
+53 -2
View File
@@ -18,13 +18,15 @@
//! ["*"]` (every body), which is what they did, so those designs keep their shape.
//! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become
//! explicit planes retaining their previous axes, including offset-plane expressions.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 5;
pub const FORMAT: u32 = 6;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -37,7 +39,30 @@ 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; 4] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5];
const JSON_STEPS: [JsonStep; 5] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6];
/// Old XZ references retain their exact frame, including nested offset and angled planes.
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
/// dimensions, profiles, extrude directions or parameter expressions in saved designs.
fn v5_to_v6(v: &mut Value) -> Result<()> {
fn preserve(v: &mut Value) {
match v {
Value::Object(o) => {
let old_xz = o.get("type").and_then(Value::as_str) == Some("origin")
&& o.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case("XZ") || n.eq_ignore_ascii_case("FRONT"));
if old_xz {
*v = serde_json::json!({"type": "custom", "plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}});
} else {
o.values_mut().for_each(preserve);
}
}
Value::Array(a) => a.iter_mut().for_each(preserve),
_ => {}
}
}
preserve(v);
Ok(())
}
/// Features in designs with components keep reaching every body.
fn v4_to_v5(v: &mut Value) -> Result<()> {
@@ -198,6 +223,32 @@ mod tests {
assert_eq!(back, d);
}
#[test]
fn old_xz_and_nested_offsets_keep_their_frame() {
let mut doc = Document::new("old XZ");
let id = doc
.add_feature(
crate::FeatureKind::ConstructionPlane {
plane: crate::PlaneRef::Offset { base: Box::new(crate::PlaneRef::Origin { name: "XZ".into() }), distance: "7 mm".into() },
},
None,
)
.unwrap();
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
json["format"] = Value::from("solvecraft/5");
let migrated = read(&json.to_string()).unwrap();
let crate::FeatureKind::ConstructionPlane { plane } = &migrated.feature(id).unwrap().kind else { panic!("plane") };
let (values, errors) = migrated.param_values();
assert!(errors.is_empty());
let p = migrated.resolve_plane(&values, plane, 0).unwrap();
assert_eq!(p.y, solvecraft_geom::Vec3::Z);
assert_eq!(p.normal(), -solvecraft_geom::Vec3::Y);
assert_eq!(p.origin, solvecraft_geom::Vec3::new(0.0, -7.0, 0.0));
assert_eq!(read(&migrated.to_json()).unwrap(), migrated);
let fresh = doc.resolve_plane(&values, &crate::PlaneRef::Origin { name: "XZ".into() }, 0).unwrap();
assert_eq!(fresh.normal(), solvecraft_geom::Vec3::Y);
}
#[test]
fn older_upgraded_newer_refused_others_rejected() {
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
+1 -1
View File
@@ -29,7 +29,7 @@ pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_iden
pub use document::*;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices,
world_state,
state_in_frame, world_state,
};
pub use params::{ParamRow, ParamUser, cycles};
pub use solvecraft_kernel as kernel;
+6
View File
@@ -34,6 +34,12 @@ impl NameCell {
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
NameCell { origin, names: Arc::new(OnceLock::new()) }
}
/// Names already known (a moved copy of a body keeps its faces' names).
pub fn known(names: Arc<Vec<String>>) -> Self {
let cell = OnceLock::new();
let _ = cell.set(names);
NameCell { origin: None, names: Arc::new(cell) }
}
}
/// Deeper chains than this are named by position only (bounded work and stack).
+15 -2
View File
@@ -273,7 +273,20 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelS
let mut warn = Vec::new();
let links = sk.links.clone();
for l in &links {
match resolve(doc, vals, st, plane, l.kind, &l.source) {
let mut source = l.source.clone();
if let (Some(name), LinkSource::Face { at, .. }) = (&l.face_name, &mut source) {
if let Some((p, split)) = crate::naming::point_on_face(st, name, *at) {
*at = p;
if split {
warn.push(format!("projected face `{name}` was split; using the nearest piece"));
}
} else {
sk.set_link_lost(&l.id, true);
warn.push(format!("projected face `{name}` no longer exists"));
continue;
}
}
match resolve(doc, vals, st, plane, l.kind, &source) {
Ok(r) => {
sk.set_link_source(&l.id, r.source);
let _ = sk.set_link_wires(&l.id, &r.wires);
@@ -472,7 +485,7 @@ fn tilted_circle(plane: &Plane, pts: &[Vec3]) -> Option<Vec<LinkGeom>> {
let (c, n, r, closed) = circle3(pts)?;
let sn = plane.normal();
let cosang = n.dot(sn).abs();
if cosang > 1.0 - 1e-12 || cosang < 1e-9 {
if !(1e-9..=1.0 - 1e-12).contains(&cosang) {
return None;
}
if closed {
+1 -1
View File
@@ -899,7 +899,7 @@ impl SheetBody {
return Err(DocError::Invalid("the hem is shorter than its bend".into()));
}
// A hem folds back over the face it starts from (toward the bottom unless flipped).
let up = !top == flip;
let up = top != flip;
let up = !up;
self.flanges.push(SheetFlange {
parent: panel,
+70
View File
@@ -81,6 +81,26 @@ fn parametric_plate_reevaluates() {
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
}
/// Setting a parameter back takes the earlier results from the cache: nothing is recomputed, but
/// the evaluation reports the features it restored (their geometry did change back).
#[test]
fn set_back_reports_restored_features() {
let (mut doc, _) = plate_doc();
let mut m = Model::new();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
doc.set_param("width", "50 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
doc.set_param("width", "40 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (0, 2));
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 24000.0) < 1e-9);
// No change: nothing recomputed and nothing restored.
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (0, 0));
}
#[test]
fn box_fillet_cut_timeline() {
let (mut doc, _) = plate_doc();
@@ -275,3 +295,53 @@ fn parameter_cycles_are_found() {
assert!(errs["a"].contains("circular reference: a → b → c → a"), "{}", errs["a"]);
assert!(vals.contains_key("d") && errs.contains_key("a") && errs.contains_key("e"));
}
/// Features that use a sketch's points or regions go with it (not only profile and path users).
#[test]
fn input_sketches_cover_points_and_patches() {
let hole: FeatureKind = serde_json::from_value(serde_json::json!({
"type": "hole", "position": [0, 0, 0], "direction": [0, 0, -1], "diameter": "3",
"points": {"sketch": 7, "ids": ["p1"]},
}))
.unwrap();
assert_eq!(hole.input_sketches(), vec![7]);
let patch: FeatureKind = serde_json::from_value(serde_json::json!({"type": "patch", "sketch": 9})).unwrap();
assert_eq!(patch.input_sketches(), vec![9]);
let pipe: FeatureKind = serde_json::from_value(serde_json::json!({"type": "pipe", "path_sketch": 72, "path": ["l1"], "diameter": "4"})).unwrap();
assert_eq!(pipe.input_sketches(), vec![72]);
}
/// Guard for `FeatureKind::input_sketches`: a variant with a field that refers to a sketch
/// (a field named `*sketch*` holding an id, sketch points or loft sections) must not sit in the
/// arm that returns no sketches. New variants are caught by the compiler (the match has no
/// wildcard); this catches a sketch field added to an existing variant.
#[test]
fn input_sketches_lists_every_variant_with_a_sketch_field() {
let src = include_str!("document.rs");
let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap();
let mut with_sketch = Vec::new();
let mut variant = "";
for line in body.lines() {
let t = line.trim();
if line.starts_with(" ") && !line.starts_with(" ") && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or("");
continue;
}
let Some((name, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue };
let refers = (name.contains("sketch") && (ty.contains("u64") || ty.contains("SketchPoints")))
|| ty.contains("SketchPoints")
|| ty.contains("LoftSection");
if refers && !line.trim_start().starts_with("//") && !with_sketch.contains(&variant) {
with_sketch.push(variant);
}
}
assert!(with_sketch.len() >= 10, "the FeatureKind parse found too few sketch users: {with_sketch:?}");
let f = src.split("pub fn input_sketches(&self)").nth(1).unwrap();
let empty_arm = f.split("=> Vec::new()").next().and_then(|s| s.rsplit("=> ").next()).unwrap();
for v in with_sketch {
assert!(
!empty_arm.contains(&format!("K::{v} {{")),
"FeatureKind::{v} has a sketch field but input_sketches says it uses no sketch: deleting the sketch would leave it broken"
);
}
}
@@ -0,0 +1,66 @@
//! Booleans that failed, panicked or hung, replayed from the scripts in
//! `examples/regressions/` (each script's description says what went wrong).
use serde_json::{Value, json};
use crate::Session;
fn replay(script: &str) -> Session {
let mut s = Session::default();
let script: Value = serde_json::from_str(script).unwrap();
s.run_script(&script).unwrap();
s
}
fn volume(s: &Session, body: &str) -> f64 {
s.model.state().body(body).map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0)
}
/// Slab less a 557.68 × 100 × 120 pocket and the loft below its floor (556.68 × 99 down 4 mm to
/// 80 × 80; prismatoid: h/6 · (A_top + A_bottom + 4 A_mid)).
fn slab_pocket_loft() -> (f64, f64) {
let slab = 900.0 * 480.0 * 160.0;
let loft = 4.0 / 6.0 * (556.68 * 99.0 + 80.0 * 80.0 + 4.0 * (556.68 + 80.0) / 2.0 * (99.0 + 80.0) / 2.0);
(slab - 557.68 * 100.0 * 120.0, loft)
}
#[test]
fn loft_cut_beside_a_round_hole() {
let s = replay(include_str!("../../../examples/regressions/loft_cut_beside_round_hole.json"));
let (pocketed, loft) = slab_pocket_loft();
let hole = std::f64::consts::PI * 17.5 * 17.5 * 25.0;
let want = pocketed - loft - hole;
let got = volume(&s, "Body1");
assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
}
#[test]
fn shallow_loft_cut_from_the_top_face() {
let s = replay(include_str!("../../../examples/regressions/shallow_loft_cut_from_top_face.json"));
let (_, loft) = slab_pocket_loft();
let want = 900.0 * 480.0 * 160.0 - loft;
let got = volume(&s, "Body1");
assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}");
}
#[test]
fn oblique_cut_through_a_small_arc_matches_a_split() {
let scripts = [
include_str!("../../../examples/regressions/oblique_cut_of_cylinder.json"),
include_str!("../../../examples/regressions/oblique_cut_of_cylinder_hang.json"),
];
// The same bar split by the prism's tilted top: the piece above it is the cut's result.
let mut split = Session::default();
let script: Value = serde_json::from_str(scripts[0]).unwrap();
let steps = script.get("commands").and_then(Value::as_array).unwrap();
split.run_script(&Value::Array(steps[..5].to_vec())).unwrap();
let whole = volume(&split, "Tool");
split.execute("solid.split_body", &json!({"body": "Tool", "plane": {"origin": [0, -472, 853.91], "normal": [0, 15.44, 40]}})).unwrap();
let above = volume(&split, "Tool");
assert!(above < whole - 10.0, "{above} of {whole}");
for script in scripts {
let s = replay(script);
let got = volume(&s, "Tool");
assert!((got - above).abs() < 1e-6 * above, "{got} vs {above}");
}
}
+142 -2
View File
@@ -9,7 +9,7 @@ use serde_json::{Value, json};
use solvecraft_doc::FeatureKind;
use super::CommandSpec;
use crate::params::{bad, str_};
use crate::params::{bad, bool_, str_};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
@@ -34,6 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
CommandSpec::new("view.list", "List Named Views", view_list).noundo(),
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). View state: not saved, not an undo step",
),
];
const FOLDERS: [&str; 3] = ["bodies", "sketches", "construction"];
@@ -155,6 +159,83 @@ fn order(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({}))
}
const ORIGIN: [&str; 7] = ["O", "X", "Y", "Z", "XY", "XZ", "YZ"];
/// A browser item whose visibility can change.
enum Shown {
Sketch(u64),
Body(String),
/// Origin items and construction geometry, by name.
Origin(String),
}
fn is_construction(k: &FeatureKind) -> bool {
matches!(k, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. })
}
fn shown_item(s: &Session, key: &str) -> Option<Shown> {
if let Some(f) = s.doc.find_feature(key) {
if matches!(f.kind, FeatureKind::Sketch { .. }) {
return Some(Shown::Sketch(f.id));
}
if is_construction(&f.kind) {
return Some(Shown::Origin(f.name.clone()));
}
}
if s.world_state().bodies.iter().any(|b| b.name == key) {
return Some(Shown::Body(key.to_string()));
}
ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
}
fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "browser.visibility";
let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?;
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
(Some(_), Some(_)) => return Err(bad(cmd, "give `items` or `folder`, not both")),
(Some("bodies"), None) => s.world_state().bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(),
(Some("sketches"), None) => s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)).collect(),
(Some("construction"), None) => s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())).collect(),
(Some("origin"), None) => ORIGIN.iter().map(|o| Shown::Origin(o.to_string())).collect(),
(Some(f), None) => return Err(bad(cmd, format!("unknown folder `{f}` (bodies, sketches, construction, origin)"))),
(None, Some(_)) => {
let keys = keys(p, "items", cmd)?;
if keys.is_empty() {
return Err(bad(cmd, "`items` must list sketches, bodies or construction geometry"));
}
// Every item is found before anything changes.
keys.iter()
.map(|k| shown_item(s, k).ok_or_else(|| bad(cmd, format!("no sketch, body or construction item `{k}`"))))
.collect::<Result<_>>()?
}
(None, None) => return Err(bad(cmd, "`items` or `folder` is required")),
};
let v = &mut s.visibility;
for it in &items {
match it {
Shown::Sketch(id) => {
v.hidden_sketches.retain(|x| x != id);
v.shown_sketches.retain(|x| x != id);
if visible { v.shown_sketches.push(*id) } else { v.hidden_sketches.push(*id) }
}
Shown::Body(n) => {
v.hidden_bodies.retain(|x| x != n);
if !visible {
v.hidden_bodies.push(n.clone());
}
}
Shown::Origin(n) => {
v.hidden_origin.retain(|x| x != n);
if !visible {
v.hidden_origin.push(n.clone());
}
}
}
}
s.revision += 1;
Ok(json!({ "visible": visible, "changed": items.len(), "visibility": s.visibility }))
}
fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "document.units";
let u = str_(p, "units").ok_or_else(|| bad(cmd, "`units` is required"))?;
@@ -339,6 +420,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
d.body_components.insert(name.to_string(), c);
}
rename_key(d, "bodies", &old, name);
for b in s.visibility.hidden_bodies.iter_mut().filter(|b| **b == old) {
*b = name.to_string();
}
Ok(json!({"body": old, "name": name}))
}
@@ -411,7 +495,9 @@ mod tests {
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
let z = zmax(&mut s);
assert!(z > 9.0, "the profile stands up on XZ: {z}");
assert!(z.abs() < 1e-6, "positive sketch Y on XZ extends toward -Z: {z}");
let ymin = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap();
assert!((ymin + 20.0).abs() < 1e-6);
assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err());
assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err());
}
@@ -478,6 +564,60 @@ mod tests {
assert_eq!(s.doc.named_views.len(), 2);
}
#[test]
fn helper_sketches_hide_and_show() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let floor = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [50, 0]], "construction": true}));
run(&mut s, "sketch.finish", json!({}));
let drain = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.point", json!({"point": [25, 5]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Floor"}));
let body = s.model.state().bodies[0].name.clone();
let drain_name = s.doc.feature(drain).unwrap().name.clone();
let cam = solvecraft_render::Camera::default();
let lines = |s: &Session| crate::view::scene(s, &cam).lines.len();
let shown = |s: &mut Session| {
let d = run(s, "document.inspect", json!({}));
d["sketches"].as_array().unwrap().iter().filter(|x| x["visible"] == true).count()
};
let (n0, undo0, doc0) = (lines(&s), s.undo.len(), s.doc.clone());
assert_eq!(shown(&mut s), 2);
// By id and by name; the view loses their lines, the design and its history don't change.
let r = run(&mut s, "browser.visibility", json!({"items": [floor, drain_name], "visible": false}));
assert_eq!(r["changed"], 2);
assert_eq!(shown(&mut s), 0);
assert!(lines(&s) < n0);
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc0) && s.undo.len() == undo0, "view state, not a design change");
run(&mut s, "browser.visibility", json!({"folder": "sketches", "visible": true}));
assert_eq!(lines(&s), n0);
// Bodies (following a rename) and construction planes.
run(&mut s, "browser.visibility", json!({"items": [body.clone(), "Floor", "xy"], "visible": false}));
assert!(crate::view::scene(&s, &cam).meshes.is_empty());
assert_eq!(s.visibility.hidden_origin, vec!["Floor".to_string(), "XY".to_string()]);
run(&mut s, "body.rename", json!({"body": body, "name": "Tray"}));
assert_eq!(s.visibility.hidden_bodies, vec!["Tray".to_string()]);
let d = run(&mut s, "document.inspect", json!({}));
assert_eq!(d["bodies"][0]["visible"], false);
// Hostile input is refused and changes nothing.
let before = s.visibility.clone();
for p in [
json!({"items": [floor]}),
json!({"items": [floor], "visible": "no"}),
json!({"items": [floor, "Nope"], "visible": false}),
json!({"items": [], "visible": false}),
json!({"items": "Floor", "visible": false}),
json!({"folder": "sketches", "items": [floor], "visible": false}),
json!({"folder": "nope", "visible": false}),
json!({"visible": false}),
] {
assert!(s.execute("browser.visibility", &p).is_err(), "{p}");
}
assert_eq!(s.visibility, before);
}
#[test]
fn groups_hold_items_and_follow_renames() {
let mut s = Session::default();
+27 -4
View File
@@ -10,6 +10,12 @@ use crate::{EngineError, Result, Sel, Session, Snapshot};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
.noundo()
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint)
.noundo()
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"),
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
CommandSpec::new("timeline.delete", "Delete", delete)
.at("SOLID", "MODIFY")
@@ -28,8 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.params("feature: id|name → features deleted with it, and features that would fail without it"),
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature)
.params("feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}}"),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
"feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \
restored: features whose earlier result for the same inputs was reused",
),
CommandSpec::new("select.set", "Select", select_set)
.noundo()
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"),
@@ -58,6 +66,21 @@ fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"redone": label}))
}
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"checkpoint": s.checkpoint()}))
}
fn restore_checkpoint(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "edit.restore_checkpoint";
let id = p.get("checkpoint").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`checkpoint` must be an id from edit.checkpoint"))?;
if bool_(p, "forget") == Some(true) {
s.drop_checkpoint(id);
return Ok(json!({"checkpoint": id, "restored": false}));
}
s.restore_checkpoint(id)?;
Ok(json!({"checkpoint": id, "restored": true}))
}
fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
s.model = solvecraft_doc::Model::new();
s.refresh();
@@ -204,7 +227,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
}
fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
@@ -307,7 +330,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
}
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
+2 -2
View File
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("extrude")
.key("E")
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
@@ -581,7 +581,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
let new = format!("({old}) + ({distance})");
let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?;
let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?;
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed")}));
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed"), "restored": out.get("restored")}));
}
// Another curved face moves along its normal.
let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?;
+14 -4
View File
@@ -170,6 +170,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
"profiles": profiles,
"links": sk.links,
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link})).collect::<Vec<_>>(),
"visible": crate::view::sketch_shown(s, id),
}))
}
@@ -207,18 +208,27 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let with_measure = bool_(p, "measure").unwrap_or(false);
let bodies: Vec<Value> =
st.bodies.iter().map(|b| if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) }).collect();
let bodies: Vec<Value> = st
.bodies
.iter()
.map(|b| {
let mut v = if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) };
if let Some(o) = v.as_object_mut() {
o.insert("visible".into(), json!(!s.visibility.hidden_bodies.contains(&b.name)));
}
v
})
.collect();
let sketches: Vec<Value> = st
.sketches
.iter()
.map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len()}))
.map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len(), "visible": crate::view::sketch_shown(s, ss.feature)}))
.collect();
Ok(json!({
"name": s.doc.name, "units": s.doc.units, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
"active_sketch": s.active_sketch, "marker": marker,
"params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches,
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(),
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(), "hidden_origin": s.visibility.hidden_origin,
}))
}
+49 -10
View File
@@ -55,13 +55,13 @@ fn densify(pts: &[Vec3]) -> Vec<Vec3> {
/// The mesh edge a selection means: by index if it still passes through the point, otherwise
/// the nearest.
fn edge_points(m: &Mesh, index: usize, point: Vec3) -> Option<Vec<Vec3>> {
fn edge_index(m: &Mesh, index: usize, point: Vec3) -> Option<usize> {
let near = |e: &Vec<Vec3>| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
let tol = m.bounds().diagonal().max(1.0) * 1e-6;
if let Some(e) = m.edges.get(index).filter(|e| near(e) < tol) {
return Some(e.clone());
if m.edges.get(index).is_some_and(|e| near(e) < tol) {
return Some(index);
}
m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).cloned()
m.edges.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b))).map(|(i, _)| i)
}
fn face_tris(m: &Mesh, f: usize) -> Vec<[Vec3; 3]> {
@@ -75,15 +75,23 @@ fn describe(s: &Session, sel: &Sel) -> Option<(Geo, Option<Vec3>, Value)> {
Sel::Vertex { point, .. } => (Geo::Points(vec![*point]), None, json!({"type": "vertex", "position": point})),
Sel::Edge { body, index, point } => {
let m = st.body(body)?.mesh();
let pts = edge_points(&m, *index, *point)?;
let index = edge_index(&m, *index, *point)?;
let pts = m.edges.get(index)?;
let rim = m.circular_rim(index);
if rim.len() > 1 {
let pieces: Vec<&Vec<Vec3>> = rim.iter().filter_map(|&i| m.edges.get(i)).collect();
let len: f64 = pieces.iter().map(|p| polyline_len(p)).sum();
let points = pieces.iter().flat_map(|p| densify(p)).collect();
return Some((Geo::Points(points), None, json!({"type": "edge", "length_mm": len})));
}
let (a, b) = (*pts.first()?, *pts.last()?);
let straight = (b - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(b))));
let len = polyline_len(&pts);
(Geo::Points(densify(&pts)), straight, json!({"type": "edge", "length_mm": len}))
let len = polyline_len(pts);
(Geo::Points(densify(pts)), straight, json!({"type": "edge", "length_mm": len}))
}
Sel::Face { body, index, .. } => {
let m = st.body(body)?.mesh();
let tris = face_tris(&m, *index);
let tris = m.surface_patch(*index).into_iter().flat_map(|f| face_tris(&m, f)).collect::<Vec<_>>();
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
let normals: Vec<Vec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect();
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
@@ -150,10 +158,41 @@ fn closest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> {
pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
let cmd = "inspect.measure";
let list = items.as_array().ok_or_else(|| bad(cmd, "`items` must be a list of selections"))?;
if list.is_empty() || list.len() > 2 {
if list.is_empty() || list.len() > 1000 {
return Err(bad(cmd, "measure one or two items"));
}
let sels: Vec<Sel> = list.iter().map(|v| serde_json::from_value::<Sel>(v.clone()).map_err(|e| bad(cmd, e.to_string()))).collect::<Result<_>>()?;
let st = s.world_state();
let mut sels = Vec::new();
for value in list {
let mut sel = serde_json::from_value::<Sel>(value.clone()).map_err(|e| bad(cmd, e.to_string()))?;
// Two kernel halves of one visible item are one measurement target.
match &mut sel {
Sel::Edge { body, index, point } => {
if let Some(b) = st.body(body) {
let m = b.mesh();
let resolved = edge_index(&m, *index, *point).unwrap_or(*index);
*index = m.circular_rim(resolved).first().copied().unwrap_or(resolved);
}
}
Sel::Face { body, index, .. } => {
if let Some(b) = st.body(body) {
*index = b.mesh().surface_patch(*index).first().copied().unwrap_or(*index);
}
}
_ => {}
}
let duplicate = sels.iter().any(|x| match (x, &sel) {
(Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. })
| (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j,
_ => false,
});
if !duplicate {
sels.push(sel);
}
if sels.len() > 2 {
return Err(bad(cmd, "measure one or two items"));
}
}
let mut described = Vec::new();
for x in &sels {
described.push(describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces or bodies)"))?);
+3 -1
View File
@@ -12,7 +12,8 @@ use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\" (new parameters default to their value's unit), comment?, \
favorite?: bool, new_name?: rename (references follow); or delete: name",
favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \
restored: features whose earlier result for the same inputs was reused (e.g. a value set back)",
),
CommandSpec::new("parameters.list", "List Parameters", list).noundo().params(
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
@@ -107,6 +108,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
out["value"] = json!(vals.get(&name).map(|v| v.v));
out["display_value"] = json!(s.doc.param_display_value(&vals, &name));
out["recomputed"] = json!(s.model.last_recomputed);
out["restored"] = json!(s.model.last_restored);
out["errors"] = json!(failing(s));
Ok(out)
}
+19
View File
@@ -67,6 +67,25 @@ fn width_follows_an_expression() {
assert!(e.contains("expected a length, got an angle"), "{e}");
}
/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the
/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken
/// for "nothing changed". timeline.edit reports the same.
#[test]
fn setting_a_parameter_back_reports_restored_features() {
let mut s = Session::default();
block(&mut s);
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}");
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}");
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "10"}}}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(1)), "{r}");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
}
#[test]
fn feature_inputs_are_named_and_editable() {
let mut s = Session::default();
+1 -1
View File
@@ -231,7 +231,7 @@ fn cyl(s: &mut Session, d: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
/// A solid of revolution about z from a closed (r, z) outline, as one body or joined.
fn revolve(s: &mut Session, pts: &[(f64, f64)], op: &str) -> Result<()> {
run(s, "sketch.create", json!({"plane": "XZ"}))?;
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, *z]).collect();
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, -*z]).collect();
run(s, "sketch.line", json!({"points": points, "closed": true}))?;
run(s, "sketch.finish", json!({}))?;
run(s, "solid.revolve", json!({"axis": "y", "operation": op}))?;
+1 -1
View File
@@ -10,7 +10,7 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] =
&[CommandSpec::new("inspect.section", "Section Analysis", section).at("SOLID", "INSPECT").icon("section").noundo().params(
"plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: -Y, YZ: +X); \
"plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: +Y, YZ: +X); \
at?: expr, the cut's world coordinate on the named plane's axis (XY: Z, XZ: Y, YZ: X), instead of offset; \
flip?: bool; or clear: true. Returns the cut's origin and normal, and for a named plane its axis and at",
)];
+36 -12
View File
@@ -1,7 +1,7 @@
//! Sketch commands: create/edit/finish sketches, draw curves, constraints and dimensions.
use serde_json::{Value, json};
use solvecraft_doc::{FeatureKind, PlaneRef};
use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind};
use solvecraft_geom::{Plane, Vec2, Vec3};
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
@@ -94,7 +94,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(concat!("p0, p1: overall ends, width", connect_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current)",
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
.at("SKETCH", "CONSTRAINTS")
@@ -359,8 +359,10 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
}
Some(v @ Value::Object(o)) => {
if let Some(fp) = o.get("face").and_then(vec3) {
// A planar face of a body at this point.
let st = s.model.state();
// A planar face of a body at this point, as the active component sees the model
// (another component's face where it is placed).
let model = s.model.state();
let st = solvecraft_doc::state_in_frame(&s.doc, &model, s.active_component);
let mut found = None;
let mut face_name = None;
for b in &st.bodies {
@@ -406,7 +408,10 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo
let s1 = (b - a).cross(p - a).dot(n);
let s2 = (c - b).cross(p - b).dot(n);
let s3 = (a - c).cross(p - c).dot(n);
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0) || (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0)
|| (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
// Exact curved boundaries can sit just outside their tessellated chords.
|| p.dist_to_segment(a, b).min(p.dist_to_segment(b, c)).min(p.dist_to_segment(c, a)) <= tol * 0.1
})
}
@@ -1332,7 +1337,9 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
};
let is_line = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. }));
let is_circle = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. }));
let (k, cur): (ConstraintKind, f64) = match (e0, e1) {
// The other reading of an angle between two lines (see below).
let mut alt: Option<(ConstraintKind, f64)> = None;
let (mut k, mut cur): (ConstraintKind, f64) = match (e0, e1) {
((None, Some(c)), None) if is_line(c) => {
let (a, b) = line_pts(sk, c).unwrap_or_default();
match ty.as_str() {
@@ -1409,7 +1416,14 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
ang = -ang;
std::mem::swap(&mut ka, &mut kb);
}
(ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang)
// Two lines make two angles, θ and 180° − θ, whichever way they were
// drawn: the acute one unless the value asks for the other.
let direct = (ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang);
let supplement =
(ConstraintKind::Angle { a: kb, b: ka, value: std::f64::consts::PI - ang, flip: true }, std::f64::consts::PI - ang);
let (acute, obtuse) = if ang <= std::f64::consts::FRAC_PI_2 { (direct, supplement) } else { (supplement, direct) };
alt = Some(obtuse);
acute
}
}
}
@@ -1426,7 +1440,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
c.driven = true;
}
before = None;
return Ok((String::new(), name, cur));
return Ok((String::new(), name, measured(is_angle, cur)));
}
let (unit, default_expr) =
if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) };
@@ -1437,20 +1451,30 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(err) = errs.get(&pname) {
return Err(bad(cmd, format!("value: {err}")));
}
let _ = vals;
// An angle between two lines dimensions whichever of its two readings is nearer the value.
if let (Some((ka, ca)), Some(want)) = (alt, vals.get(&pname).and_then(|v| v.to_kind(Kind::Angle).ok()))
&& (want - ca).abs() < (want - cur).abs()
{
(k, cur) = (ka, ca);
}
let name = k.name();
let id = sk.add_constraint(k, Some(pname.clone()))?;
place_text(sk, &id, text_at);
Ok((pname, name, cur))
Ok((pname, name, measured(is_angle, cur)))
})?;
if driven {
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current, "sketch": info}));
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}));
}
if reject_redundant(before, &info, cmd).is_err() {
return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true"));
}
let v = s.doc.param(&param).map(|p| p.expr.clone()).unwrap_or_default();
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current, "sketch": info}))
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info}))
}
/// A dimension's measured value as reported, in the unit its expression is written in.
fn measured(is_angle: bool, v: f64) -> (f64, &'static str) {
if is_angle { (round6(v.to_degrees()), "deg") } else { (round6(v), "mm") }
}
/// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the
+3
View File
@@ -57,6 +57,8 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
return Err(bad(cmd, "`width` must be positive"));
}
let opacity = num(p, "opacity").unwrap_or(if decal { 1.0 } else { 0.5 }).clamp(0.0, 1.0);
// It belongs to the active component (its plane is in that frame) and moves with it.
let component = s.active_component;
let doc = s.doc_mut();
let id = doc.canvases.iter().map(|c| c.id).max().unwrap_or(0) + 1;
let base = if decal { "Decal" } else { "Canvas" };
@@ -74,6 +76,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
opacity,
flip: bool_(p, "flip").unwrap_or(false),
visible: true,
component,
});
Ok(json!({"canvas": id, "name": name, "pixels": pixels}))
}
@@ -201,3 +201,65 @@ fn drawing_onto_a_curve_keeps_the_point_on_it() {
let cc = sk.center(sk.curve_index(&c).unwrap()).unwrap();
assert!((st.dist(cc) - 8.0).abs() < 1e-7, "{st:?} {cc:?}");
}
/// A faucet spout path: a vertical riser, a horizontal top and an outlet sloping down, joined
/// by fillets. The riser points up and the outlet down-left, so their directions are 150° apart
/// while the lines meet at 30°.
fn spout() -> Session {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[795, 955], [795, 1068], [689, 1068], [661, 1020]], "ids": ["l1", "l2", "l3"]}));
run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 50}));
run(&mut s, "sketch.fillet", json!({"point": "l2.end", "radius": 35}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "vertical"}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "horizontal"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 795}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 955}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l2.start"], "type": "vertical", "value": 1068}));
run(&mut s, "sketch.dimension", json!({"entities": ["l3.end", "l1.start"], "type": "horizontal", "value": 134}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l3.end"], "type": "vertical", "value": 1020}));
assert_eq!(d["sketch"]["dof"], 1, "{d}");
s
}
/// The angle (degrees, 0..=90) between two lines of the active sketch.
fn line_angle(s: &Session, a: &str, b: &str) -> f64 {
let sk = sketch(s);
let dir = |l: &str| {
let c = sk.curve_index(l).unwrap();
let (p, q) = crate::cmd::sketch::line_pts(&sk, c).unwrap();
q - p
};
let (u, v) = (dir(a), dir(b));
let t = u.cross(v).abs().atan2(u.dot(v)).to_degrees();
t.min(180.0 - t)
}
#[test]
fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degrees() {
// The riser and the outlet are not adjacent and meet at 30°; their directions are 150°
// apart. Asking for 30° used to swing the outlet round and was refused as over-constraining.
for ents in [["l1", "l3"], ["l3", "l1"]] {
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ents, "type": "angle", "value": "30 deg"}));
assert_eq!(d["sketch"]["dof"], 0, "{d}");
assert_eq!(d["unit"], "deg", "{d}");
assert!((d["measured"].as_f64().unwrap() - 30.3).abs() < 0.1, "{d}");
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
}
// The other reading still works, and the adjacent top line measures 60° the same way.
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"], "value": "150 deg"}));
assert_eq!(d["sketch"]["dof"], 0, "{d}");
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2", "l3"], "value": "60 deg"}));
assert!((d["measured"].as_f64().unwrap() - 59.74).abs() < 0.01, "{d}");
// Without a value the acute angle is kept as it is.
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"]}));
let e = d["expression"].as_str().unwrap();
assert!(e.ends_with(" deg") && (e.trim_end_matches(" deg").parse::<f64>().unwrap() - 30.3).abs() < 0.1, "{d}");
// Lengths report millimetres.
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "driven": true}));
assert_eq!(d["unit"], "mm", "{d}");
}
+14 -2
View File
@@ -520,7 +520,7 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize
let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs();
if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-9 {
if !(1e-6..=std::f64::consts::FRAC_PI_2 - 1e-9).contains(&half) {
return Err(bad(cmd, "the lines are parallel"));
}
let d = r / half.tan();
@@ -813,6 +813,12 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
}
let ci = sk.add_circle(cc, nr, Some(c), None)?;
made.push(ci);
sk.offsets.push(solvecraft_sketch::OffsetChain {
sources: vec![(ids[0].clone(), true)],
outputs: ids_of(sk, &made),
distance: r - nr,
closed: true,
});
return Ok((ids_of(sk, &made), cons));
}
let pool: Vec<usize> = if use_chain {
@@ -916,7 +922,13 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok((ids_of(sk, &made), cons))
let outputs = ids_of(sk, &made);
if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) {
let sources = order.iter().filter_map(|(i, forward)| sk.curves.get(*i).map(|c| (c.id.clone(), *forward))).collect();
sk.offsets.push(solvecraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed });
sk.refresh_offsets()?;
}
Ok((outputs, cons))
})?;
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
}
+13 -1
View File
@@ -140,8 +140,20 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki
let plane = sketch_plane(doc, id)?;
let (vals, _) = doc.param_values();
let st = s.model.state_before(id);
// The model as the sketch's component sees it.
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
let st = solvecraft_doc::state_in_frame(doc, &st, comp);
let r = resolve(doc, &vals, &st, &plane, kind, &src)?;
Ok(sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?)
let name = if let LinkSource::Face { at, .. } = &r.source {
solvecraft_doc::naming::face_names_at(&st, &[*at]).into_iter().next().filter(|n| !n.is_empty())
} else {
None
};
let link = sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?;
if let Some(l) = sk.links.iter_mut().find(|l| l.id == link) {
l.face_name = name;
}
Ok(link)
}
fn link_json(sk: &Sketch, link: &str) -> Value {
+34 -9
View File
@@ -30,6 +30,12 @@ enum K {
Plane,
/// A body name, or a list of them.
Body,
/// A point (or a list of picks) for the sketch being made or edited: into that sketch's
/// component frame, whichever component the geometry under it belongs to (a sketch in the
/// root on a moved component's face finds the face where it is shown).
Here,
/// A plane object for a new sketch or canvas: into the active component's frame.
HerePlane,
}
use K::*;
@@ -56,11 +62,12 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
"plastic.lip" | "sheet.convert" => &[("face", Pick), ("body", Body)],
"sheet.flange" | "sheet.hem" => &[("edges", Picks), ("body", Body)],
"sheet.fold" => &[("points", Picks), ("fixed", Pick), ("body", Body)],
"sketch.create" | "sketch.redefine" => &[("plane", Plane)],
"canvas.decal" => &[("face", Pick)],
"sketch.intersect" => &[("refs", Picks)],
"sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Pick), ("body", Body)],
"sketch.intersection_curve" => &[("a", Picks), ("b", Picks)],
"sketch.create" | "sketch.redefine" => &[("plane", HerePlane)],
"canvas.decal" => &[("face", Here)],
"canvas.insert" => &[("plane", HerePlane), ("at", Here)],
"sketch.project" | "sketch.intersect" | "sketch.include_3d" => &[("refs", Here), ("ref", Here)],
"sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Here)],
"sketch.intersection_curve" => &[("a", Here), ("b", Here)],
"inspect.curvature_comb" | "inspect.minimum_radius" | "inspect.isocurve" => &[("edges", Picks), ("faces", Picks)],
"parts.insert" | "parts.fastener" => &[("at", Pick), ("point", Make), ("direction", Dir)],
"appearance.assign" => &[("faces", Picks), ("bodies", Body), ("body", Body)],
@@ -75,6 +82,8 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
struct Frames {
bodies: Vec<(String, String, Mat)>,
active: Mat,
/// The frame of the sketch being edited (its component), else the active component's.
sketch: Mat,
}
fn frames(s: &Session) -> Frames {
@@ -104,7 +113,8 @@ fn frames(s: &Session) -> Frames {
(world, own, m)
})
.collect();
Frames { bodies, active: doc.component_transform(s.active_component) }
let sketch_comp = s.active_sketch.and_then(|id| doc.feature(id)).map(|f| f.component).unwrap_or(s.active_component);
Frames { bodies, active: doc.component_transform(s.active_component), sketch: doc.component_transform(sketch_comp) }
}
/// Does any placement move anything (so the world differs from the components' frames)?
@@ -171,12 +181,14 @@ struct Mapper<'a> {
/// The frame of the command's first pick (its directions turn with it).
first: Option<Mat>,
changed: bool,
/// Picks go into the sketch's frame (`Here`), not the frame of the body under them.
here: bool,
}
impl Mapper<'_> {
fn pick(&mut self, v: &Value, body: Option<&str>) -> Option<Value> {
let p = vec3(v)?;
let m = frame_at(self.s, &self.f, p, body).unwrap_or(self.f.active);
let m = if self.here { self.f.sketch } else { frame_at(self.s, &self.f, p, body).unwrap_or(self.f.active) };
self.first.get_or_insert(m);
self.local_point(&m, p)
}
@@ -325,14 +337,14 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
return None;
}
let obj = params.as_object()?;
let mut m = Mapper { s, f: frames(s), first: None, changed: false };
let mut m = Mapper { s, f: frames(s), first: None, changed: false, here: false };
let body = obj.get("body").and_then(Value::as_str).map(str::to_string);
let mut out: Map<String, Value> = obj.clone();
// Picks first: their frame turns the directions.
for pass in [true, false] {
for (k, kind) in keys {
let Some(v) = obj.get(*k) else { continue };
let is_pick = matches!(kind, Pick | Picks | Plane);
let is_pick = matches!(kind, Pick | Picks | Plane | Here | HerePlane);
if is_pick != pass {
continue;
}
@@ -343,6 +355,19 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
Dir => m.dir(v),
Plane => m.plane(v),
Body => m.body(v),
Here | HerePlane => {
// A new sketch or canvas goes into the active component.
let keep = m.f.sketch;
if *kind == HerePlane {
m.f.sketch = m.f.active;
}
m.here = true;
// Body names stay: the sketch's view of the model keeps them.
let r = if *kind == HerePlane { m.plane(v) } else { m.picks(v, None) };
m.here = false;
m.f.sketch = keep;
r
}
};
if let Some(n) = new {
out.insert((*k).to_string(), n);
+103
View File
@@ -144,3 +144,106 @@ fn sketch_on_a_face_follows_a_moved_body() {
let c = sketch_point_world(&mut s, "OnTop", "p1");
assert!((c.z - 25.0).abs() < 1e-6, "{c:?}");
}
/// A 1 x 1 PNG for canvases.
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
/// Canvases belong to the active component and move with its occurrence; one put on a moved
/// component's face from the root sits on the face (and stays the root's).
#[test]
fn canvases_move_with_their_component() {
let mut s = moved_part();
let part = s.active_component;
assert_ne!(part, 0);
run(&mut s, "canvas.insert", json!({"data": PNG, "plane": "XY", "name": "Plan"}));
// From the root, on the moved block's top face (world).
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "canvas.insert", json!({"data": PNG, "plane": {"face": [65, 10, 10]}, "name": "Lid", "width": 10}));
run(&mut s, "canvas.insert", json!({"data": PNG, "plane": "XZ", "name": "Root"}));
let (vals, _) = s.doc.param_values();
let world = |s: &Session, name: &str| {
let c = s.doc.canvases.iter().find(|c| c.name == name).unwrap();
(c.component, s.doc.canvas_plane(&vals, c).unwrap())
};
let (c, p) = world(&s, "Plan");
assert_eq!(c, part);
assert!(near(p.origin, Vec3::new(50.0, 0.0, 0.0)), "{:?}", p.origin);
let (c, p) = world(&s, "Lid");
assert_eq!(c, 0, "the active component's");
let lid = s.doc.canvases.iter().find(|c| c.name == "Lid").unwrap();
let centre = p.to_world(lid.center);
assert!(near(centre, Vec3::new(65.0, 10.0, 10.0)), "{centre:?}");
assert_eq!(world(&s, "Root").0, 0);
// Moving the occurrence carries its canvases, not the root's.
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 40, 0]}));
let (vals, _) = s.doc.param_values();
let plan = s.doc.canvases.iter().find(|c| c.name == "Plan").unwrap();
assert!(near(s.doc.canvas_plane(&vals, plan).unwrap().origin, Vec3::new(50.0, 40.0, 0.0)));
let lid = s.doc.canvases.iter().find(|c| c.name == "Lid").unwrap();
let centre = s.doc.canvas_plane(&vals, lid).unwrap().to_world(lid.center);
assert!(near(centre, Vec3::new(65.0, 10.0, 10.0)), "{centre:?}");
let root = s.doc.canvases.iter().find(|c| c.name == "Root").unwrap();
assert!(near(s.doc.canvas_plane(&vals, root).unwrap().origin, Vec3::ZERO));
// The component survives a save and reopen.
let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(back.canvases, s.doc.canvases);
}
/// World points of a sketch's curves' points (sorted).
fn sketch_world_points(s: &mut Session, sketch: &str) -> Vec<Vec3> {
let si = run(s, "sketch.inspect", json!({ "sketch": sketch }));
si["points"]
.as_array()
.unwrap()
.iter()
.map(|p| {
let w = &p["world"];
Vec3::new(w[0].as_f64().unwrap(), w[1].as_f64().unwrap(), w[2].as_f64().unwrap())
})
.collect()
}
/// A block "C" (40 x 30 x 20) in its own component, the root active again.
fn block_in_component() -> Session {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "C"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "component.activate", json!({"component": "root"}));
s
}
/// The user's "big square": a sketch made in the root on a moved component's face lies on the
/// face where it is shown, its projected loop on the face's edges (moved and turned), and it
/// follows when the occurrence moves on.
#[test]
fn root_sketch_on_a_moved_components_face() {
let mut s = block_in_component();
run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "translate": [100, 0, 0]}));
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [120, 15, 20]}, "name": "Top"}));
assert!(!r["projected"].is_null(), "the face's edges are projected: {r}");
run(&mut s, "sketch.finish", json!({}));
let pts = sketch_world_points(&mut s, "Top");
assert!(pts.len() >= 4, "{pts:?}");
// Every point but the sketch's origin (the model origin on the face plane).
for p in pts.iter().filter(|p| !near(**p, Vec3::new(0.0, 0.0, 20.0))) {
assert!((p.z - 20.0).abs() < 1e-6 && p.x > 100.0 - 1e-6 && p.x < 140.0 + 1e-6 && p.y > -1e-6 && p.y < 30.0 + 1e-6, "{p:?}");
}
for corner in [Vec3::new(100.0, 0.0, 20.0), Vec3::new(140.0, 30.0, 20.0)] {
assert!(pts.iter().any(|p| near(*p, corner)), "{corner:?} in {pts:?}");
}
assert_eq!(s.doc.find_feature("Top").unwrap().component, 0, "the sketch is the root's");
// Turned 90 degrees about Z (around the world origin): the top face is now x -30..0, y 0..40.
let mut s = block_in_component();
run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "axis": [0, 0, 1], "angle": "90 deg", "origin": [0, 0, 0]}));
run(&mut s, "sketch.create", json!({"plane": {"face": [-15, 20, 20]}, "name": "Top"}));
run(&mut s, "sketch.finish", json!({}));
let pts = sketch_world_points(&mut s, "Top");
for corner in [Vec3::new(0.0, 0.0, 20.0), Vec3::new(-30.0, 40.0, 20.0)] {
assert!(pts.iter().any(|p| near(*p, corner)), "{corner:?} in {pts:?}");
}
// Projected geometry stays on the face when the occurrence moves on (the link re-resolves).
run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "translate": [0, 0, 5]}));
let pts = sketch_world_points(&mut s, "Top");
assert!(pts.iter().any(|p| near(*p, Vec3::new(-30.0, 40.0, 25.0))), "{pts:?}");
}
+78
View File
@@ -168,6 +168,36 @@ 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`).
pub 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>,
next_checkpoint: u64,
}
/// The design, active sketch and undo history at an `edit.checkpoint`.
#[derive(Clone, Debug)]
pub(crate) struct Checkpoint {
pub id: u64,
pub doc: Arc<Document>,
pub active_sketch: Option<u64>,
pub undo: Vec<Snapshot>,
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>,
/// 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).
@@ -188,6 +218,8 @@ pub enum SurfaceAnalysis {
}
const MAX_UNDO: usize = 200;
/// Checkpoints kept (the oldest go first).
const MAX_CHECKPOINTS: usize = 16;
const MAX_LOG: usize = 2000;
impl Default for Session {
@@ -219,6 +251,9 @@ impl Session {
clipboard: Vec::new(),
section: None,
analysis: None,
checkpoints: Vec::new(),
next_checkpoint: 1,
visibility: Visibility::default(),
}
}
@@ -339,6 +374,44 @@ impl Session {
}
}
/// Remember the design, active sketch and undo history; returns the checkpoint's id.
pub fn checkpoint(&mut self) -> u64 {
let id = self.next_checkpoint;
self.next_checkpoint += 1;
self.checkpoints.push(Checkpoint {
id,
doc: self.doc.clone(),
active_sketch: self.active_sketch,
undo: self.undo.clone(),
redo: self.redo.clone(),
});
if self.checkpoints.len() > MAX_CHECKPOINTS {
self.checkpoints.remove(0);
}
id
}
/// Go back to checkpoint `id` as if nothing since had happened (the undo history too). It
/// and later checkpoints are used up.
pub fn restore_checkpoint(&mut self, id: u64) -> Result<()> {
let i = self.checkpoints.iter().position(|c| c.id == id).ok_or_else(|| EngineError::Other(format!("no checkpoint {id}")))?;
let Some(c) = self.checkpoints.drain(i..).next() else { return Err(EngineError::Other(format!("no checkpoint {id}"))) };
self.doc = c.doc;
self.active_sketch = c.active_sketch;
self.undo = c.undo;
self.redo = c.redo;
self.selection.clear();
self.refresh();
Ok(())
}
/// Forget checkpoint `id` (and later ones) without going back.
pub fn drop_checkpoint(&mut self, id: u64) {
if let Some(i) = self.checkpoints.iter().position(|c| c.id == id) {
self.checkpoints.truncate(i);
}
}
/// A scratch copy of the session (same design and model, no history or log) to try
/// commands on without touching this one.
pub fn scratch(&self) -> Session {
@@ -360,6 +433,9 @@ impl Session {
clipboard: self.clipboard.clone(),
section: None,
analysis: None,
checkpoints: Vec::new(),
next_checkpoint: 1,
visibility: Visibility::default(),
}
}
@@ -427,6 +503,8 @@ impl Session {
}
}
#[cfg(test)]
mod boolean_regression_tests;
#[cfg(test)]
mod construct_tests;
#[cfg(test)]
+20 -1
View File
@@ -140,7 +140,11 @@ impl Autosaver {
let _ = fs::remove_file(&p);
}
}
let id = format!("{}-{}", unix_now(), std::process::id());
// Unique within the process too: an app started again in the same second (tests that
// "crash" and restart) must not take over the crashed app's entry.
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let id = format!("{}-{}-{seq}", unix_now(), std::process::id());
let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, &id, "lock")).map_err(io)?;
lock.lock().map_err(io)?;
Ok(Autosaver { dir: dir.to_path_buf(), id, interval, lock: Some(lock), last: None, last_at: Instant::now(), has_entry: false })
@@ -225,6 +229,21 @@ mod tests {
let _ = fs::remove_dir_all(&d);
}
#[test]
fn an_app_restarted_in_the_same_second_still_offers_the_crashed_ones_work() {
let d = dir("restart");
let mut s = Session::default();
let mut a = Autosaver::new(&d, Duration::from_secs(3600)).unwrap();
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
assert!(a.save(&s).unwrap());
drop(a);
let b = Autosaver::new(&d, Duration::from_secs(3600)).unwrap();
let found = orphans(&d);
assert_eq!(found.len(), 1, "the crashed app's entry is offered");
assert_ne!(found[0].id, b.id);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn saving_drops_the_entry_and_a_clean_close_leaves_nothing() {
let d = dir("clean");
+103 -30
View File
@@ -20,6 +20,21 @@ fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
#[test]
fn join_extrude_from_a_chamfered_top_face() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
run(&mut s, "solid.extrude", json!({"distance": 20}));
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": 2}));
let base = volume(&mut s);
run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20]}}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
assert_eq!(s.model.state().bodies.len(), 1);
assert!(rel(volume(&mut s), base + PI * 64.0 * 5.0) < 5e-4);
}
mod component_frames;
mod robustness;
@@ -279,8 +294,6 @@ fn hostile_params_never_panic() {
"chain",
"at",
"curve",
"major",
"minor",
"minor_radius",
"apex",
"rho",
@@ -298,32 +311,47 @@ fn hostile_params_never_panic() {
"kind",
"prefix",
];
let mut internal = Vec::new();
for spec in command_specs() {
if matches!(spec.id, "doc.open" | "file.save" | "file.save_as" | "file.export" | "file.save_mesh" | "sketch.export_dxf") {
continue; // file system side effects are covered by their own tests
}
for v in hostile_values() {
for k in keys {
if k == "path" && spec.id.starts_with("parameters.") {
continue; // parameter files: covered by their own tests (no stray files)
}
let mut s = Session::default();
// A model with a sketch, a body and an active sketch, so most commands get far.
let _ = s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10}));
let _ = s.execute("sketch.create", &json!({"plane": "XY"}));
let _ = s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [5, 5]}));
let p = json!({ k: v.clone() });
if let Err(EngineError::Internal(id, msg)) = s.execute(spec.id, &p) {
internal.push(format!("{id} {p}: {msg}"));
}
// Whole-value hostility too.
if let Err(EngineError::Internal(id, msg)) = s.execute(spec.id, &v) {
internal.push(format!("{id} {v}: {msg}"));
}
}
}
}
// Commands are independent; worker threads take them one at a time (their costs vary a lot).
let ids: Vec<&str> = command_specs()
.iter()
.map(|spec| spec.id)
.filter(|id| !matches!(*id, "doc.open" | "file.save" | "file.save_as" | "file.export" | "file.save_mesh" | "sketch.export_dxf")) // file system side effects are covered by their own tests
.collect();
let next = std::sync::atomic::AtomicUsize::new(0);
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let internal: Vec<String> = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut internal = Vec::new();
while let Some(&id) = ids.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
for v in hostile_values() {
for k in keys {
if k == "path" && id.starts_with("parameters.") {
continue; // parameter files: covered by their own tests (no stray files)
}
let mut s = Session::default();
// A model with a sketch, a body and an active sketch, so most commands get far.
let _ = s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10}));
let _ = s.execute("sketch.create", &json!({"plane": "XY"}));
let _ = s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [5, 5]}));
let p = json!({ k: v.clone() });
if let Err(EngineError::Internal(id, msg)) = s.execute(id, &p) {
internal.push(format!("{id} {p}: {msg}"));
}
// Whole-value hostility too.
if let Err(EngineError::Internal(id, msg)) = s.execute(id, &v) {
internal.push(format!("{id} {v}: {msg}"));
}
}
}
}
internal
})
})
.collect();
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
});
assert!(internal.is_empty(), "commands panicked:\n{}", internal.join("\n"));
}
@@ -637,10 +665,10 @@ fn section_analysis() {
assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 0, 0]}})).is_err());
run(&mut s, "inspect.section", json!({"clear": true}));
assert!(s.section.is_none());
// XZ's normal is -Y: an offset moves the cut to -Y, `at` places it at a world Y, and the
// XZ's normal is +Y: an offset moves the cut to +Y, `at` places it at a world Y, and the
// result says where the cut landed either way.
let r = run(&mut s, "inspect.section", json!({"plane": "XZ", "offset": 10}));
assert_eq!((r["section"]["axis"].as_str(), r["section"]["at"].as_f64()), (Some("Y"), Some(-10.0)), "{r}");
assert_eq!((r["section"]["axis"].as_str(), r["section"]["at"].as_f64()), (Some("Y"), Some(10.0)), "{r}");
for (plane, at, flip) in [("XZ", -282.5, false), ("XZ", 7.0, true), ("XY", 3.0, false), ("YZ", -4.0, true)] {
let r = run(&mut s, "inspect.section", json!({"plane": plane, "at": at, "flip": flip}));
assert_eq!(r["section"]["at"].as_f64(), Some(at), "{plane} {r}");
@@ -811,6 +839,51 @@ fn pipe_scale_offset_bounding_and_materials() {
assert!(s.execute("material.assign", &json!({"bodies": ["Cube"], "material": "unobtainium"})).is_err());
}
/// Regression: deleting a pipe's path sketch left the pipe in the timeline ("unknown sketch").
#[test]
fn deleting_a_path_sketch_deletes_its_pipe() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]], "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
let pipe = run(&mut s, "solid.pipe", json!({"path_sketch": "Path", "path": ["l1"], "diameter": 4}));
let deps = run(&mut s, "timeline.dependents", json!({"feature": "Path"}));
assert_eq!(deps["deleted_with_it"].as_array().map(Vec::len), Some(1), "{deps} (pipe {pipe})");
let r = run(&mut s, "timeline.delete", json!({"features": ["Path"]}));
assert_eq!(r["deleted"].as_array().map(Vec::len), Some(2), "{r}");
assert!(s.doc.features.is_empty(), "{:?}", s.doc.features.iter().map(|f| &f.name).collect::<Vec<_>>());
assert!(s.model.results.iter().all(|r| r.error.is_none()));
}
/// Regression: batches were rolled back by undoing as many steps as the undo history grew,
/// which is none once the history is full, leaving their sketches behind.
#[test]
fn checkpoint_restores_with_a_full_undo_history() {
let mut s = Session::default();
for i in 0..MAX_UNDO + 5 {
run(&mut s, "parameters.add", json!({"name": format!("p{i}"), "expression": "1 mm"}));
}
assert_eq!(s.undo.len(), MAX_UNDO);
let labels: Vec<String> = s.undo.iter().map(|u| u.label.clone()).collect();
let doc = s.doc.clone();
let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "TapHole"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 2}));
assert!(s.active_sketch.is_some());
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id}));
assert!(Arc::ptr_eq(&s.doc, &doc) && s.active_sketch.is_none());
assert!(s.doc.find_feature("TapHole").is_none());
assert_eq!(s.undo.iter().map(|u| u.label.clone()).collect::<Vec<_>>(), labels);
// Used up: a second restore is an error, not a jump somewhere else.
assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err());
// Forgetting one leaves the design alone.
let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Kept"}));
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id, "forget": true}));
assert!(s.doc.find_feature("Kept").is_some());
assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err());
}
#[test]
fn components_occurrences_and_world_placement() {
let mut s = Session::default();
+28 -6
View File
@@ -41,10 +41,11 @@ pub mod colors {
pub fn bounds(s: &Session) -> Aabb3 {
let st = s.world_state();
let mut b = Aabb3::EMPTY;
for body in &st.bodies {
let v = &s.visibility;
for body in st.bodies.iter().filter(|x| !v.hidden_bodies.contains(&x.name)) {
b = b.union(&body.mesh().bounds());
}
for ss in &st.sketches {
for ss in st.sketches.iter().filter(|x| !v.hidden_sketches.contains(&x.feature)) {
if let Some((lo, hi)) = ss.sketch.bounds() {
for p in [lo, hi] {
b.add(ss.plane.to_world(p));
@@ -121,7 +122,7 @@ pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool {
/// The scene for a headless render.
pub fn scene(s: &Session, cam: &Camera) -> Scene {
let st = s.world_state();
let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), ..Default::default() };
let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), clip: s.section.map(|(o, n)| (n, n.dot(o))), ..Default::default() };
let b = bounds(s);
sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target);
// Grid on the ground plane (XY with Z up, XZ with Y up).
@@ -151,7 +152,7 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, width: 1.6, on_top: false });
}
for body in &st.bodies {
for body in st.bodies.iter().filter(|b| !s.visibility.hidden_bodies.contains(&b.name)) {
let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name));
let mesh = body.mesh();
// The body's appearance (or imported colour) and faces with looks of their own, as in
@@ -174,8 +175,7 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
}
for ss in &st.sketches {
let active = s.active_sketch == Some(ss.feature);
// Finished sketches are hidden once a feature uses them, like Fusion's default.
if !active && sketch_consumed(s, ss.feature) {
if !sketch_shown(s, ss.feature) {
continue;
}
for (pts, col, cons) in sketch_lines(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) {
@@ -185,6 +185,13 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
sc
}
/// Is a sketch drawn? The active one is; others unless hidden. Finished sketches are hidden
/// once a feature uses them, like Fusion's default, unless shown one by one.
pub fn sketch_shown(s: &Session, id: u64) -> bool {
let v = &s.visibility;
s.active_sketch == Some(id) || (!v.hidden_sketches.contains(&id) && (v.shown_sketches.contains(&id) || !sketch_consumed(s, id)))
}
/// Is a sketch used by a later feature?
pub fn sketch_consumed(s: &Session, id: u64) -> bool {
use solvecraft_doc::FeatureKind;
@@ -293,4 +300,19 @@ mod tests {
let dark = img.rgba.chunks(4).filter(|p| p[0] < 70 && p[1] < 70 && p[2] < 75).count();
assert!(dark > 40, "silhouette pixels: {dark}");
}
/// A section shows in snapshots: the cut part is gone and the cut face is drawn.
#[test]
fn snapshots_show_the_section() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
let cam = home_camera(&s);
let whole = solvecraft_render::render(&scene(&s, &cam), &cam, 200, 140).rgba;
s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap();
let sc = scene(&s, &cam);
assert!(sc.clip.is_some());
let cut = solvecraft_render::render(&sc, &cam, 200, 140).rgba;
let changed = whole.chunks(4).zip(cut.chunks(4)).filter(|(a, b)| a != b).count();
assert!(changed > 500, "{changed} pixels changed");
}
}
+65
View File
@@ -82,6 +82,71 @@ impl Mesh {
pub fn face_edges(&self, f: u32) -> Vec<usize> {
self.edge_faces.iter().enumerate().filter(|(_, fs)| fs.contains(&f)).map(|(i, _)| i).collect()
}
/// Pieces of one surface joined across hidden seams, including `face` itself.
pub fn surface_patch(&self, face: usize) -> Vec<usize> {
let mut patch = vec![face];
let mut todo = vec![face];
while let Some(f) = todo.pop() {
for (ei, fs) in self.edge_faces.iter().enumerate() {
if !self.seams.get(ei).copied().unwrap_or(false) || !fs.iter().any(|x| *x as usize == f) {
continue;
}
for &next in fs {
let next = next as usize;
if !patch.contains(&next) {
patch.push(next);
todo.push(next);
}
}
}
}
patch.sort_unstable();
patch
}
/// A complete circular rim may be split into several kernel edges. Keep its pieces
/// together for picking, without joining other tangent curves or changing edge indices.
pub fn circular_rim(&self, edge: usize) -> Vec<usize> {
let single = vec![edge];
let Some(p) = self.edges.get(edge).filter(|p| p.len() >= 3) else { return single };
let (Some(&a), Some(&b), Some(&c)) = (p.first(), p.get(p.len() / 2), p.last()) else { return single };
let (u, v) = (b - a, c - a);
let n = u.cross(v);
if !(n.len2() > u.len2() * v.len2() * 1e-12) {
return single;
}
let center = a + (v.cross(n) * u.len2() + n.cross(u) * v.len2()) / (2.0 * n.len2());
let radius = center.dist(a);
let normal = n.normalized().unwrap_or(Vec3::ZERO);
let tol = (radius * 1e-6).max(1e-9);
let mut rim = self.tangent_chain(edge, 2f64.to_radians());
if rim.len() < 2 || !radius.is_finite() {
return single;
}
// All samples must lie on this circle, and every end must join exactly one other
// piece. Open arcs, ellipses, and branching tangent chains stay separate.
for &i in &rim {
let Some(points) = self.edges.get(i) else { return single };
if points.iter().any(|q| !q.is_finite() || (q.dist(center) - radius).abs() > tol || (*q - center).dot(normal).abs() > tol) {
return single;
}
for end in [points.first(), points.last()].into_iter().flatten() {
let joins = rim
.iter()
.filter(|&&j| j != i)
.filter_map(|&j| self.edges.get(j))
.flat_map(|q| [q.first(), q.last()])
.flatten()
.filter(|q| q.dist(*end) < tol)
.count();
if joins != 1 {
return single;
}
}
}
rim.sort_unstable();
rim
}
/// The edges reached from `e` through vertices where the next edge continues smoothly
/// (angle between the end tangents below `max_angle`), including `e` itself.
pub fn tangent_chain(&self, e: usize, max_angle: f64) -> Vec<usize> {
+2 -2
View File
@@ -17,8 +17,8 @@ pub struct Plane {
impl Plane {
/// Top: sketch x = +X, y = +Y, normal +Z.
pub const XY: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y };
/// Front: sketch x = +X, y = +Z, normal −Y.
pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
/// Front: sketch x = +X, y = −Z, normal +Y.
pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::new(0.0, 0.0, -1.0) };
/// Right: sketch x = +Y, y = +Z, normal +X.
pub const YZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::Y, y: Vec3::Z };
+42 -2
View File
@@ -28,10 +28,10 @@ fn raycast_hits_front_face() {
#[test]
fn planes() {
assert_eq!(Plane::XY.normal(), Vec3::Z);
assert_eq!(Plane::XZ.normal(), Vec3::new(0.0, -1.0, 0.0));
assert_eq!(Plane::XZ.normal(), Vec3::Y);
assert_eq!(Plane::YZ.normal(), Vec3::X);
let p = Plane::XZ.to_world(Vec2::new(3.0, 4.0));
assert_eq!(p, Vec3::new(3.0, 0.0, 4.0));
assert_eq!(p, Vec3::new(3.0, 0.0, -4.0));
assert_eq!(Plane::XZ.to_local(p), Vec2::new(3.0, 4.0));
let hit = Plane::XY.offset(5.0).intersect_ray(Vec3::new(1.0, 2.0, 10.0), Vec3::new(0.0, 0.0, -2.0)).unwrap();
assert!(hit.dist(Vec3::new(1.0, 2.0, 5.0)) < 1e-12);
@@ -84,6 +84,46 @@ fn tangent_chains_follow_smooth_edges() {
assert_eq!(m.face_edges(1), vec![1, 2]);
}
#[test]
fn circular_rims_group_only_closed_cocircular_edges() {
let arc = |start: f64, sweep: f64, yscale: f64| -> Vec<Vec3> {
(0..=32)
.map(|i| {
let a = start + sweep * i as f64 / 32.0;
Vec3::new(10.0 * a.cos(), yscale * 10.0 * a.sin(), 5.0)
})
.collect()
};
let pi = std::f64::consts::PI;
let mut m = Mesh {
positions: vec![Vec3::new(-10.0, -10.0, 5.0), Vec3::new(10.0, 10.0, 5.0)],
edges: vec![arc(0.0, pi, 1.0), arc(2.0 * pi, -pi, 1.0)],
..Default::default()
};
assert_eq!(m.circular_rim(0), vec![0, 1]);
assert_eq!(m.circular_rim(1), vec![0, 1]);
// A hidden seam cannot form a selectable rim.
m.seams = vec![false, true];
assert_eq!(m.circular_rim(0), vec![0]);
m.seams.clear();
m.edges[1] = arc(pi, pi / 2.0, 1.0);
assert_eq!(m.circular_rim(0), vec![0]);
// A closed, smooth ellipse is not a circle.
m.edges = vec![arc(0.0, pi, 0.7), arc(pi, pi, 0.7)];
assert_eq!(m.circular_rim(0), vec![0]);
assert_eq!(m.circular_rim(99), vec![99]);
}
#[test]
fn surface_patches_cross_only_hidden_seams() {
let mut m = Mesh { edge_faces: vec![vec![0, 1], vec![1, 2], vec![2, 3]], seams: vec![true, true, false], ..Default::default() };
assert_eq!(m.surface_patch(0), vec![0, 1, 2]);
assert_eq!(m.surface_patch(2), vec![0, 1, 2]);
assert_eq!(m.surface_patch(3), vec![3]);
m.seams.clear();
assert_eq!(m.surface_patch(0), vec![0]);
}
#[test]
fn cubic_and_conic_segments_measure_exactly() {
use crate::{Loop2, Seg2, Vec2};
+56 -1
View File
@@ -795,13 +795,68 @@ pub enum PathSeg {
},
}
impl PathSeg {
/// Unit directions of the path where the segment starts and ends.
fn tangents(&self) -> Option<(Vec3, Vec3)> {
match *self {
PathSeg::Line { a, b } => {
let t = (b - a).normalized()?;
Some((t, t))
}
PathSeg::Arc { a, center, axis, angle } => {
let k = axis.normalized()?;
let end = rotate_about(a, center, k, angle);
Some((k.cross(a - center).normalized()?, k.cross(end - center).normalized()?))
}
}
}
/// Points along the segment (an arc within `tol` of its chords), without its start.
fn points_after_start(&self, tol: f64) -> Vec<Vec3> {
match *self {
PathSeg::Line { b, .. } => vec![b],
PathSeg::Arc { a, center, axis, angle } => {
let Some(k) = axis.normalized() else { return vec![a] };
let r = (a - center).len();
let step = if r > tol { 2.0 * (1.0 - tol / r).acos() } else { angle.abs() };
let n = ((angle.abs() / step.max(1e-6)).ceil() as usize).clamp(1, 512);
(1..=n).map(|i| rotate_about(a, center, k, angle * i as f64 / n as f64)).collect()
}
}
}
}
/// `p` turned by `angle` about the unit axis `k` through `center` (right hand).
fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 {
let v = p - center;
let (s, c) = angle.sin_cos();
center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c))
}
/// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile
/// perpendicular to the path (lines translate it, arcs rotate it).
/// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners
/// is swept as a polyline, mitred at the corners.
pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body> {
region_ok(region)?;
if path.is_empty() || path.len() > 1000 {
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
}
// Segment by segment the profile only follows a path whose direction runs on across each
// joint: at a corner the next line would slide the profile within its own plane.
let ends: Vec<(Vec3, Vec3)> =
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
let corner = ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos());
if corner {
let first = match path.first() {
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
};
let mut pts = vec![first];
for seg in path {
pts.extend(seg.points_after_start(1e-2));
}
return crate::sweep_path::sweep_path(plane, region, &pts);
}
// The outer loop and each hole, swept along the path; the ends are capped with holes.
let mut starts = vec![wire(plane, &region.outer.ccw())?];
for h in &region.holes {
+8 -2
View File
@@ -404,6 +404,12 @@ pub fn heal(solid: Solid, size: f64) -> Solid {
/// [`heal`], keeping faces apart along lines they were split along on purpose.
pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> Solid {
// Edges a boolean made are intersection curves, which panic where they no longer
// evaluate; the solid then stays as it is.
crate::guard("heal", || Ok(heal_shells(&solid, size, keep))).ok().flatten().unwrap_or(solid)
}
fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>]) -> Option<Solid> {
let tol = (size * 1e-7).max(1e-9);
let mut shells = Vec::new();
let mut changed = false;
@@ -428,9 +434,9 @@ pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> Solid {
shells.push(cur);
}
if !changed {
return solid;
return None;
}
Solid::try_new(shells).unwrap_or(solid)
Solid::try_new(shells).ok()
}
#[cfg(test)]
+2 -1
View File
@@ -150,7 +150,8 @@ fn a_drill_point_round_trips_through_iges() {
let plate = box_solid(Vec3::new(-20.0, -15.0, 0.0), Vec3::new(20.0, 15.0, 10.0)).unwrap();
let profile =
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 2.0), Vec2::new(4.0, 5.0), Vec2::new(4.0, 11.0), Vec2::new(0.0, 11.0)]), holes: vec![] };
let hole = crate::revolve(&Plane::XZ, &[profile], Vec2::ZERO, Vec2::new(0.0, 1.0), std::f64::consts::TAU).unwrap().pop().unwrap();
let plane = Plane { y: Vec3::Z, ..Plane::XZ }; // Positive profile heights are world +Z.
let hole = crate::revolve(&plane, &[profile], Vec2::ZERO, Vec2::new(0.0, 1.0), std::f64::consts::TAU).unwrap().pop().unwrap();
let drilled = boolean(&plate, &hole, BoolOp::Cut).unwrap().unwrap();
let v = 12000.0 - std::f64::consts::PI * 16.0 * (5.0 + 1.0);
assert!(rel(measure(&drilled).unwrap().volume, v) < 1e-3);
+1 -1
View File
@@ -61,7 +61,7 @@ pub use lip::raise_band;
pub use measure::{BodyMeasure, measure};
pub use meshbody::{MAX_TRIANGLES, mesh_body};
pub use offset::with_same_surface;
pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix};
pub use polybool::{faceted_boolean, planar_boolean};
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
+41 -11
View File
@@ -15,6 +15,12 @@ pub enum BoolOp {
Intersect,
}
/// Whether a union can sew two matching planar faces without intersecting them.
/// Callers can retain the exact touching tool instead of extending it into material.
pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool {
a.solid.boundaries().len() == 1 && b.solid.boundaries().len() == 1 && crate::coplanar::glue(a, b).is_some_and(|r| r.is_ok())
}
/// Shifts applied to both operands on retries. The boolean classifies some faces by casting a
/// ray whose direction is derived from point coordinates; moving both solids (and the result
/// back) leaves the geometry unchanged but changes those rays.
@@ -46,7 +52,10 @@ fn mismatch(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, result: &solve
let hi = ba.max.min(bb.max);
let all = ba.union(&bb);
let mut boxes = vec![(all, 300usize)];
if lo.x < hi.x && lo.y < hi.y && lo.z < hi.z {
// A touching seam can overlap by round-off only. Samples there sit on the
// operands' boundaries, where ray parity cannot classify membership reliably.
let gap = all.diagonal().max(1.0) * 1e-9;
if hi.x - lo.x > gap && hi.y - lo.y > gap && hi.z - lo.z > gap {
boxes.push((solvecraft_geom::Aabb3 { min: lo, max: hi }, 900));
}
let (ia_idx, ib_idx, ir_idx) = (a.inside_index(), b.inside_index(), result.inside_index());
@@ -180,11 +189,28 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
r
}
/// Smallest side of the box where the bodies' vertex boxes overlap (infinite when they don't).
fn meeting_size(a: &Body, b: &Body) -> f64 {
let bbox = |x: &Body| {
let mut bb = solvecraft_geom::Aabb3::EMPTY;
for v in x.solid.vertex_iter() {
bb.add(from_p3(v.point()));
}
bb
};
let (ba, bb) = (bbox(a), bbox(b));
let s = solvecraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }.size();
if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
}
fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
b.require_brep("a boolean")?;
let size = a.size().max(b.size());
let tol_m = size * 5e-4;
// The checks mesh both bodies; where they meet in a region much thinner than they are (a
// thin part trimmed by a long one), a step from their size would facet the small faces
// there coarsely enough to fail the membership test on a correct result.
let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5);
let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok());
let (va, vb) = match &meshes {
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
@@ -287,7 +313,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
empty_votes += 1;
last = "empty result".into();
}
Ok(s) => match Body::new(crate::heal::heal_keep(s, size, &keep)) {
Ok(s) => match guard("boolean result", || Body::new(crate::heal::heal_keep(s, size, &keep))) {
Ok(body) => {
let (ok, v) = check(&body);
if ok {
@@ -489,6 +515,15 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle:
/// Split a body with a plane: the parts on the positive and the negative side of the plane
/// (only the non-empty ones).
pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec<Body>> {
let mut out = Vec::new();
for positive in [true, false] {
out.extend(half_space(body, plane, positive)?);
}
Ok(out)
}
/// The part of a body on one side of a plane (`None` when nothing is there).
pub(crate) fn half_space(body: &Body, plane: &solvecraft_geom::Plane, positive: bool) -> Result<Option<Body>> {
body.require_brep("split")?;
let size = body.size();
let mut bb = solvecraft_geom::Aabb3::EMPTY;
@@ -506,12 +541,7 @@ pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec
]),
holes: vec![],
};
let mut out = Vec::new();
for (lo, hi) in [(0.0, reach), (-reach, 0.0)] {
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
if let Some(part) = boolean(body, &half, BoolOp::Intersect)? {
out.push(part);
}
}
Ok(out)
let (lo, hi) = if positive { (0.0, reach) } else { (-reach, 0.0) };
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
boolean(body, &half, BoolOp::Intersect)
}
+106
View File
@@ -306,12 +306,118 @@ pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
b.require_brep("a boolean")?;
let (Some(pa), Some(pb)) = (planar_polys(a), planar_polys(b)) else {
if let Some(r) = sliced_planar_boolean(a, b, op) {
return r;
}
return Err(KernelError::Failed("not supported yet: coincident faces on curved bodies".into()));
};
let size = a.size().max(b.size());
polys_boolean(pa, pb, size, op, size * 1e-9)
}
/// [`planar_boolean`] of a body with curved faces and an all-planar one, when a slab between
/// two parallel planes holds `b` and none of `a`'s curved faces (a pocket cut beside a round
/// hole): `a` is split across the slab, the middle piece takes the exact boolean, and the
/// pieces outside are joined back on. `None` when no such slab exists.
fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
let pb = planar_polys(b)?;
let size = a.size().max(b.size());
let tol = (a.size() * 1e-3).max(1e-3);
let infos = a.faces(tol).ok()?;
let mesh = a.tessellate(tol).ok()?;
let mut curved: HashMap<u32, solvecraft_geom::Aabb3> = HashMap::new();
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
if infos.get(*fi as usize)?.plane_normal.is_some() {
continue;
}
let bx = curved.entry(*fi).or_insert(solvecraft_geom::Aabb3::EMPTY);
for p in mesh.tri(t)? {
bx.add(p);
}
}
let mut tool = solvecraft_geom::Aabb3::EMPTY;
for p in pb.iter().flat_map(|p| &p.v) {
tool.add(*p);
}
let at = |v: Vec3, k: usize| [v.x, v.y, v.z].get(k).copied().unwrap_or(0.0);
let axes = [Vec3::X, Vec3::Y, Vec3::Z];
// Planar faces of `a` square to an axis: a cut must not land in one of their planes.
let flat: Vec<(Vec3, Vec3)> = infos.iter().filter_map(|f| Some((f.plane_normal?, f.centroid))).collect();
let gap = size * 1e-4;
let place = |k: usize, near: f64, far: f64| -> Option<f64> {
let axis = *axes.get(k)?;
[0.5, 0.37, 0.63, 0.25, 0.75]
.iter()
.map(|t| near + (far - near) * t)
.find(|x| !flat.iter().any(|(n, c)| n.dot(axis).abs() > 1.0 - 1e-9 && (at(*c, k) - x).abs() < gap))
};
// An axis along which every curved face lies clear of the tool: the cuts either side.
let (k, lo, hi) = (0..3).find_map(|k| {
let (t0, t1) = (at(tool.min, k), at(tool.max, k));
let (mut below, mut above) = (None::<f64>, None::<f64>);
for bx in curved.values() {
let (c0, c1) = (at(bx.min, k), at(bx.max, k));
if c1 < t0 - gap {
below = Some(below.map_or(c1, |x| x.max(c1)));
} else if c0 > t1 + gap {
above = Some(above.map_or(c0, |x| x.min(c0)));
} else {
return None;
}
}
let lo = match below {
Some(c) => Some(place(k, t0, c)?),
None => None,
};
let hi = match above {
Some(c) => Some(place(k, t1, c)?),
None => None,
};
Some((k, lo, hi))
})?;
let (u, w) = match k {
0 => (Vec3::Y, Vec3::Z),
1 => (Vec3::Z, Vec3::X),
_ => (Vec3::X, Vec3::Y),
};
let axis = *axes.get(k)?;
let run = || -> Result<Option<Body>> {
let fail = |m: &str| KernelError::Failed(format!("planar boolean beside curved faces: {m}"));
let mut middle = a.clone();
let mut outside = Vec::new();
for (cut, keep_positive) in [(lo, true), (hi, false)] {
let Some(x) = cut else { continue };
let plane = solvecraft_geom::Plane::new(axis * x, u, w).ok_or_else(|| fail("plane"))?;
outside.extend(crate::ops::half_space(&middle, &plane, !keep_positive)?);
middle = crate::ops::half_space(&middle, &plane, keep_positive)?.ok_or_else(|| fail("nothing beside the tool"))?;
}
let pm = planar_polys(&middle).ok_or_else(|| fail("the middle piece is not planar"))?;
let r = polys_boolean(pm, pb, size, op, size * 1e-9)?;
if op == BoolOp::Intersect {
return Ok(r);
}
// The rebuild leaves the cut faces' edges split where other polygons were cut; healed,
// they match the pieces outside edge for edge.
let mut pieces: Vec<Body> = match r {
Some(m) => vec![Body::new(crate::heal::heal_keep(m.deep_copy(), size, a.split_keep()))?],
None => Vec::new(),
};
// Each piece outside meets the middle along its cut face (glued one shell to another).
if pieces.iter().chain(&outside).any(|x| x.solid.boundaries().len() != 1) {
return Err(fail("a piece in several parts"));
}
for o in outside {
let joined = pieces.iter().enumerate().find_map(|(i, m)| Some((i, crate::coplanar::glue(m, &o)?.ok()?)));
let (i, j) = joined.ok_or_else(|| fail("a piece does not join back"))?;
if let Some(slot) = pieces.get_mut(i) {
*slot = j;
}
}
crate::ops::join_pieces(pieces)
};
Some(run())
}
fn polys_boolean(pa: Vec<Poly>, pb: Vec<Poly>, size: f64, op: BoolOp, eps: f64) -> Result<Option<Body>> {
let polys = csg(pa, pb, op, eps)?;
if polys.is_empty() {
+128 -3
View File
@@ -77,6 +77,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
let mut hi = f64::NEG_INFINITY;
let mut tops: Vec<&mt::Face> = Vec::new();
let mut top_h = Vec::new();
let mut bottom_h = Vec::new();
for f in b.solid.face_iter() {
let surf = f.oriented_surface();
if let mt::Surface::Plane(p) = &surf {
@@ -90,6 +91,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
continue;
}
if n.dot(d) < -1.0 + 1e-9 {
bottom_h.push(h);
lo = lo.min(h);
continue;
}
@@ -110,7 +112,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
}
}
// One height for every top (and bottom) cap.
if top_h.iter().any(|h| (h - hi).abs() > tol) || !(lo.is_finite() && hi > lo + tol) {
if top_h.iter().any(|h| (h - hi).abs() > tol) || bottom_h.iter().any(|h| (h - lo).abs() > tol) || !(lo.is_finite() && hi > lo + tol) {
return None;
}
let mut regions = Vec::new();
@@ -382,6 +384,129 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
}
/// The boolean done on prism sections, or `None` when it doesn't apply.
/// The pieces of a segment between the given points (those lying on it, inside it).
fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
if !matches!(seg, Seg2::Line { .. } | Seg2::Arc { .. }) {
return None;
}
let mut ts: Vec<f64> = pts
.iter()
.filter_map(|p| {
let t = seg.closest_param(*p);
(t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t)
})
.collect();
ts.sort_by(f64::total_cmp);
ts.dedup_by(|x, y| (*x - *y).abs() < 1e-9);
let mut out = Vec::new();
let mut t0 = 0.0;
for t1 in ts.into_iter().chain(std::iter::once(1.0)) {
out.push(match *seg {
Seg2::Line { .. } => Seg2::Line { a: seg.point_at(t0), b: seg.point_at(t1) },
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius, start: start + sweep * t0, sweep: sweep * (t1 - t0) },
_ => return None,
});
t0 = t1;
}
Some(out)
}
/// The union of two prisms along `d` stacked end to end (or overlapping) with different
/// sections, sewn from its faces: each slab between the prisms' end levels has walls over its
/// section's outline; each level has a face up over the section below less the one above, and
/// a face down over the one above less the one below. Every outline is split where any other
/// meets it, so the faces share their edges. `None` when a slab is empty or a curve isn't a
/// line or an arc.
fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option<Result<Body>> {
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
let mut levels = vec![pa.lo, pa.hi, pb.lo, pb.hi];
levels.sort_by(f64::total_cmp);
levels.dedup_by(|x, y| (*x - *y).abs() < tol);
let covers = |p: &Prism, z0: f64, z1: f64| p.lo <= z0 + tol && p.hi >= z1 - tol;
let mut slabs: Vec<Vec<Region2>> = Vec::new();
for w in levels.windows(2) {
let (z0, z1) = (*w.first()?, *w.get(1)?);
let s = match (covers(pa, z0, z1), covers(pb, z0, z1)) {
(true, true) => boolean2d(&pa.regions, &pb.regions, BoolOp::Union, tol)?,
(true, false) => pa.regions.clone(),
(false, true) => pb.regions.clone(),
(false, false) => return None,
};
slabs.push(oriented(&s));
}
// Faces at each level: up where the slab below sticks out, down where the one above does.
let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> {
match (x.is_empty(), y.is_empty()) {
(true, _) => Some(Vec::new()),
(false, true) => Some(x.to_vec()),
(false, false) => boolean2d(x, y, BoolOp::Cut, tol).map(|r| oriented(&r)),
}
};
let mut caps: Vec<(f64, bool, Vec<Region2>)> = Vec::new();
for (k, z) in levels.iter().enumerate() {
let below: &[Region2] = if k == 0 { &[] } else { slabs.get(k - 1)? };
let above: &[Region2] = slabs.get(k).map(Vec::as_slice).unwrap_or(&[]);
caps.push((*z, true, diff(below, above)?));
caps.push((*z, false, diff(above, below)?));
}
// Every segment end, everywhere: outlines split there.
let loops_of =
|rs: &[Region2]| -> Vec<Loop2> { rs.iter().flat_map(|r| std::iter::once(r.outer.clone()).chain(r.holes.iter().cloned())).collect() };
let mut pts: Vec<Vec2> = Vec::new();
for lp in slabs.iter().flat_map(|s| loops_of(s)).chain(caps.iter().flat_map(|c| loops_of(&c.2))) {
for sg in &lp.segs {
pts.push(sg.start());
pts.push(sg.end());
}
}
let at = |u: Vec2, z: f64| plane.to_world(u) + d * z;
let edge = |sg: &Seg2, z: f64| -> EdgeSpec {
match sg {
Seg2::Arc { .. } => EdgeSpec::Arc { a: at(sg.start(), z), b: at(sg.end(), z), mid: at(sg.point_at(0.5), z) },
_ => EdgeSpec::Line { a: at(sg.start(), z), b: at(sg.end(), z) },
}
};
let mut faces: Vec<FaceSpec> = Vec::new();
for (i, s) in slabs.iter().enumerate() {
let (z0, z1) = (*levels.get(i)?, *levels.get(i + 1)?);
for lp in loops_of(s) {
for sg0 in &lp.segs {
for sg in split_seg(sg0, &pts, tol)? {
let (a, b) = (sg.start(), sg.end());
let lp3 = vec![
edge(&sg, z0),
EdgeSpec::Line { a: at(b, z0), b: at(b, z1) },
edge(&sg.reversed(), z1),
EdgeSpec::Line { a: at(a, z1), b: at(a, z0) },
];
let tangent = plane.dir_to_world(sg.derivative(0.5));
let outward = tangent.cross(d).normalized()?;
let surface = match sg {
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
_ => SurfSpec::Plane,
};
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
}
}
}
}
for (z, up, rs) in &caps {
for r in rs {
let mut loops = Vec::new();
for lp in std::iter::once(&r.outer).chain(&r.holes) {
let mut segs: Vec<Seg2> = Vec::new();
for sg in &lp.segs {
segs.extend(split_seg(sg, &pts, tol)?);
}
let segs: Vec<Seg2> = if *up { segs } else { segs.iter().rev().map(Seg2::reversed).collect() };
loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::<Vec<_>>());
}
faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None });
}
}
Some(crate::sew::sew(&faces))
}
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
@@ -423,10 +548,10 @@ pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
return Some(Ok(None));
}
let (lo, hi) = if stacked {
// Only when the sections are the same (the union adds nothing to either).
// Different sections (a boss hanging over a plate's edge): sewn slab by slab.
let (u, x, y) = (area(&regions), area(&pa.regions), area(&pb.regions));
if (u - x).abs() > tol * (1.0 + u) || (u - y).abs() > tol * (1.0 + u) {
return None;
return sew_stack(&plane, d, &pa, &pb, tol).map(|r| r.map(Some));
}
(pa.lo.min(pb.lo), pa.hi.max(pb.hi))
} else {
+1 -1
View File
@@ -1208,7 +1208,7 @@ mod tests {
let m0 = measure(&c).unwrap();
let side = c.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_none()).unwrap();
// A horizontal line seen from the front (XZ sketch) at z = 5, swept along y.
let xz = Plane::XZ;
let xz = Plane { y: Vec3::Z, ..Plane::XZ };
let tool = SplitTool::Curves { plane: xz, curves: vec![vec![Seg2::Line { a: Vec2::new(-20.0, 5.0), b: Vec2::new(20.0, 5.0) }]] };
let s = split_faces(&c, &[side], &tool).unwrap();
let m = measure(&s).unwrap();
+75 -5
View File
@@ -40,8 +40,8 @@ fn extrude_on_other_planes_and_offsets() {
let b = extrude(&Plane::XZ, &[rect(10.0, 5.0)], -2.0, 3.0).unwrap().pop().unwrap();
let m = measure(&b).unwrap();
assert!(rel(m.volume, 250.0) < 1e-9);
// XZ normal is -Y: the body spans y in [-3, 2].
assert!((m.bbox.min.y + 3.0).abs() < 1e-6 && (m.bbox.max.y - 2.0).abs() < 1e-6, "{:?}", m.bbox);
// XZ normal is +Y: the body spans y in [-2, 3].
assert!((m.bbox.min.y + 2.0).abs() < 1e-6 && (m.bbox.max.y - 3.0).abs() < 1e-6, "{:?}", m.bbox);
}
#[test]
@@ -52,6 +52,39 @@ fn region_with_hole() {
assert!(rel(m.volume, (1200.0 - PI * 25.0) * 10.0) < 5e-4, "{}", m.volume);
}
#[test]
fn through_cut_preserves_a_larger_blind_pocket() {
let plate = box_solid(Vec3::new(-30.0, -28.0, 0.0), Vec3::new(60.0, 22.0, 8.0)).unwrap();
let pocket = cylinder(Vec3::ZERO, Vec3::Z, 11.0, 6.0).unwrap();
let through = cylinder(Vec3::new(0.0, 0.0, -1.0), Vec3::Z, 9.5, 10.0).unwrap();
let expected = 90.0 * 50.0 * 8.0 - PI * 11.0_f64.powi(2) * 6.0 - PI * 9.5_f64.powi(2) * 2.0;
for reverse in [false, true] {
let (first, second) = if reverse { (&through, &pocket) } else { (&pocket, &through) };
let cut = boolean(&plate, first, BoolOp::Cut).unwrap().unwrap();
let result = boolean(&cut, second, BoolOp::Cut).unwrap().unwrap();
let m = measure(&result).unwrap();
assert!(rel(m.volume, expected) < 5e-4, "reverse={reverse}: {} vs {expected}", m.volume);
assert_eq!(m.merged.faces, 9);
assert!(!result.tessellate(0.01).unwrap().contains(Vec3::new(10.0, 0.0, 3.0)));
}
}
#[test]
fn join_a_cylinder_on_its_chamfered_top() {
for seam in [0.0, crate::build::SEAM_ANGLE] {
let region = Region2 { outer: Loop2::circle_from(Vec2::ZERO, 10.0, seam), holes: vec![] };
let base = extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().pop().unwrap();
let base = chamfer(&base, &[Vec3::new(10.0, 0.0, 20.0)], 2.0).unwrap();
let region = Region2 { outer: Loop2::circle_from(Vec2::new(0.0, -3.774758283725534e-15), 8.0, seam), holes: vec![] };
let plane = Plane { origin: Vec3::new(0.0, 0.0, 20.000000000000007), ..Plane::XY };
let tool = extrude(&plane, &[region], 0.0, 5.0).unwrap().pop().unwrap();
let expected = measure(&base).unwrap().volume + PI * 64.0 * 5.0;
let joined = boolean(&base, &tool, BoolOp::Union).unwrap().unwrap();
assert!(rel(measure(&joined).unwrap().volume, expected) < 5e-4);
assert_eq!(joined.lumps().unwrap().len(), 1);
}
}
#[test]
fn box_fillet_cut_volume() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
@@ -225,6 +258,38 @@ fn loft_and_sweep() {
assert!(rel(measure(&t).unwrap().volume, expect) < 1e-3, "{} vs {expect}", measure(&t).unwrap().volume);
}
#[test]
fn sweep_round_a_sharp_corner_is_mitred() {
// A 2 × 2 square centred on an L of two 10 mm lines: lines alone would slide the profile
// along the second leg within its own plane (an empty, inside-out body). Mitred, the
// volume is the area times the path length.
let sq =
Region2 { outer: Loop2::polygon(&[Vec2::new(-1.0, -1.0), Vec2::new(1.0, -1.0), Vec2::new(1.0, 1.0), Vec2::new(-1.0, 1.0)]), holes: vec![] };
let path = [
PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 10.0) },
PathSeg::Line { a: Vec3::new(0.0, 0.0, 10.0), b: Vec3::new(10.0, 0.0, 10.0) },
];
let b = sweep(&Plane::XY, &sq, &path).unwrap();
let m = measure(&b).unwrap();
assert!(rel(m.volume, 80.0) < 1e-9, "{}", m.volume);
}
#[test]
fn moving_a_body_with_an_intersection_curve_edge_ends() {
// A cylinder cut by a tilted box keeps an elliptical intersection curve as an edge. Moving
// such a body scales the curve's tolerance by the matrix norm, whose root refinement cycled
// forever for about one translation in eight (this one included): the app hung.
let cyl = cylinder(Vec3::ZERO, Vec3::Z, 5.0, 40.0).unwrap();
let block = box_solid(Vec3::new(-20.0, -20.0, 20.0), Vec3::new(20.0, 20.0, 60.0)).unwrap();
let block = crate::ops::transform(&block, Vec3::ZERO, Vec3::new(0.0, 0.0, 20.0), Vec3::X, 0.4).unwrap();
let cut = boolean(&cyl, &block, BoolOp::Cut).unwrap().unwrap();
assert!(cut.solid.edge_iter().any(|e| matches!(e.curve(), truck_modeling::Curve::IntersectionCurve(_))));
let t = Vec3::new(0.6911, -0.3337, -0.6127) * 2.334;
let moved = crate::ops::transform(&cut, t, Vec3::ZERO, Vec3::Z, 0.0).unwrap();
let (v0, v1) = (measure(&cut).unwrap().volume, measure(&moved).unwrap().volume);
assert!(rel(v0, v1) < 1e-6, "{v0} vs {v1}");
}
#[test]
fn planar_booleans_with_coincident_faces() {
// L-bracket: a wall flush with three sides of its base.
@@ -1344,7 +1409,8 @@ fn boolean_identity_survey() {
fn partial_revolve_on_the_axis() {
let sq =
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0), Vec2::new(20.0, 7.5), Vec2::new(0.0, 7.5)]), holes: vec![] };
let pie = revolve(&Plane::XZ, &[sq], Vec2::ZERO, Vec2::Y, std::f64::consts::FRAC_PI_2).unwrap().pop().unwrap();
let plane = Plane { y: Vec3::Z, ..Plane::XZ }; // This fixture's rectangle is above z = 0.
let pie = revolve(&plane, &[sq], Vec2::ZERO, Vec2::Y, std::f64::consts::FRAC_PI_2).unwrap().pop().unwrap();
assert!(pie.validity().is_empty(), "{:?}", pie.validity());
let v = measure(&pie).unwrap().volume;
assert!((v - PI * 400.0 / 4.0 * 7.5).abs() < 1e-3 * v, "{v}");
@@ -1361,8 +1427,12 @@ fn partial_revolve_on_the_axis() {
/// exactly where the other body cut them).
#[test]
fn boolean_identity_on_tangent_primitives() {
for seed in [1, 32, 169] {
let c = crate::fuzz::boolean_case(seed);
// Independent seeds, one thread each.
let cases: Vec<_> = std::thread::scope(|scope| {
let workers = [1, 32, 169].map(|seed| scope.spawn(move || (seed, crate::fuzz::boolean_case(seed))));
workers.into_iter().map(|w| w.join().unwrap()).collect()
});
for (seed, c) in cases {
assert_eq!(c.outcome, "ok", "seed {seed}: {} {:?} {:?}", c.detail, c.a, c.b);
}
}
+46
View File
@@ -213,6 +213,52 @@ fn batch_stops_at_first_error_and_rolls_back() {
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 2);
}
/// Regression: with a full undo history a failed batch reported `undone: 0` and left its
/// sketches behind (an empty "Sketch1", two sketches named "TapHole").
#[test]
fn batch_rolls_back_with_a_full_undo_history() {
let mut s = server();
let fill: Vec<Value> =
(0..230).map(|i| json!({"command": "parameters.add", "params": {"name": format!("p{i}"), "expression": "1 mm"}})).collect();
tool(&mut s, "batch", json!({"commands": fill}));
let undo = tool(&mut s, "inspect_design", json!({"measure": false}))["undo"].clone();
for _ in 0..2 {
let r = raw_tool(
&mut s,
"batch",
json!({"commands": [
{"command": "sketch.create", "params": {"plane": "XY", "name": "TapHole"}},
{"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 2}},
{"command": "sketch.dimension", "params": {"entities": ["nope"], "value": 3}},
]}),
);
let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(body["failed"]["index"], 2, "{body}");
assert_eq!(body["undone"], 2, "{body}");
}
let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
assert_eq!(d["sketches"].as_array().map(Vec::len), Some(0), "{d}");
assert!(d["active_sketch"].is_null(), "{d}");
assert_eq!(d["undo"], undo);
}
#[test]
fn helper_sketches_can_be_hidden() {
let mut s = server();
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 40, "height": 2}}));
let sk = tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}))["sketch"].clone();
tool(&mut s, "execute", json!({"command": "sketch.line", "params": {"points": [[0, 0], [40, 0]], "construction": true}}));
tool(&mut s, "execute", json!({"command": "sketch.finish"}));
let visible = |s: &mut Server| tool(s, "inspect_design", json!({"measure": false}))["sketches"][0]["visible"].clone();
assert_eq!(visible(&mut s), true);
tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": [sk], "visible": false}}));
assert_eq!(visible(&mut s), false);
let r = raw_tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": ["Nope"], "visible": false}}));
assert_eq!(r["result"]["isError"], true);
tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"folder": "sketches", "visible": true}}));
assert_eq!(visible(&mut s), true);
}
#[test]
fn unknown_tools_and_bad_arguments_are_clean_errors() {
let mut s = server();
+55 -12
View File
@@ -129,7 +129,8 @@ pub fn tool_definitions() -> Vec<Value> {
"set_parameter",
"Set a parameter",
"Create or change a user parameter and re-evaluate the timeline. `value` is a number (mm) or an expression \
(\"40 mm\", \"width * 2\", \"30 deg\"). Returns the new value, the features recomputed and any features that now fail.",
(\"40 mm\", \"width * 2\", \"30 deg\"). Returns the new value, the features recomputed (`recomputed`), the features whose earlier \
result for the same inputs was reused instead (`restored`, e.g. after setting a value back) and any features that now fail.",
obj(
json!({
"name": string("Parameter name"),
@@ -360,7 +361,56 @@ fn undo_depth(b: &mut dyn Backend) -> Option<u64> {
b.call("document.inspect", json!({})).ok().and_then(|d| d["undo"].as_u64())
}
/// `batch`: run steps in order, stop at the first failure and (by default) undo what ran.
/// How a failed batch goes back to where it started.
enum Undo {
/// An engine checkpoint: the design, active sketch and undo history exactly as they were.
Checkpoint(u64),
/// An app without checkpoints: undo as many steps as the batch added (wrong once the undo
/// history is full, as its depth no longer grows).
Depth(u64),
None,
}
impl Undo {
fn begin(b: &mut dyn Backend) -> Undo {
match exec(b, "edit.checkpoint", json!({})).ok().and_then(|v| v["checkpoint"].as_u64()) {
Some(id) => Undo::Checkpoint(id),
None => undo_depth(b).map_or(Undo::None, Undo::Depth),
}
}
/// Go back; the number of steps undone.
fn rollback(self, b: &mut dyn Backend, steps: usize) -> u64 {
match self {
Undo::Checkpoint(id) => match exec(b, "edit.restore_checkpoint", json!({"checkpoint": id})) {
Ok(_) => steps as u64,
Err(_) => 0,
},
Undo::Depth(before) => {
let after = undo_depth(b).unwrap_or(before);
let mut undone = 0u64;
for _ in before..after {
if exec(b, "edit.undo", json!({})).is_err() {
break;
}
undone += 1;
}
undone
}
Undo::None => 0,
}
}
/// The batch went through: forget the checkpoint.
fn finish(self, b: &mut dyn Backend) {
if let Undo::Checkpoint(id) = self {
let _ = exec(b, "edit.restore_checkpoint", json!({"checkpoint": id, "forget": true}));
}
}
}
/// `batch`: run steps in order, stop at the first failure and (by default) go back to the
/// design as it was before the batch.
fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
let Some(steps) = a.get("commands").and_then(Value::as_array) else {
return text_result(&json!("`commands` must be an array of {command, params}"), true);
@@ -369,7 +419,7 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
return text_result(&json!(format!("batch too long ({} steps, at most {MAX_BATCH})", steps.len())), true);
}
let rollback = a.get("rollback").and_then(Value::as_bool).unwrap_or(true);
let before = if rollback { undo_depth(b) } else { None };
let start = if rollback { Undo::begin(b) } else { Undo::None };
let mut results = Vec::with_capacity(steps.len());
for (i, step) in steps.iter().enumerate() {
let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str);
@@ -380,15 +430,7 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
match r {
Ok(v) => results.push(v),
Err(e) => {
let mut undone = 0u64;
if let (Some(before), Some(after)) = (before, rollback.then(|| undo_depth(b)).flatten()) {
for _ in before..after {
if exec(b, "edit.undo", json!({})).is_err() {
break;
}
undone += 1;
}
}
let undone = start.rollback(b, i);
return text_result(
&json!({
"ok": false,
@@ -403,6 +445,7 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
}
}
}
start.finish(b);
text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false)
}
+23
View File
@@ -40,6 +40,9 @@ pub struct Scene {
pub background: Option<(Rgb, Rgb)>,
/// Scene radius for clipping planes.
pub radius: f64,
/// A section cut: (normal, d); what lies where normal·p > d is cut away, and the inside
/// shows as a hatched cap.
pub clip: Option<(Vec3, f64)>,
}
/// RGBA pixels plus depth.
@@ -109,6 +112,9 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
for (ti, t) in m.triangles.iter().enumerate() {
let face = m.tri_face.get(ti).copied();
let base = sm.face_colors.iter().find(|(f, _)| Some(*f) == face).map_or(body, |(_, c)| c.f());
let wp = t.map(|k| m.positions.get(k as usize).copied().unwrap_or_default());
// Seen from inside (only where a section cuts the body open).
let inside = (wp[1] - wp[0]).cross(wp[2] - wp[0]).dot(view_dir) < 0.0;
let (Some(Some(a)), Some(Some(b)), Some(Some(cc))) = (pts.get(t[0] as usize), pts.get(t[1] as usize), pts.get(t[2] as usize)) else {
continue;
};
@@ -134,6 +140,11 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 {
continue;
}
if let Some((cn, cd)) = scene.clip
&& cn.dot(wp[0] * w0 + wp[1] * w1 + wp[2] * w2) > cd
{
continue;
}
let z = (w0 * a[2] + w1 * b[2] + w2 * cc[2]) as f32;
let i = y * sw + x;
if c.depth.get(i).is_some_and(|d| z < *d) {
@@ -141,6 +152,12 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
*d = z;
}
let n = (ns[0] * w0 + ns[1] * w1 + ns[2] * w2).normalized().unwrap_or(Vec3::Z);
if scene.clip.is_some() && inside {
// The cut: the body's colour, hatched at 45 degrees.
let k = if ((x + y) / SS) % 9 < 2 { 0.5 } else { 0.8 };
c.put(x, y, base.map(|v| v * k), 1.0);
continue;
}
let mut col = shade(n, view_dir, base);
let ndv = n.dot(view_dir).abs();
if curved && ndv < 0.14 {
@@ -165,6 +182,11 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
let steps = (len.ceil() as usize).clamp(1, 20_000);
for k in 0..=steps {
let t = k as f64 / steps as f64;
if let Some((cn, cd)) = scene.clip
&& cn.dot(*p + (*q - *p) * t) > cd + 1e-6
{
continue;
}
let (x, y, z) = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t);
let r = half.ceil() as i64;
for dy in -r..=r {
@@ -268,6 +290,7 @@ mod tests {
lines: vec![],
background: None,
radius: 20.0,
clip: None,
};
let mut cam = Camera { distance: 50.0, ..Default::default() };
cam.set_view(StandardView::Front);
+2
View File
@@ -13,6 +13,7 @@ mod dims;
mod linalg;
mod link;
mod model;
mod offset;
mod profiles;
mod shape;
mod solver;
@@ -25,6 +26,7 @@ pub use curves::{
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView};
pub use offset::OffsetChain;
pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions};
pub use shape::{Shape, intersections};
pub use solver::{Hold, SolveReport, SolveStatus, solve, solve_holding};
+76 -2
View File
@@ -104,6 +104,9 @@ pub struct Link {
#[serde(default)]
pub kind: LinkKind,
pub source: LinkSource,
/// Persistent model face identity, resolved by the document before geometry refresh.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub face_name: Option<String>,
/// The reference could not be found on the last recompute; the geometry is the last good one.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub lost: bool,
@@ -169,7 +172,7 @@ struct Layout {
curves: Vec<LCurve>,
}
#[derive(Clone, Copy, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq)]
enum LCurve {
Line(usize, usize),
Circle(usize, f64),
@@ -368,7 +371,7 @@ impl Sketch {
return Err(SketchError::TooLarge);
}
let id = self.fresh("j");
self.links.push(Link { id: id.clone(), kind, source, lost: false });
self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false });
self.build_link(&id, &lay)?;
Ok(id)
}
@@ -579,6 +582,77 @@ impl Sketch {
return Ok(false);
}
}
// A resized face can enumerate the same boundary in another order. Match the
// shared-point graph in normalized sketch space before discarding its identity.
if let Some(cur) = &cur
&& points.len() == lay.pts.len()
&& cur.len() == lay.curves.len()
&& points.len() <= 512
{
let old: Vec<Vec2> = points.iter().filter_map(|i| self.point(*i)).collect();
let normalized = |ps: &[Vec2]| -> Vec<Vec2> {
let lo = ps.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y)));
let hi = ps.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y)));
ps.iter().map(|p| Vec2::new((p.x - lo.x) / (hi.x - lo.x).max(1e-9), (p.y - lo.y) / (hi.y - lo.y).max(1e-9))).collect()
};
let (a, b) = (normalized(&old), normalized(&lay.pts));
let mut mapping = Vec::new();
for p in &a {
let mut candidates: Vec<(usize, f64)> = b.iter().enumerate().map(|(i, q)| (i, p.dist(*q))).collect();
candidates.sort_by(|x, y| x.1.total_cmp(&y.1));
if let Some((i, d)) = candidates.first()
&& *d < 0.1
&& candidates.get(1).is_none_or(|(_, second)| *d < *second * 0.5)
&& !mapping.contains(i)
{
mapping.push(*i);
} else {
break;
}
}
let mut used = Vec::new();
let mut curve_map = Vec::new();
if mapping.len() == points.len() {
for c in cur {
let mapped: Option<Vec<usize>> = curve_points(c).iter().map(|i| mapping.get(*i).copied()).collect();
let found = lay
.curves
.iter()
.enumerate()
.find(|(i, fresh)| {
if used.contains(i) || !same_shape(c, fresh) {
return false;
}
let fresh_pts = curve_points(fresh);
mapped.as_ref().is_some_and(|p| {
*p == fresh_pts || matches!(c, LCurve::Line(..)) && p.iter().rev().copied().collect::<Vec<_>>() == fresh_pts
})
})
.map(|(i, _)| i);
if let Some(i) = found {
used.push(i);
curve_map.push(i);
} else {
break;
}
}
}
if curve_map.len() == cur.len() && mapping.len() == points.len() {
for (pi, li) in points.iter().zip(mapping) {
if let (Some(p), Some(q)) = (self.points.get_mut(*pi), lay.pts.get(li)) {
p.pos = *q;
}
}
for (ci, li) in curves.iter().zip(curve_map) {
if let (Some(Curve { kind: CurveKind::Circle { r, .. }, .. }), Some(LCurve::Circle(_, value))) =
(self.curves.get_mut(*ci), lay.curves.get(li))
{
*r = *value;
}
}
return Ok(false);
}
}
// Rebuild: drop the old entities (constraints on them go too) and make new ones.
self.drop_link_entities(id);
self.build_link(id, &lay)?;
+7 -8
View File
@@ -467,6 +467,8 @@ pub struct Sketch {
/// Links to geometry outside the sketch (projections, intersections, includes).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub links: Vec<crate::link::Link>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub offsets: Vec<crate::offset::OffsetChain>,
/// 3D curves (world coordinates) carried by the sketch.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub wires: Vec<crate::wire::Wire>,
@@ -495,6 +497,7 @@ impl Sketch {
curves: Vec::new(),
constraints: Vec::new(),
links: Vec::new(),
offsets: Vec::new(),
wires: Vec::new(),
counters: Default::default(),
view: SketchView::default(),
@@ -695,10 +698,8 @@ impl Sketch {
| Symmetric { l, .. }
| PointLineDistance { l, .. }
| LinearDiameter { l, .. } => need_line(l)?,
ArcLength { c, .. } => {
if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) {
return Err(SketchError::WrongKind(name(c), "arc length needs an arc".into()));
}
ArcLength { c, .. } if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) => {
return Err(SketchError::WrongKind(name(c), "arc length needs an arc".into()));
}
Smooth { a, b } => {
let ends = |i: usize| self.curves.get(i).and_then(|c| c.kind.ends());
@@ -727,10 +728,8 @@ impl Sketch {
return Err(SketchError::WrongKind(name(a), "tangent needs a circle or arc".into()));
}
}
Equal { a, b } => {
if is_line(a) != is_line(b) {
return Err(SketchError::WrongKind(name(b), "equal needs two lines or two circles/arcs".into()));
}
Equal { a, b } if is_line(a) != is_line(b) => {
return Err(SketchError::WrongKind(name(b), "equal needs two lines or two circles/arcs".into()));
}
Coincident { p, q } if p == q => return Err(SketchError::Invalid("a point is always coincident with itself".into())),
_ => {}
+116
View File
@@ -0,0 +1,116 @@
//! Associative offsets keep their output entity ids while their source curves move.
use crate::{CurveKind, Shape, Sketch, SketchError, intersections};
use serde::{Deserialize, Serialize};
use solvecraft_geom::Vec2;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OffsetChain {
pub sources: Vec<(String, bool)>,
pub outputs: Vec<String>,
pub distance: f64,
pub closed: bool,
}
impl Sketch {
pub fn refresh_offsets(&mut self) -> Result<(), SketchError> {
for offset in self.offsets.clone() {
if offset.sources.len() != offset.outputs.len() || offset.sources.len() > 10_000 || !offset.distance.is_finite() {
return Err(SketchError::Invalid("damaged offset chain".into()));
}
let bad = || SketchError::Invalid("an offset source vanished or changed shape".into());
let mut pieces = Vec::new();
for (id, forward) in &offset.sources {
let i = self.curve_index(id).ok_or_else(bad)?;
let kind = &self.curves.get(i).ok_or_else(bad)?.kind;
let piece = match *kind {
CurveKind::Line { a, b } => {
let (a, b) = (self.point(a).ok_or_else(bad)?, self.point(b).ok_or_else(bad)?);
let (a, b) = if *forward { (a, b) } else { (b, a) };
let n = (b - a).normalized().ok_or_else(bad)?.perp() * offset.distance;
(Shape::Line { a: a + n, b: b + n }, a + n, b + n)
}
CurveKind::Arc { c, a, b } => {
let (c, a, b) = (self.point(c).ok_or_else(bad)?, self.point(a).ok_or_else(bad)?, self.point(b).ok_or_else(bad)?);
let r = c.dist(a) - if *forward { offset.distance } else { -offset.distance };
if r <= 1e-9 {
return Err(SketchError::Invalid("the offset collapses an arc".into()));
}
let a = c + (a - c).normalized().ok_or_else(bad)? * r;
let b = c + (b - c).normalized().ok_or_else(bad)? * r;
let (a, b) = if *forward { (a, b) } else { (b, a) };
(Shape::Round { c, r, start: 0.0, sweep: std::f64::consts::TAU }, a, b)
}
CurveKind::Circle { c, r } => {
let c = self.point(c).ok_or_else(bad)?;
let r = r - offset.distance;
if r <= 1e-9 {
return Err(SketchError::Invalid("the offset collapses a circle".into()));
}
let out = offset.outputs.first().and_then(|id| self.curve_index(id)).ok_or_else(bad)?;
if let Some(crate::Curve { kind: CurveKind::Circle { r: target, .. }, .. }) = self.curves.get_mut(out) {
*target = r;
} else {
return Err(bad());
}
let _ = c;
continue;
}
_ => return Err(bad()),
};
pieces.push(piece);
}
if pieces.is_empty() {
continue;
}
let joint = |i: usize, j: usize| -> Result<Vec2, SketchError> {
let (x, y) = (pieces.get(i).ok_or_else(bad)?, pieces.get(j).ok_or_else(bad)?);
if x.2.dist(y.1) < 1e-6 {
return Ok(x.2);
}
let guess = (x.2 + y.1) * 0.5;
intersections(&x.0, &y.0)
.into_iter()
.min_by(|a, b| a.dist(guess).total_cmp(&b.dist(guess)))
.ok_or_else(|| SketchError::Invalid("the offset curves no longer meet".into()))
};
let n = pieces.len();
let mut joints = vec![if offset.closed { joint(n - 1, 0)? } else { pieces.first().ok_or_else(bad)?.1 }];
for i in 0..n.saturating_sub(1) {
joints.push(joint(i, i + 1)?);
}
if !offset.closed {
joints.push(pieces.last().ok_or_else(bad)?.2);
}
for (i, id) in offset.outputs.iter().enumerate() {
let a = *joints.get(i).ok_or_else(bad)?;
let b = *joints.get(if offset.closed { (i + 1) % n } else { i + 1 }).ok_or_else(bad)?;
let shape = &pieces.get(i).ok_or_else(bad)?.0;
if let Shape::Line { a: p, b: q } = shape
&& (b - a).dot(*q - *p) <= 1e-9
{
return Err(SketchError::Invalid("the offset curves would cross".into()));
}
let ci = self.curve_index(id).ok_or_else(bad)?;
let kind = self.curves.get(ci).ok_or_else(bad)?.kind.clone();
let (pa, pb) = match kind {
CurveKind::Line { a, b } => (a, b),
CurveKind::Arc { a, b, .. } => {
if offset.sources.get(i).is_some_and(|x| x.1) {
(a, b)
} else {
(b, a)
}
}
_ => return Err(bad()),
};
for (pi, pos) in [(pa, a), (pb, b)] {
if let Some(p) = self.points.get_mut(pi) {
p.pos = pos;
p.fixed = true;
}
}
}
}
Ok(())
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ pub fn text_geometry(text: &str, at: Vec2, height: f64, angle: f64) -> Result<Ve
if text.chars().count() > MAX_TEXT_CHARS {
return Err(SketchError::TooLarge);
}
if !(height.is_finite() && height > 1e-6 && height < 1e6) || !angle.is_finite() || !at.is_finite() {
if !(height.is_finite() && height > 1e-6 && height < 1e6 && angle.is_finite() && at.is_finite()) {
return Err(SketchError::Invalid("text height must be positive".into()));
}
let face = ttf_parser::Face::parse(FONT, 0).map_err(|e| SketchError::Invalid(format!("font: {e}")))?;
+1 -1
View File
@@ -98,7 +98,7 @@ impl Sketch {
}
let id = if geom.is_empty() {
let id = self.fresh("j");
self.links.push(Link { id: id.clone(), kind, source, lost: false });
self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false });
id
} else {
self.add_link(kind, source, geom)?
+1 -1
View File
@@ -27,7 +27,7 @@ pub fn map(app: &SolveApp) -> Option<AccessMap> {
return Some(m);
}
let st = app.session.world_state();
let meshes: Vec<_> = st.bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(&b.name)).map(|b| b.mesh()).collect();
let meshes: Vec<_> = st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name)).map(|b| b.mesh()).collect();
// Every new map gets a new version (the GPU uploads it once).
let version = VERSION.with(|v| {
v.set(v.get() + 1);
+49 -46
View File
@@ -80,19 +80,19 @@ impl OccMove {
/// Is a sketch drawn in the viewport?
pub fn sketch_visible(app: &SolveApp, id: u64) -> bool {
if app.ui.hidden_sketches.contains(&id) {
if app.session.visibility.hidden_sketches.contains(&id) {
return false;
}
app.ui.shown_sketches.contains(&id) || (app.ui.show_sketches && !solvecraft_engine::view::sketch_consumed(&app.session, id))
app.session.visibility.shown_sketches.contains(&id) || (app.ui.show_sketches && !solvecraft_engine::view::sketch_consumed(&app.session, id))
}
pub fn set_sketch_visible(app: &mut SolveApp, id: u64, on: bool) {
app.ui.hidden_sketches.retain(|x| *x != id);
app.ui.shown_sketches.retain(|x| *x != id);
app.session.visibility.hidden_sketches.retain(|x| *x != id);
app.session.visibility.shown_sketches.retain(|x| *x != id);
if on {
app.ui.shown_sketches.push(id);
app.session.visibility.shown_sketches.push(id);
} else {
app.ui.hidden_sketches.push(id);
app.session.visibility.hidden_sketches.push(id);
}
}
@@ -126,7 +126,7 @@ pub fn group_items(app: &SolveApp, id: u64) -> Vec<Item> {
];
if g.folder == "bodies" {
let bodies = group_bodies(app, id);
let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.ui.hidden_bodies.contains(b));
let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.session.visibility.hidden_bodies.contains(b));
v.push(Item::sep());
v.push(Item::action(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": bodies })));
v.push(Item::action("ui.isolate", "Isolate", "").with(json!({ "bodies": bodies })));
@@ -158,8 +158,8 @@ pub fn folder_items(app: &SolveApp, component: u64, folder: &str) -> Vec<Item> {
/// The menu of the Origin folder.
pub fn origin_items(app: &SolveApp) -> Vec<Item> {
let planes = ["XY", "XZ", "YZ"].iter().all(|p| !app.ui.hidden_origin.iter().any(|h| h == p));
let axes = ["X", "Y", "Z"].iter().all(|p| !app.ui.hidden_origin.iter().any(|h| h == p));
let planes = ["XY", "XZ", "YZ"].iter().all(|p| !app.session.visibility.hidden_origin.iter().any(|h| h == p));
let axes = ["X", "Y", "Z"].iter().all(|p| !app.session.visibility.hidden_origin.iter().any(|h| h == p));
vec![
Item::action("ui.origin", "Show/Hide", "eye").key("V"),
Item::action("ui.originAll", "Show All", "eye"),
@@ -232,41 +232,41 @@ const INDENT: f32 = 14.0;
/// One row's look.
#[derive(Default)]
struct Row<'a> {
depth: usize,
pub(crate) struct Row<'a> {
pub(crate) depth: usize,
/// Some(open) for rows that fold.
fold: Option<bool>,
pub(crate) fold: Option<bool>,
/// Some(visible) for rows with an eye.
eye: Option<bool>,
icon: &'a str,
label: &'a str,
selected: bool,
dim: bool,
color: Option<Color32>,
pub(crate) eye: Option<bool>,
pub(crate) icon: &'a str,
pub(crate) label: &'a str,
pub(crate) selected: bool,
pub(crate) dim: bool,
pub(crate) color: Option<Color32>,
/// Component rows: is it the active one?
radio: Option<bool>,
pub(crate) radio: Option<bool>,
/// Trailing badges: "lock", "pin".
badges: &'a [&'a str],
pub(crate) badges: &'a [&'a str],
}
/// What happened on a row.
#[derive(Default)]
struct RowResp {
rect: Option<Rect>,
clicked: bool,
double: bool,
secondary: Option<Pos2>,
fold: bool,
eye: bool,
radio: bool,
hovered: bool,
pub(crate) struct RowResp {
pub(crate) rect: Option<Rect>,
pub(crate) clicked: bool,
pub(crate) double: bool,
pub(crate) secondary: Option<Pos2>,
pub(crate) fold: bool,
pub(crate) eye: bool,
pub(crate) radio: bool,
pub(crate) hovered: bool,
/// The row's response (drag and drop).
resp: Option<egui::Response>,
pub(crate) resp: Option<egui::Response>,
/// The press began on the fold arrow, eye or radio (not a drag of the row).
on_control: bool,
pub(crate) on_control: bool,
}
fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
let t = Tokens::get();
// Rows scrolled out of view only take their space: no widgets, no painting.
let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_H));
@@ -449,11 +449,11 @@ fn sel_of_key(k: &str) -> Option<Sel> {
None
}
fn is_open(app: &SolveApp, key: &str, default: bool) -> bool {
pub(crate) fn is_open(app: &SolveApp, key: &str, default: bool) -> bool {
if default { !app.tree.collapsed.contains(key) } else { app.tree.expanded.contains(key) }
}
fn toggle(app: &mut SolveApp, key: &str, default: bool) {
pub(crate) fn toggle(app: &mut SolveApp, key: &str, default: bool) {
if default {
if !app.tree.collapsed.remove(key) {
app.tree.collapsed.insert(key.to_string());
@@ -520,7 +520,7 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize,
let open = is_open(app, &key, true);
let active = app.session.active_component == id;
let bodies = crate::context_menu::component_bodies(app, id);
let visible = bodies.is_empty() || bodies.iter().any(|b| !app.ui.hidden_bodies.contains(b));
let visible = bodies.is_empty() || bodies.iter().any(|b| !app.session.visibility.hidden_bodies.contains(b));
let grounded = app.session.doc.occurrence_of(id).is_some_and(|o| o.grounded);
let faded = app.session.active_component != 0 && !app.session.doc.component_within(id, app.session.active_component) && !active;
let name = component_name(app, id);
@@ -546,9 +546,12 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize,
}
if r.eye {
if visible {
app.ui.hidden_bodies.extend(bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(b)).cloned().collect::<Vec<_>>());
app.session
.visibility
.hidden_bodies
.extend(bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(b)).cloned().collect::<Vec<_>>());
} else {
app.ui.hidden_bodies.retain(|b| !bodies.contains(b));
app.session.visibility.hidden_bodies.retain(|b| !bodies.contains(b));
}
}
if r.clicked {
@@ -661,7 +664,7 @@ fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
("YZ", "plane", "YZ"),
];
for (k, icon, label) in items {
let visible = !app.ui.hidden_origin.iter().any(|h| h == k);
let visible = !app.session.visibility.hidden_origin.iter().any(|h| h == k);
let sel = match icon {
"plane" => Some(Sel::Plane { name: k.into() }),
"axis" => Some(Sel::Axis { name: k.into() }),
@@ -675,9 +678,9 @@ fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
);
if r.eye {
if visible {
app.ui.hidden_origin.push(k.into());
app.session.visibility.hidden_origin.push(k.into());
} else {
app.ui.hidden_origin.retain(|h| h != k);
app.session.visibility.hidden_origin.retain(|h| h != k);
}
}
if r.clicked
@@ -1001,7 +1004,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
row_key: format!("b:{}", b.name),
label: b.name.clone(),
icon: "body",
visible: !app.ui.hidden_bodies.contains(&b.name),
visible: !app.session.visibility.hidden_bodies.contains(&b.name),
selected: sel.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name)),
color: None,
locked: app.ui.locked_bodies.contains(&b.name),
@@ -1040,7 +1043,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
row_key: format!("p:{}", f.id),
label: f.name.clone(),
icon: "plane",
visible: !app.ui.hidden_origin.contains(&f.name),
visible: !app.session.visibility.hidden_origin.contains(&f.name),
selected: sel.iter().any(|x| matches!(x, Sel::Plane { name } if *name == f.name)),
color: None,
locked: false,
@@ -1079,9 +1082,9 @@ fn set_visible(app: &mut SolveApp, folder: &str, items: &[&Entry], on: bool) {
for e in items {
match folder {
"bodies" => {
app.ui.hidden_bodies.retain(|b| *b != e.key);
app.session.visibility.hidden_bodies.retain(|b| *b != e.key);
if !on {
app.ui.hidden_bodies.push(e.key.clone());
app.session.visibility.hidden_bodies.push(e.key.clone());
}
}
"sketches" => {
@@ -1090,9 +1093,9 @@ fn set_visible(app: &mut SolveApp, folder: &str, items: &[&Entry], on: bool) {
}
}
_ => {
app.ui.hidden_origin.retain(|p| *p != e.label);
app.session.visibility.hidden_origin.retain(|p| *p != e.label);
if !on {
app.ui.hidden_origin.push(e.label.clone());
app.session.visibility.hidden_origin.push(e.label.clone());
}
}
}
+36 -27
View File
@@ -358,7 +358,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
if has(|s| matches!(s, Sel::Face { .. })) {
let face = sel.iter().find_map(|s| if let Sel::Face { body, .. } = s { Some(body.clone()) } else { None }).unwrap_or_default();
let (feature, sketch) = body_feature_and_sketch(app, &face);
let hidden = app.ui.hidden_bodies.contains(&face);
let hidden = app.session.visibility.hidden_bodies.contains(&face);
v.push(cmd(app, "sketch.create", "Create Sketch"));
v.push(cmd(app, "solid.extrude", "Extrude"));
v.push(cmd(app, "construct.plane.offset", "Offset Plane"));
@@ -379,7 +379,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
v.push(cmd(app, "inspect.measure", "Measure"));
} else if has(|s| matches!(s, Sel::Edge { .. })) {
let body = sel.iter().find_map(|s| if let Sel::Edge { body, .. } = s { Some(body.clone()) } else { None }).unwrap_or_default();
let hidden = app.ui.hidden_bodies.contains(&body);
let hidden = app.session.visibility.hidden_bodies.contains(&body);
v.push(cmd(app, "solid.fillet", "Fillet"));
v.push(cmd(app, "solid.chamfer", "Chamfer"));
v.push(Item::sep());
@@ -403,9 +403,9 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
v.push(act("ui.fit", "Fit", "fit"));
v.push(act("ui.home", "Home View", "home"));
v.push(Item::sep());
let anything_hidden = !app.ui.hidden_bodies.is_empty() || !app.ui.hidden_sketches.is_empty();
let anything_hidden = !app.session.visibility.hidden_bodies.is_empty() || !app.session.visibility.hidden_sketches.is_empty();
v.push(act("ui.showAll", "Show All", "eye").on(anything_hidden));
v.push(act("ui.showAll", "Unisolate", "").on(!app.ui.hidden_bodies.is_empty()));
v.push(act("ui.showAll", "Unisolate", "").on(!app.session.visibility.hidden_bodies.is_empty()));
v.push(Item::sep());
v.push(cmd(app, "solid.extrude", "Extrude"));
v.push(cmd(app, "solid.fillet", "Fillet"));
@@ -428,7 +428,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec<Item> {
let names = json!(bodies);
let all_hidden = bodies.iter().all(|b| app.ui.hidden_bodies.contains(b));
let all_hidden = bodies.iter().all(|b| app.session.visibility.hidden_bodies.contains(b));
let one = bodies.len() == 1;
let locked = bodies.iter().all(|b| app.ui.locked_bodies.contains(b));
let items = json!(bodies.iter().map(|b| json!({"type": "body", "name": b})).collect::<Vec<_>>());
@@ -493,7 +493,10 @@ fn joint_items(app: &SolveApp, id: u64) -> Vec<Item> {
let rigid = j.kind == solvecraft_engine::doc::joints::JointKind::Rigid;
vec![
act("ui.editJoint", "Edit Joint", "joint").with(json!({ "joint": id })),
act("ui.editJoint", "Edit Joint Limits", "").with(json!({ "joint": id })).on(!rigid),
act("ui.driveJoint", "Drive Joint", "").with(json!({ "joint": id })).on(!rigid),
act("ui.animateJoint", "Animate Joint", "").with(json!({ "joint": id })).on(!rigid && !j.suppressed),
act("ui.findJoint", "Find in Window", "").with(json!({ "joint": id })),
cmd(app, "joint.edit", if j.suppressed { "Unsuppress" } else { "Suppress" }).with(json!({ "joint": id, "suppressed": !j.suppressed })),
Item::sep(),
cmd(app, "joint.delete", "Delete").key("Del").with(json!({ "joint": id })),
@@ -506,7 +509,7 @@ fn component_items(app: &SolveApp, id: u64) -> Vec<Item> {
let occ = doc.occurrence_of(id);
let active = app.session.active_component == id;
let bodies = component_bodies(app, id);
let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.ui.hidden_bodies.contains(b));
let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.session.visibility.hidden_bodies.contains(b));
let names = json!(bodies);
let mut v = Vec::new();
if id != 0 {
@@ -666,18 +669,19 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
"ui.finishSketch" => app.finish_sketch(),
"ui.hide" => {
for b in bodies {
if !app.ui.hidden_bodies.contains(&b) {
app.ui.hidden_bodies.push(b);
if !app.session.visibility.hidden_bodies.contains(&b) {
app.session.visibility.hidden_bodies.push(b);
}
}
}
"ui.show" => app.ui.hidden_bodies.retain(|b| !bodies.contains(b)),
"ui.show" => app.session.visibility.hidden_bodies.retain(|b| !bodies.contains(b)),
"ui.showAll" => {
app.ui.hidden_bodies.clear();
app.ui.hidden_sketches.clear();
app.session.visibility.hidden_bodies.clear();
app.session.visibility.hidden_sketches.clear();
}
"ui.isolate" => {
app.ui.hidden_bodies = app.session.world_state().bodies.iter().map(|b| b.name.clone()).filter(|b| !bodies.contains(b)).collect();
app.session.visibility.hidden_bodies =
app.session.world_state().bodies.iter().map(|b| b.name.clone()).filter(|b| !bodies.contains(b)).collect();
}
"ui.lock" => {
let locked = bodies.iter().all(|b| app.ui.locked_bodies.contains(b));
@@ -791,12 +795,17 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
"ui.canvasVisible" => drop(app.run("canvas.edit", p)),
"ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.shortcutPin" => crate::toolbar_custom::run(app, &item.id, &p),
"ui.workspace" => {}
"ui.editJoint" | "ui.driveJoint" => {
"ui.findJoint" => {
if let Some(id) = id_of(&p, "joint") {
let d = if item.id == "ui.editJoint" {
crate::dialogs_assembly::edit_joint(app, id)
} else {
crate::dialogs_assembly::drive_joint(app, id)
crate::dialogs_assembly::find_joint(app, id);
}
}
"ui.editJoint" | "ui.driveJoint" | "ui.animateJoint" => {
if let Some(id) = id_of(&p, "joint") {
let d = match item.id.as_str() {
"ui.editJoint" => crate::dialogs_assembly::edit_joint(app, id),
"ui.animateJoint" => crate::dialogs_assembly::animate_joint(app, id),
_ => crate::dialogs_assembly::drive_joint(app, id),
};
if let Some(d) = d {
app.tool = None;
@@ -822,14 +831,14 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
}
"ui.originAll" => {
app.ui.show_origin = true;
app.ui.hidden_origin.retain(|h| !["O", "X", "Y", "Z", "XY", "XZ", "YZ"].contains(&h.as_str()));
app.session.visibility.hidden_origin.retain(|h| !["O", "X", "Y", "Z", "XY", "XZ", "YZ"].contains(&h.as_str()));
}
"ui.originPart" => {
let show = p.get("show").and_then(Value::as_bool).unwrap_or(true);
for k in strs(&p, "keys") {
app.ui.hidden_origin.retain(|h| *h != k);
app.session.visibility.hidden_origin.retain(|h| *h != k);
if !show {
app.ui.hidden_origin.push(k);
app.session.visibility.hidden_origin.push(k);
}
}
}
@@ -925,16 +934,16 @@ pub fn toggle_visibility(app: &mut SolveApp) {
})
.collect();
let canvases = app.tree.picked_canvases.clone();
let any_visible = bodies.iter().any(|b| !app.ui.hidden_bodies.contains(b))
let any_visible = bodies.iter().any(|b| !app.session.visibility.hidden_bodies.contains(b))
|| sketches.iter().any(|s| crate::browser::sketch_visible(app, *s))
|| origin.iter().any(|o| !app.ui.hidden_origin.contains(o))
|| origin.iter().any(|o| !app.session.visibility.hidden_origin.contains(o))
|| app.session.doc.canvases.iter().any(|c| canvases.contains(&c.id) && c.visible);
let hide = any_visible;
app.ui.hidden_bodies.retain(|b| !bodies.contains(b));
app.ui.hidden_origin.retain(|o| !origin.contains(o));
app.session.visibility.hidden_bodies.retain(|b| !bodies.contains(b));
app.session.visibility.hidden_origin.retain(|o| !origin.contains(o));
if hide {
app.ui.hidden_bodies.extend(bodies);
app.ui.hidden_origin.extend(origin);
app.session.visibility.hidden_bodies.extend(bodies);
app.session.visibility.hidden_origin.extend(origin);
}
for s in sketches {
crate::browser::set_sketch_visible(app, s, !hide);
@@ -1007,7 +1016,7 @@ fn commit_rename(app: &mut SolveApp, r: &Rename) {
match &r.what {
RenameWhat::Body(old) => {
if app.run("body.rename", json!({ "body": old, "name": name })).is_ok() {
for list in [&mut app.ui.hidden_bodies, &mut app.ui.locked_bodies] {
for list in [&mut app.session.visibility.hidden_bodies, &mut app.ui.locked_bodies] {
for b in list.iter_mut().filter(|b| *b == old) {
*b = name.clone();
}
+18 -5
View File
@@ -119,6 +119,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
"document.inspect" => wrap(app.session.execute("document.inspect", p).map_err(|e| e.to_string())),
"ui.inspect" => ok(json!({
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
"visibility": app.session.visibility,
"viewport": app.viewport.rect.map(|r| json!([r.left(), r.top(), r.width(), r.height()])),
"camera": app.cam,
"tool": app.tool.as_ref().map(|t| t.cmd),
@@ -149,18 +150,30 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
ok(json!({"open": app.home.open, "recent": app.home.recent}))
}
"ui.set" => {
// What is hidden lives in the session (`browser.visibility`); `ui.set` still sets it.
const VIS: [(&str, &str); 4] = [
("hiddenBodies", "hidden_bodies"),
("hiddenOrigin", "hidden_origin"),
("hiddenSketches", "hidden_sketches"),
("shownSketches", "shown_sketches"),
];
let mut cur = serde_json::to_value(&app.ui).unwrap_or(json!({}));
if let (Some(o), Some(src)) = (cur.as_object_mut(), p.as_object()) {
let mut vis = serde_json::to_value(&app.session.visibility).unwrap_or(json!({}));
if let (Some(o), Some(vo), Some(src)) = (cur.as_object_mut(), vis.as_object_mut(), p.as_object()) {
for (k, v) in src {
o.insert(k.clone(), v.clone());
match VIS.iter().find(|(camel, _)| camel == k) {
Some((_, snake)) => vo.insert(snake.to_string(), v.clone()),
None => o.insert(k.clone(), v.clone()),
};
}
}
match serde_json::from_value::<crate::UiState>(cur) {
Ok(u) => {
match (serde_json::from_value::<crate::UiState>(cur), serde_json::from_value::<solvecraft_engine::Visibility>(vis)) {
(Ok(u), Ok(v)) => {
app.ui = u;
app.session.visibility = v;
ok(serde_json::to_value(&app.ui).unwrap_or_default())
}
Err(e) => err(e),
(Err(e), _) | (_, Err(e)) => err(e),
}
}
"ui.view" => {
+57 -8
View File
@@ -480,10 +480,12 @@ impl Dialog {
.collect::<Vec<_>>()
})
.unwrap_or_else(|| vec![sel.clone()]),
(Kind::Measure { .. }, _) => vec![crate::viewport::measurement_selection(s, sel)],
_ if inp.multi => crate::viewport::grouped_selection(s, sel),
_ => vec![sel.clone()],
};
for x in picked {
if !inp.items.contains(&x) {
if !inp.items.iter().any(|y| selection::same_item(y, &x)) {
inp.items.push(x);
}
}
@@ -631,8 +633,11 @@ impl Dialog {
/// A pick in the viewport (already accepted by the active input).
pub fn pick(&mut self, s: &Session, sel: Sel) {
let measure = matches!(self.kind, Kind::Measure { .. });
let sel = if measure { crate::viewport::measurement_selection(s, &sel) } else { sel };
let chain = matches!(self.kind, Kind::Fillet { chain: true, .. });
let mut picked = vec![sel.clone()];
let mut picked =
if !measure && self.active_input().is_some_and(|i| i.multi) { crate::viewport::grouped_selection(s, &sel) } else { vec![sel.clone()] };
// Tangent chain: an edge brings the edges that continue it smoothly.
if chain
&& let Sel::Edge { body, index, .. } = &sel
@@ -667,13 +672,15 @@ impl Dialog {
}
/// Box selection result: replace (or add to) the active input.
pub fn take_box(&mut self, sels: Vec<Sel>, add: bool) {
pub fn take_box(&mut self, s: &Session, sels: Vec<Sel>, add: bool) {
let measure = matches!(self.kind, Kind::Measure { .. });
let Some(inp) = self.inputs.get_mut(self.active) else { return };
if !add {
inp.items.clear();
}
for x in sels {
if fits(inp.accept, &x) && !inp.items.contains(&x) {
let x = if measure { crate::viewport::measurement_selection(s, &x) } else { x };
if fits(inp.accept, &x) && !inp.items.iter().any(|y| selection::same_item(y, &x)) {
inp.items.push(x);
}
}
@@ -1848,7 +1855,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
Err(e) => d.error = Some(e),
}
}
if !(keep && !cancel) {
if !keep || cancel {
crate::dialogs_motion::closed(app, &mut d, applied);
}
if keep && !cancel {
@@ -2691,11 +2698,50 @@ pub(crate) fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &P
}
}
thread_local! {
/// A dialog is being filled from a feature: its points are in component frames.
static REFILL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// While filling a dialog from a feature (dropped at the end).
struct Refill;
impl Refill {
fn start() -> Refill {
REFILL.with(|r| r.set(true));
Refill
}
}
impl Drop for Refill {
fn drop(&mut self) {
REFILL.with(|r| r.set(false));
}
}
/// A point stored in a feature, as it shows on world body `body`: features keep their picks in
/// the frame of the body they were on, so each body's placement takes the point to the world
/// (a moved component, a pasted instance). Other points are the world already.
fn stored_on(frames: &Option<Vec<(String, solvecraft_engine::doc::Mat)>>, body: &str, p: Vec3) -> Vec3 {
frames.as_ref().and_then(|f| f.iter().find(|(n, _)| n == body)).map(|(_, m)| solvecraft_engine::doc::apply_point(m, p)).unwrap_or(p)
}
/// Body placements while filling a dialog from a feature (when anything is placed).
fn stored_frames(s: &Session) -> Option<Vec<(String, solvecraft_engine::doc::Mat)>> {
if !REFILL.with(std::cell::Cell::get) || !solvecraft_engine::frames::placed(s) {
return None;
}
let st = s.model.state();
Some(s.doc.body_frames(&st).into_iter().map(|(w, _, _, m)| (w, m)).collect())
}
/// The edge of a visible body through (or nearest) a point.
pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option<Sel> {
pub(crate) fn edge_sel(s: &Session, p0: Vec3) -> Option<Sel> {
let st = s.world_state();
let frames = stored_frames(s);
let mut best: Option<(f64, Sel)> = None;
for b in &st.bodies {
let p = stored_on(&frames, &b.name, p0);
let m = b.mesh();
for (i, e) in m.edges.iter().enumerate() {
let d = e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
@@ -2708,10 +2754,12 @@ pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option<Sel> {
}
/// The body face containing a point (nearest triangle).
pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
pub(crate) fn face_sel(s: &Session, p0: Vec3) -> Option<Sel> {
let st = s.world_state();
let frames = stored_frames(s);
let mut best: Option<(f64, Sel)> = None;
for b in &st.bodies {
let p = stored_on(&frames, &b.name, p0);
let m = b.mesh();
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let Some([a, bb, c]) = m.tri(t) else { continue };
@@ -2721,7 +2769,7 @@ pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
}
}
}
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p.len()).map(|x| x.1)
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p0.len()).map(|x| x.1)
}
pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
@@ -2756,6 +2804,7 @@ fn pt3(v: Vec3) -> Value {
/// A dialog that edits an existing feature, filled from it. The timeline is rolled back to just
/// before the feature, so its references show on the geometry they refer to.
pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dialog> {
let _refill = Refill::start();
let s = &app.session;
let f = s.doc.feature(id)?.clone();
let st = s.world_state();
+165 -60
View File
@@ -209,6 +209,38 @@ pub fn edit_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
}
/// The Drive Joints dialog on a joint (by id).
/// Edit Joint with its motion playing (the browser's Animate Joint).
pub fn animate_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
let mut d = edit_joint(app, id)?;
if let Kind::Assembly(Asm::Joint(f)) = &mut d.kind {
f.animate = true;
}
Some(d)
}
/// Frame the two components a joint connects (the browser's Find in Window).
pub fn find_joint(app: &mut SolveApp, id: u64) {
let s = &app.session;
let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == id) else { return };
let occs = [j.a.occurrence, j.b.occurrence];
let model = s.model.state();
let world = s.world_state();
let mut bb = Aabb3::default();
for (name, _, path, _) in s.doc.body_frames(&model) {
// The root (occurrence 0) holds the bodies outside every occurrence.
let on = occs.iter().any(|o| if *o == 0 { path.is_empty() } else { path.contains(o) });
if on && let Some(b) = world.body(&name) {
bb = bb.union(&b.mesh().bounds());
}
}
if bb.is_empty() {
return;
}
let mut cam = app.cam;
cam.fit(&bb);
app.animate_to(cam);
}
pub fn drive_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
let joint = app.session.doc.assembly.joints.iter().position(|j| j.id == id)?;
Some(Dialog::new(Kind::Assembly(Asm::Drive { joint, values: current_values(&app.session, joint), focus: true }), vec![]))
@@ -494,12 +526,25 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
let cur = s.doc.eval(v, unit_kind).ok().filter(|x| x.is_finite()).unwrap_or(0.0);
let (mut shown, scale) = if angle { (cur.to_degrees(), 1f64.to_degrees()) } else { (cur, 1.0) };
row_label(ui, "");
ui.spacing_mut().slider_width = 124.0;
ui.spacing_mut().slider_width = 160.0;
let r = ui.add(egui::Slider::new(&mut shown, lo * scale..=hi * scale).show_value(false));
if r.changed() {
*v = if angle { format!("{shown:.1} deg") } else { format!("{shown:.2} mm") };
}
ui.end_row();
// The slider's range: the joint's limits, or the default span when it has none.
let limited = j.limits.get(i).copied().flatten().is_some();
let fmt = |x: f64| {
let (x, unit) = if angle { (x.to_degrees(), "°") } else { (x, " mm") };
if x.fract().abs() < 1e-6 { format!("{x:.0}{unit}") } else { format!("{x:.1}{unit}") }
};
row_label(ui, "");
ui.label(
RichText::new(format!("{} … {}{}", fmt(lo), fmt(hi), if limited { " (limits)" } else { " (no limits)" }))
.size(11.0)
.color(if limited { t.text } else { t.text_dim }),
);
ui.end_row();
}
}
Asm::MotionLink { a, b, ia, ib, ratio, offset } => {
@@ -826,6 +871,8 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
}
return;
}
// A joint names its two picks; a joint origin has one.
let two = matches!(k, Asm::Joint(_));
let hover = match k {
Asm::Joint(f) => Some(&mut f.hover),
Asm::JointOrigin { hover, .. } => Some(hover),
@@ -840,8 +887,9 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
.filter(|(_, i)| i.accept == SNAPS)
.filter_map(|(n, i)| i.items.first().and_then(|x| snap_of(&app.session, x)).map(|s| (s, if n == 0 { t.accent } else { t.warning })))
.collect();
for (s, c) in &picked {
glyph(painter, proj, s, *c, app.cam.half_height());
for (n, (s, c)) in picked.iter().enumerate() {
let tag = format!("Component {}", n + 1);
glyph(painter, proj, s, *c, app.cam.half_height(), two.then_some(tag.as_str()));
}
// The snap under the cursor (found again only when the hovered item changes).
let accepting = d.inputs.get(d.active).is_some_and(|i| i.accept == SNAPS);
@@ -853,7 +901,7 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
*hover = Some((sel.clone(), snap_of(&app.session, &sel)));
}
if let Some((_, Some(s))) = hover.as_ref() {
glyph(painter, proj, s, t.hover_profile_edge, app.cam.half_height());
glyph(painter, proj, s, t.hover_profile_edge, app.cam.half_height(), Some(snap_name(s.kind)));
}
}
None => *hover = None,
@@ -861,36 +909,70 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
}
/// A snap: its kind's marker at the point and the z axis as an arrow.
fn glyph(painter: &egui::Painter, proj: &Proj, s: &SnapPt, col: Color32, half_height: f64) {
fn glyph(painter: &egui::Painter, proj: &Proj, s: &SnapPt, col: Color32, half_height: f64, tag: Option<&str>) {
let Some(c) = proj.to_screen(s.at) else { return };
let len = half_height * 2.0 * 0.06;
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
let halo = Stroke::new(4.5, Color32::from_black_alpha(150));
let white = Stroke::new(1.5, Color32::WHITE);
if let Some(tip) = proj.to_screen(s.at + s.z * len) {
painter.line_segment([c, tip], Stroke::new(2.0, col));
let d = (tip - c).normalized();
let n = vec2(-d.y, d.x);
painter.add(egui::Shape::convex_polygon(vec![tip, tip - d * 8.0 + n * 4.0, tip - d * 8.0 - n * 4.0], col, Stroke::NONE));
let head = vec![tip + d * 2.0, tip - d * 9.0 + n * 5.0, tip - d * 9.0 - n * 5.0];
painter.line_segment([c, tip], halo);
painter.add(egui::Shape::convex_polygon(head.clone(), Color32::TRANSPARENT, halo));
painter.line_segment([c, tip], Stroke::new(2.0, col));
painter.add(egui::Shape::convex_polygon(head, col, Stroke::NONE));
}
let white = Stroke::new(1.5, Color32::WHITE);
let shape = |fill: Color32, stroke: Stroke, grow: f32| -> egui::Shape {
match s.kind {
SnapKind::FaceCenter => egui::Shape::rect_filled(egui::Rect::from_center_size(c, vec2(12.0 + grow, 12.0 + grow)), 1.5, fill),
SnapKind::CircleCenter => egui::Shape::circle_filled(c, 7.0 + grow / 2.0, fill),
SnapKind::EdgeMid => {
let r = 8.0 + grow / 2.0;
egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r * 0.93, r * 0.7), c + vec2(-r * 0.93, r * 0.7)], fill, stroke)
}
SnapKind::Vertex => {
let r = 7.5 + grow / 2.0;
egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r, 0.0), c + vec2(0.0, r), c + vec2(-r, 0.0)], fill, stroke)
}
}
};
painter.add(shape(Color32::from_black_alpha(150), Stroke::NONE, 4.0));
painter.add(shape(col, Stroke::NONE, 0.0));
match s.kind {
SnapKind::FaceCenter => {
painter.rect(egui::Rect::from_center_size(c, vec2(10.0, 10.0)), 1.0, col, white, egui::StrokeKind::Middle);
painter.rect_stroke(egui::Rect::from_center_size(c, vec2(12.0, 12.0)), 1.5, white, egui::StrokeKind::Middle);
}
SnapKind::CircleCenter => {
painter.circle(c, 6.0, col, white);
painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], Stroke::new(1.2, Color32::WHITE));
painter.line_segment([c - vec2(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.2, Color32::WHITE));
painter.circle_stroke(c, 7.0, white);
painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, Color32::WHITE));
painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, Color32::WHITE));
}
SnapKind::EdgeMid => {
painter.add(egui::Shape::convex_polygon(vec![c + vec2(0.0, -7.0), c + vec2(6.5, 5.0), c + vec2(-6.5, 5.0)], col, white));
}
SnapKind::Vertex => {
painter.add(egui::Shape::convex_polygon(
vec![c + vec2(0.0, -6.5), c + vec2(6.5, 0.0), c + vec2(0.0, 6.5), c + vec2(-6.5, 0.0)],
col,
white,
));
SnapKind::EdgeMid | SnapKind::Vertex => {
painter.add(shape(Color32::TRANSPARENT, white, 0.0));
}
}
// What it snaps to (hovered), or which component it is (picked): a small label.
if let Some(tag) = tag {
let t = Tokens::get();
let font = egui::FontId::proportional(11.5);
let g = painter.layout_no_wrap(tag.to_string(), font, t.text);
let at = c + vec2(12.0, 10.0);
let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0));
painter.rect(r, 3.0, t.panel.gamma_multiply(0.92), Stroke::new(1.0, col), egui::StrokeKind::Inside);
painter.galley(at, g, t.text);
}
}
/// What a snap is called (the hover label).
fn snap_name(k: SnapKind) -> &'static str {
match k {
SnapKind::FaceCenter => "Face center",
SnapKind::CircleCenter => "Circle center",
SnapKind::EdgeMid => "Edge midpoint",
SnapKind::Vertex => "Vertex",
}
}
// ---- timeline and browser ----
@@ -963,61 +1045,36 @@ pub fn timeline_joints(app: &mut SolveApp, ui: &mut egui::Ui, p: &egui::Painter,
/// The Joints folder at the end of the Browser: a row per joint (double-click edits, the menu
/// edits, drives, suppresses or deletes).
pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
let t = Tokens::get();
use crate::browser::{Row, draw_row, is_open, toggle};
let joints: Vec<(u64, String, JointKind, bool)> =
app.session.doc.assembly.joints.iter().map(|j| (j.id, j.name.clone(), j.kind, j.suppressed)).collect();
if joints.is_empty() {
return;
}
let fold_id = egui::Id::new("sc_browser_joints_open");
let mut open = ui.data(|d| d.get_temp::<bool>(fold_id)).unwrap_or(true);
let row = |ui: &mut egui::Ui, depth: f32, icon: &str, label: &str, dim: bool, fold: Option<bool>| -> egui::Response {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), egui::Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
let x = r.left() + 2.0 + depth * 14.0;
let cy = r.center().y;
if let Some(o) = fold {
let c = egui::pos2(x + 6.0, cy);
let pts = if o {
vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)]
} else {
vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)]
};
ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE));
}
let ir = egui::Rect::from_center_size(egui::pos2(x + 26.0, cy), vec2(16.0, 16.0));
crate::icons::paint(ui.painter(), ir, icon, if dim { t.border } else { t.icon }, t.icon_fill, t.accent);
ui.painter().text(
egui::pos2(x + 38.0, cy),
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(13.0),
if dim { t.text_dim } else { t.text },
);
resp
};
let depth = depth as f32;
if row(ui, depth, "folder", "Joints", false, Some(open)).clicked() {
open = !open;
ui.data_mut(|d| d.insert_temp(fold_id, open));
// Drawn like every other browser node (its fold and rows publish `fold:Joints`, `row:<name>`).
let key = "c0/joints";
let open = is_open(app, key, true);
let r = draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), icon: "folder", label: "Joints", ..Default::default() });
if r.fold || r.clicked {
toggle(app, key, true);
}
if !open {
return;
}
let mut dialog: Option<Dialog> = None;
for (id, name, kind, suppressed) in joints {
let resp = row(ui, depth + 1.0, "joint", &name, suppressed, None).on_hover_text(format!("{kind:?} joint"));
if resp.double_clicked() {
let r =
draw_row(ui, ui.id().with(("joint", id)), &Row { depth: depth + 1, icon: "joint", label: &name, dim: suppressed, ..Default::default() });
if let Some(resp) = &r.resp {
resp.clone().on_hover_text(format!("{kind:?} joint"));
}
if r.double {
dialog = edit_joint(app, id);
}
// The same menus as the rest of the browser.
if resp.secondary_clicked() {
let at = resp.interact_pointer_pos().unwrap_or(resp.rect.left_bottom());
if let Some(at) = r.secondary {
crate::context_menu::open_for(app, at, crate::context_menu::Target::Joint { id });
}
let _ = (kind, suppressed);
}
if let Some(d) = dialog {
app.tool = None;
@@ -1028,3 +1085,51 @@ pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
#[cfg(test)]
#[path = "dialogs_assembly_tests.rs"]
mod tests;
thread_local! {
/// Named joint origins in the world, for the design state they were found in.
static ORIGINS: std::cell::RefCell<Option<(String, Vec<(String, solvecraft_engine::doc::Mat)>)>> = const { std::cell::RefCell::new(None) };
}
/// Named joint origins in the world: on their occurrence's component, so they move with it
/// (joint drives, free moves not captured yet, Capture Position).
pub fn origins_world(s: &Session) -> Vec<(String, solvecraft_engine::doc::Mat)> {
if s.doc.assembly.origins.is_empty() {
return Vec::new();
}
let key = format!("{}:{:?}", s.revision, s.pending_moves);
if let Some(v) = ORIGINS.with(|o| o.borrow().as_ref().filter(|(k, _)| *k == key).map(|(_, v)| v.clone())) {
return v;
}
let doc: std::borrow::Cow<solvecraft_engine::doc::Document> = if s.pending_moves.is_empty() {
std::borrow::Cow::Borrowed(&*s.doc)
} else {
let mut d = (*s.doc).clone();
for o in &mut d.occurrences {
if let Some(m) = s.pending_moves.get(&o.id) {
o.transform = *m;
}
}
std::borrow::Cow::Owned(d)
};
let st = s.model.state();
let v: Vec<_> = doc.assembly.origins.iter().filter_map(|o| doc.origin_world(&st, &o.origin).ok().map(|m| (o.name.clone(), m))).collect();
ORIGINS.with(|o| *o.borrow_mut() = Some((key, v.clone())));
v
}
/// Named joint origins in the viewport: a small triad where each sits.
pub fn origins(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
let t = Tokens::get();
let len = app.cam.half_height() * 2.0 * 0.04;
for (_, m) in origins_world(&app.session) {
let at = solvecraft_engine::doc::apply_point(&m, Vec3::ZERO);
let Some(c) = proj.to_screen(at) else { continue };
for (axis, col) in [(Vec3::X, t.axis_x), (Vec3::Y, t.axis_y), (Vec3::Z, t.axis_z)] {
if let Some(tip) = proj.to_screen(at + solvecraft_engine::doc::apply_vector(&m, axis) * len) {
painter.line_segment([c, tip], Stroke::new(2.0, col));
}
}
painter.circle(c, 4.5, t.panel, Stroke::new(1.5, t.text));
}
}
@@ -263,3 +263,41 @@ fn drive_value_takes_the_keyboard() {
assert!((j.values[0] - 90f64.to_radians()).abs() < 1e-9, "{:?}", j.values);
assert!(app.dialog.is_none());
}
/// Named joint origins are drawn where their component is: a drive of the occurrence, or a free
/// move not captured yet, carries them along.
#[test]
fn joint_origins_follow_their_component() {
let mut app = two_boxes();
app.session.execute("joint.origin", &json!({"name": "Top of B", "face": [40, 10, 10]})).unwrap();
let at = |app: &SolveApp| {
let v = origins_world(&app.session);
let (_, m) = v.iter().find(|(n, _)| n == "Top of B").unwrap();
solvecraft_engine::doc::apply_point(m, Vec3::ZERO)
};
assert!(at(&app).dist(Vec3::new(40.0, 10.0, 10.0)) < 1e-6, "{:?}", at(&app));
let b = occ(&app, "B");
app.session.execute("occurrence.move", &json!({"occurrence": b, "translate": [0, 0, 15]})).unwrap();
assert!(at(&app).dist(Vec3::new(40.0, 10.0, 25.0)) < 1e-6, "{:?}", at(&app));
// A drag not captured yet.
let mut m = app.session.doc.occurrence_of(app.session.doc.find_component("B").unwrap()).unwrap().transform;
m[3][0] += 5.0;
app.session.pending_moves.insert(b, m);
assert!(at(&app).dist(Vec3::new(45.0, 10.0, 25.0)) < 1e-6, "{:?}", at(&app));
}
/// Edit Feature on a moved component: the dialog shows the feature's picks where the
/// component is (its stored points are in the component's frame).
#[test]
fn edit_feature_on_a_moved_component_refills_in_the_world() {
let mut app = two_boxes();
let b = occ(&app, "B");
app.session.execute("occurrence.move", &json!({"occurrence": b, "translate": [0, 0, 15]})).unwrap();
let r = app.session.execute("solid.fillet", &json!({"edges": [[40, 0, 25]], "radius": 2})).unwrap();
let id = r["feature"].as_u64().unwrap();
let d = crate::dialogs::for_feature(&app, id, None).unwrap();
let items: Vec<Sel> = d.inputs.iter().flat_map(|i| i.items.clone()).collect();
let Some(Sel::Edge { body, point, .. }) = items.first() else { panic!("{items:?}") };
assert_eq!(body, "Body2");
assert!(point.dist(Vec3::new(40.0, 0.0, 25.0)) < 1e-6, "{point:?}");
}
+127 -8
View File
@@ -41,6 +41,10 @@ pub enum Mo {
tracks: Vec<Track>,
step: f64,
playing: bool,
/// Playback starts over at the end (else it stops there).
looping: bool,
/// Playback speed (index into `SPEEDS`).
speed: usize,
/// The step the shown pose is for (and its revision).
shown: Option<(u64, u64)>,
saved: Option<BTreeMap<u64, Mat>>,
@@ -101,6 +105,8 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
tracks: Vec::new(),
step: 0.0,
playing: false,
looping: true,
speed: 2,
shown: None,
saved: None,
error: None,
@@ -116,6 +122,73 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
// ---- pure mappings (tested) ----
/// Playback speeds offered (times `STEPS_PER_S`).
pub const SPEEDS: [f64; 5] = [0.25, 0.5, 1.0, 2.0, 4.0];
/// Steps played per second at 1×.
const STEPS_PER_S: f64 = 24.0;
/// The next playback step: `by` steps on; at the end it starts over (looping) or stops there.
/// Returns the step and whether it still plays.
pub fn advance(step: f64, by: f64, total: u32, looping: bool) -> (f64, bool) {
let last = f64::from(total);
let next = step + by.max(0.0);
if next <= last {
(next, true)
} else if looping && last > 0.0 {
(next % (last + 1.0), true)
} else {
(last, false)
}
}
#[derive(Clone, Copy)]
enum Transport {
Start,
Back,
Play,
Stop,
Forward,
End,
}
/// A media button drawn in code: bars and triangles.
fn transport(ui: &mut egui::Ui, what: Transport, tip: &str) -> egui::Response {
let t = Tokens::get();
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 22.0), Sense::click());
let fill = if resp.hovered() { t.hover } else { t.field };
ui.painter().rect(r, 3.0, fill, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let c = r.center();
let ink = if matches!(what, Transport::Play | Transport::Stop) { t.accent } else { t.icon };
let p = ui.painter();
let tri = |dir: f32, x: f32| {
egui::Shape::convex_polygon(vec![pos2(x - 4.0 * dir, c.y - 5.0), pos2(x + 4.0 * dir, c.y), pos2(x - 4.0 * dir, c.y + 5.0)], ink, Stroke::NONE)
};
let bar = |x: f32| egui::Shape::rect_filled(egui::Rect::from_center_size(pos2(x, c.y), vec2(2.0, 10.0)), 0.0, ink);
match what {
Transport::Play => drop(p.add(tri(1.0, c.x + 1.0))),
Transport::Stop => drop(p.rect_filled(egui::Rect::from_center_size(c, vec2(9.0, 9.0)), 1.0, ink)),
Transport::Back => {
p.add(bar(c.x + 4.0));
p.add(tri(-1.0, c.x - 1.0));
}
Transport::Forward => {
p.add(bar(c.x - 4.0));
p.add(tri(1.0, c.x + 1.0));
}
Transport::Start => {
p.add(bar(c.x - 6.0));
p.add(tri(-1.0, c.x - 0.5));
p.add(tri(-1.0, c.x + 5.0));
}
Transport::End => {
p.add(tri(1.0, c.x - 5.0));
p.add(tri(1.0, c.x + 0.5));
p.add(bar(c.x + 6.0));
}
}
resp.on_hover_text(tip)
}
/// The study command's parameters.
pub fn study_params(s: &Session, name: &str, study: Option<&str>, steps: &str, tracks: &[Track]) -> Result<Value, String> {
let steps: u32 = steps.trim().parse().map_err(|_| "steps must be a whole number".to_string())?;
@@ -321,7 +394,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
ui.end_row();
}
}
Mo::MotionStudy { name, study, steps, tracks, step, playing, shown, saved, error } => {
Mo::MotionStudy { name, study, steps, tracks, step, playing, looping, speed, shown, saved, error } => {
row_label(ui, "Name");
ui.text_edit_singleline(name);
ui.end_row();
@@ -336,19 +409,54 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
ui.end_row();
return enter;
}
// Playback.
row_label(ui, "Step");
// Playback: to start, step back, play/stop, step on, to end; loop and speed.
row_label(ui, "Playback");
ui.horizontal(|ui| {
if ui.button(if *playing { "■" } else { "▶" }).on_hover_text(if *playing { "Stop" } else { "Play" }).clicked() {
let last = f64::from(total);
if transport(ui, Transport::Start, "To the start").clicked() {
*step = 0.0;
*playing = false;
}
if transport(ui, Transport::Back, "Step back").clicked() {
*step = (step.round() - 1.0).max(0.0);
*playing = false;
}
let (what, tip) = if *playing { (Transport::Stop, "Stop") } else { (Transport::Play, "Play") };
if transport(ui, what, tip).clicked() {
// Play from the start again when at the end.
if !*playing && step.round() >= last {
*step = 0.0;
}
*playing = !*playing;
}
ui.spacing_mut().slider_width = 220.0;
ui.add(egui::Slider::new(step, 0.0..=f64::from(total)).integer());
if transport(ui, Transport::Forward, "Step on").clicked() {
*step = (step.round() + 1.0).min(last);
*playing = false;
}
if transport(ui, Transport::End, "To the end").clicked() {
*step = last;
*playing = false;
}
ui.add_space(6.0);
ui.checkbox(looping, "Loop");
let names: Vec<String> = SPEEDS.iter().map(|x| format!("{x}×")).collect();
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
combo(ui, "ms_speed", &refs, speed);
});
ui.end_row();
row_label(ui, "Step");
ui.horizontal(|ui| {
ui.spacing_mut().slider_width = 260.0;
if ui.add(egui::Slider::new(step, 0.0..=f64::from(total)).integer().show_value(false)).dragged() {
*playing = false;
}
ui.label(RichText::new(format!("{} / {total}", step.round() as u32)).color(t.text_dim));
});
ui.end_row();
if *playing {
let dt = ui.input(|i| i.stable_dt).min(0.1);
*step = (*step + f64::from(dt) * 24.0) % (f64::from(total) + 1.0);
let dt = f64::from(ui.input(|i| i.stable_dt).min(0.1));
let fps = STEPS_PER_S * SPEEDS.get(*speed).copied().unwrap_or(1.0);
(*step, *playing) = advance(*step, dt * fps, total, *looping);
ui.ctx().request_repaint();
}
// Tracks: a row per keyed joint value, its keys on a strip under the steps.
@@ -667,6 +775,15 @@ pub fn commands(app: &SolveApp, k: &Mo, inputs: &[SelInput]) -> Result<Vec<(Stri
mod tests {
use super::*;
/// Playback runs on, starts over at the end when looping, else stops on the last step.
#[test]
fn playback_advances_loops_and_stops() {
assert_eq!(advance(3.0, 2.0, 10, true), (5.0, true));
assert_eq!(advance(9.5, 2.0, 10, true), (0.5, true));
assert_eq!(advance(9.5, 2.0, 10, false), (10.0, false));
assert_eq!(advance(4.0, -3.0, 10, false), (4.0, true));
}
/// What a dialog shows goes away when it closes; an exploded view applied with OK stays.
#[test]
fn shown_poses_are_put_back() {
@@ -688,6 +805,8 @@ mod tests {
tracks: vec![],
step: 0.0,
playing: false,
looping: true,
speed: 2,
shown: None,
saved,
error: None,
+1 -1
View File
@@ -187,7 +187,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
}
let tk = Tokens::get();
let is_sel = |id: &str| app.session.selection.iter().any(|s| matches!(s, Sel::SketchConstraint { id: x } if x == id));
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| proj.rect.contains(*p)).and_then(hit);
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect)).and_then(hit);
let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p));
let font = FontId::proportional(12.0);
let centres = chain_centres(sk);
+23 -15
View File
@@ -27,10 +27,6 @@ pub struct DocSlot {
/// The per-design part of the UI state (what is hidden, shown or locked).
#[derive(Clone, Debug, Default)]
struct DocView {
hidden_bodies: Vec<String>,
hidden_origin: Vec<String>,
hidden_sketches: Vec<u64>,
shown_sketches: Vec<u64>,
hidden_profiles: Vec<u64>,
shown_dims: Vec<u64>,
locked_bodies: Vec<String>,
@@ -63,10 +59,6 @@ impl Documents {
fn take_view(app: &mut SolveApp) -> DocView {
let u = &mut app.ui;
DocView {
hidden_bodies: std::mem::take(&mut u.hidden_bodies),
hidden_origin: std::mem::take(&mut u.hidden_origin),
hidden_sketches: std::mem::take(&mut u.hidden_sketches),
shown_sketches: std::mem::take(&mut u.shown_sketches),
hidden_profiles: std::mem::take(&mut u.hidden_profiles),
shown_dims: std::mem::take(&mut u.shown_dims),
locked_bodies: std::mem::take(&mut u.locked_bodies),
@@ -75,10 +67,6 @@ fn take_view(app: &mut SolveApp) -> DocView {
fn put_view(app: &mut SolveApp, v: DocView) {
let u = &mut app.ui;
u.hidden_bodies = v.hidden_bodies;
u.hidden_origin = v.hidden_origin;
u.hidden_sketches = v.hidden_sketches;
u.shown_sketches = v.shown_sketches;
u.hidden_profiles = v.hidden_profiles;
u.shown_dims = v.shown_dims;
u.locked_bodies = v.locked_bodies;
@@ -110,6 +98,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
/// Make tab `i` the active design.
pub fn switch(app: &mut SolveApp, i: usize) {
app.home.open = false;
if i == app.docs.active || i >= app.docs.slots.len() {
return;
}
@@ -305,6 +294,25 @@ pub fn control(app: &mut SolveApp, p: &Value) -> Value {
/// + for a new design.
pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
let t = Tokens::get();
let home = Rect::from_min_size(pos2(x0, r.top() + 5.0), vec2(66.0, r.height() - 5.0));
let response = ui.interact(home, ui.id().with("home_tab"), Sense::click());
ui.painter().rect_filled(
home,
egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 },
if app.home.open {
t.toolbar
} else if response.hovered() {
t.hover
} else {
t.app_bar
},
);
ui.painter().text(home.center(), Align2::CENTER_CENTER, "Home", FontId::proportional(13.0), if app.home.open { t.text } else { t.app_bar_text });
crate::scenario::publish_handle("tab:home", home.center());
if response.clicked() {
app.home.show(app.session.revision);
}
let x0 = x0 + 70.0;
let info = tabs_info(app);
let n = info.len();
let (first, shown, w) = layout(n, app.docs.active, x1 - x0);
@@ -313,8 +321,9 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
let mut shut: Option<usize> = None;
for (i, (name, dirty)) in info.iter().enumerate().skip(first).take(shown) {
let tr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(w - 2.0, r.height() - 5.0));
let active = i == app.docs.active || app.docs.slots.is_empty();
let active = !app.home.open && (i == app.docs.active || app.docs.slots.is_empty());
let resp = ui.interact(tr, ui.id().with(("doc_tab", i)), Sense::click_and_drag());
crate::scenario::publish_handle(&format!("tab:design:{i}"), tr.center());
// The tabs are part of the title bar: dragging one moves the window, a double click
// maximizes it.
if app.custom_titlebar || app.integrated_titlebar {
@@ -329,7 +338,7 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
} else if resp.hovered() {
Color32::from_white_alpha(28)
} else {
Color32::from_white_alpha(10)
t.app_bar
};
ui.painter().rect_filled(tr, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, fill);
let ink = if active { t.text } else { t.app_bar_text };
@@ -401,7 +410,6 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
ui.painter().line_segment([c - vec2(0.0, 6.0), c + vec2(0.0, 6.0)], s);
if presp.on_hover_text("New Design (Ctrl+N)").clicked() {
new_design(app);
app.home.show(app.session.revision);
}
if let Some(i) = shut {
close(app, i, false);
+1 -1
View File
@@ -255,7 +255,7 @@ pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::I
}
let state: Option<State> = ui.data(|d| d.get_temp(id));
let pointer = ui.input(|i| i.pointer.hover_pos());
let hovered = if state.is_none() { pointer.filter(|p| proj.rect.contains(*p)).and_then(|p| hit(&sh, p)) } else { None };
let hovered = if state.is_none() { pointer.filter(|_| ui.rect_contains_pointer(proj.rect)).and_then(|p| hit(&sh, p)) } else { None };
let active = state.map(|s| s.handle).or(hovered);
let hot = |h: Handle| active == Some(h);
let col = |h: Handle, i: usize| if hot(h) { tk.accent } else { axis_color(i) };
+128 -34
View File
@@ -11,7 +11,7 @@ use egui_wgpu::wgpu::util::DeviceExt;
pub const TRI_SIZE: usize = 28;
/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32).
pub const LINE_SIZE: usize = 32;
const UNIFORM_SIZE: u64 = 192;
const UNIFORM_SIZE: u64 = 208;
/// CPU-side geometry waiting to be uploaded. The model scene changes with the design; the
/// highlight scene (hover, selection, the origin widget) changes often and is small.
@@ -146,6 +146,9 @@ pub struct ViewportCallback {
pub access: Option<AccessMap>,
/// Draw silhouette outlines around the model (visual styles with edges).
pub outline: bool,
/// Eye position and 1 for a perspective camera (0: orthographic), for the section cap's depth
/// and the depth bias.
pub eye: [f32; 4],
}
/// Heights of the model seen from a direction (an orthographic depth map): a point is reachable
@@ -208,6 +211,9 @@ impl Batches {
struct Resources {
tri: wgpu::RenderPipeline,
/// While a section is shown (when the depth buffer has a stencil): `tri` marking where the
/// cap shows, then the cap again at the section plane's depth, so it hides the inside.
tri_cut: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
/// Highlight triangles over the model (depth test ≤, no depth write).
tri_hl: wgpu::RenderPipeline,
trans: wgpu::RenderPipeline,
@@ -274,13 +280,14 @@ const SHADER: &str = r#"
struct U {
vp: mat4x4<f32>,
back: vec4<f32>, // xyz = toward the eye, w = 1 for linear output
screen: vec4<f32>, // viewport width, height (px), depth bias, silhouettes on (1)
screen: vec4<f32>, // viewport width, height (px), depth bias (see `biased_z`), silhouettes on (1)
clip: vec4<f32>, // section plane: normal, d (zero normal: no section)
cap: vec4<f32>, // colour of the inside of cut bodies
ana: vec4<f32>, // surface analysis: mode (1 zebra, 2 draft, 3 curvature), parameter
pull: vec4<f32>, // draft pull direction
acc_u: vec4<f32>, // accessibility map axes (xyz, offset)
acc_v: vec4<f32>,
eye: vec4<f32>, // eye position, w = 1 for perspective (0: orthographic, looking along -back)
};
@group(1) @binding(2) var acc_t: texture_2d<f32>;
@@ -321,6 +328,41 @@ fn vs_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c:
/// Opaque model surfaces: cut by the section plane, the inside shows as a flat cap colour.
@fragment
fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let c = solid(i, front);
return c;
}
struct CutOut {
@location(0) c: vec4<f32>,
@builtin(frag_depth) depth: f32,
};
/// The section cap (the inside seen through the cut, drawn on the far walls' back faces where
/// they are the visible surface) again, at the depth of the section plane rather than of the
/// far wall, so the cut face hides what lies inside the body instead of showing it like a
/// pane of glass.
@fragment
fn fs_solid_cut(i: TOut, @builtin(front_facing) front: bool) -> CutOut {
var o: CutOut;
o.c = solid(i, front);
o.depth = cap_depth(i.wp, i.pos.z);
return o;
}
/// Depth of the section plane on the view ray through `wp` (a far wall seen through the cut),
/// never behind the wall itself (`z`).
fn cap_depth(wp: vec3<f32>, z: f32) -> f32 {
var dir = -u.back.xyz;
if (u.eye.w > 0.5) { dir = wp - u.eye.xyz; }
let dn = dot(u.clip.xyz, dir);
if (abs(dn) < 1e-9) { return z; }
let p = wp + dir * ((u.clip.w - dot(u.clip.xyz, wp)) / dn);
let c = u.vp * vec4<f32>(p, 1.0);
if (c.w <= 0.0) { return z; }
return clamp(c.z / c.w, 0.0, z);
}
fn solid(i: TOut, front: bool) -> vec4<f32> {
// Derivatives first, in uniform control flow (browsers' WGSL compilers reject them after
// a branch that returns; the whole shader module then fails and nothing is drawn).
let k = curvature(i);
@@ -328,9 +370,11 @@ fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f3
let fw = fwidth(ndv);
if (clipped(i.wp)) { discard; }
if (!front && length(u.clip.xyz) > 0.5) {
// The cut face: the cap colour, hatched at 45 degrees on screen.
// The cut face in its body's colour (as Fusion's "from component"), tinted toward the
// cap colour so it reads as a cut, hatched at 45 degrees on screen.
let base = mix(i.c.rgb, u.cap.rgb, 0.35);
let h = fract((i.pos.x + i.pos.y) / 9.0);
return out_color(vec4<f32>(select(u.cap.rgb, u.cap.rgb * 0.62, h < 0.22), u.cap.a));
return out_color(vec4<f32>(select(base, base * 0.6, h < 0.22), 1.0));
}
if (u.ana.x > 0.5 && length(i.n) > 0.5) {
return analysis(i, k);
@@ -440,11 +484,23 @@ fn shade(i: TOut) -> vec4<f32> {
return out_color(vec4<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), i.c.a));
}
/// Clip-space z of `p` (clipped to `c`) pulled `s` depth biases toward the eye (pushed away
/// when negative). Orthographic depth is linear in distance, so the bias is a fixed step of it.
/// Perspective depth is not: near the far plane a fixed step spans tens of millimetres (edges
/// showed through walls), so the point moves toward the eye by a fraction of its distance.
fn biased_z(p: vec3<f32>, c: vec4<f32>, s: f32) -> f32 {
if (u.eye.w > 0.5) {
let q = u.vp * vec4<f32>(mix(p, u.eye.xyz, u.screen.z * 8.0 * s), 1.0);
return q.z / max(q.w, 1e-6) * c.w;
}
return c.z - u.screen.z * s * c.w;
}
@vertex
fn vs_ghost(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec4<f32>) -> TOut {
var o: TOut;
o.pos = u.vp * vec4<f32>(p, 1.0);
o.pos.z = o.pos.z + u.screen.z * 4.0 * o.pos.w;
o.pos.z = biased_z(p, o.pos, -4.0);
o.n = n;
o.c = c;
o.wp = p;
@@ -476,7 +532,7 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(
let base = select(ca, cb, t > 0.5);
let off = nrm * s * w * 0.5 + dir * (t * 2.0 - 1.0) * w * 0.5;
var o: LOut;
o.pos = vec4<f32>(base.xy + off / half * base.w, base.z - u.screen.z * base.w, base.w);
o.pos = vec4<f32>(base.xy + off / half * base.w, biased_z(select(a, b, t > 0.5), base, 1.0), base.w);
o.c = c;
o.wp = select(a, b, t > 0.5);
return o;
@@ -503,7 +559,7 @@ fn vs_img(@location(0) p: vec3<f32>, @location(1) uv: vec2<f32>, @location(2) a:
var o: IOut;
o.pos = u.vp * vec4<f32>(p, 1.0);
// Pulled toward the eye like lines, so a decal on a face is not lost in it.
o.pos.z = o.pos.z - u.screen.z * o.pos.w;
o.pos.z = biased_z(p, o.pos, 1.0);
o.uv = uv;
o.a = a;
o.wp = p;
@@ -697,27 +753,29 @@ impl Resources {
bind_group_layouts: &[Some(&bgl), Some(&access_bgl)],
immediate_size: 0,
});
let solid = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("sc_tris"),
layout: Some(&solid_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_tri"),
buffers: &[Some(tri_layout())],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
depth_stencil: depth(wgpu::CompareFunction::Less, true),
multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false },
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_solid"),
targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: None, write_mask: wgpu::ColorWrites::ALL })],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
});
let solid = |label: &str, fs: &str, cull: Option<wgpu::Face>, ds: Option<wgpu::DepthStencilState>| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&solid_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_tri"),
buffers: &[Some(tri_layout())],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, cull_mode: cull, ..Default::default() },
depth_stencil: ds,
multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false },
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(fs),
targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: None, write_mask: wgpu::ColorWrites::ALL })],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
})
};
let access_bind = access_texture(device, None, &access_bgl, 1, &[]);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("sc_image"),
@@ -731,7 +789,29 @@ impl Resources {
sampler,
textures: Default::default(),
quads: Vec::new(),
tri: solid,
tri: solid("sc_tris", "fs_solid", None, depth(wgpu::CompareFunction::Less, true)),
tri_cut: t.depth.filter(|f| f.has_stencil_aspect()).map(|format| {
// The visible surface marks the stencil: 1 where it is a back face (the cap).
use wgpu::StencilOperation::{Keep, Replace, Zero};
let face = |pass_op| wgpu::StencilFaceState { compare: wgpu::CompareFunction::Always, fail_op: Keep, depth_fail_op: Keep, pass_op };
let mark = wgpu::DepthStencilState {
format,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: wgpu::StencilState { front: face(Zero), back: face(Replace), read_mask: 0xff, write_mask: 0xff },
bias: Default::default(),
};
// Then the cap's back faces again, only there, at the section plane's depth.
let only = wgpu::StencilFaceState { compare: wgpu::CompareFunction::Equal, ..face(Keep) };
let cap = wgpu::DepthStencilState {
format,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: wgpu::StencilState { front: only, back: only, read_mask: 0xff, write_mask: 0 },
bias: Default::default(),
};
(solid("sc_tris_mark", "fs_solid", None, Some(mark)), solid("sc_tris_cap", "fs_solid_cut", Some(wgpu::Face::Front), Some(cap)))
}),
access_bgl,
access_bind,
access_version: 0,
@@ -793,9 +873,10 @@ impl Batches {
}
}
/// Create the pipelines. Call once with the app's render state and its depth/MSAA settings.
pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32) -> GpuTarget {
let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, 0), samples };
/// Create the pipelines. Call once with the app's render state and its depth/stencil/MSAA
/// settings. Without a stencil the section cap does not hide the inside of cut bodies.
pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) -> GpuTarget {
let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, stencil_bits), samples };
let res = Resources::new(&rs.device, t);
rs.renderer.write().callback_resources.insert(res);
t
@@ -899,8 +980,20 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
let (m, h, pv) = (&res.model, &res.highlight, &res.preview);
lines(pass, &res.overlay, &m.under);
pass.set_bind_group(1, &res.access_bind, &[]);
tris(pass, &res.tri, &m.tris);
tris(pass, &res.tri, &pv.tris);
let cut = res.tri_cut.as_ref().filter(|_| self.clip[..3].iter().map(|c| c * c).sum::<f32>() > 0.25);
match cut {
Some((mark, cap)) => {
pass.set_stencil_reference(1);
tris(pass, mark, &m.tris);
tris(pass, mark, &pv.tris);
tris(pass, cap, &m.tris);
tris(pass, cap, &pv.tris);
}
None => {
tris(pass, &res.tri, &m.tris);
tris(pass, &res.tri, &pv.tris);
}
}
tris(pass, &res.tri_hl, &h.tris);
lines(pass, &res.line, &m.lines);
lines(pass, &res.line_hidden, &m.hidden);
@@ -941,6 +1034,7 @@ pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec
}
None => v.extend([0.0; 8]),
}
v.extend(cb.eye);
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
+53 -19
View File
@@ -1,6 +1,6 @@
//! The start page: New Design, Open, the recent designs and the built-in samples, each with a
//! picture of the design rendered from it. Shown on launch without a file and from the house
//! button; it closes when a design is opened or started, or the design changes.
//! button or the first document tab; selecting a design returns to its view.
use std::collections::HashMap;
@@ -24,8 +24,7 @@ pub struct HomeState {
pub open: bool,
/// Recent design paths, newest first (kept in the preferences).
pub recent: Vec<String>,
/// The session revision when the page opened (a change closes it).
rev: u64,
pub opened_at: std::collections::BTreeMap<String, u64>,
thumbs: HashMap<String, Option<egui::TextureHandle>>,
samples: HashMap<usize, Session>,
/// Keys asked for and not made yet.
@@ -37,9 +36,8 @@ pub struct HomeState {
}
impl HomeState {
pub fn show(&mut self, rev: u64) {
pub fn show(&mut self, _rev: u64) {
self.open = true;
self.rev = rev;
}
/// Put a design at the top of the recent list.
@@ -47,6 +45,8 @@ impl HomeState {
self.recent.retain(|p| p != path);
self.recent.insert(0, path.to_string());
self.recent.truncate(MAX_RECENT);
self.opened_at.insert(path.to_string(), web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0));
self.opened_at.retain(|p, _| self.recent.contains(p));
self.thumbs.remove(path);
}
}
@@ -179,9 +179,8 @@ pub fn open_sample(app: &mut SolveApp, i: usize) {
}
}
fn open_recent(app: &mut SolveApp, path: &str) {
pub fn open_recent(app: &mut SolveApp, path: &str) {
if !solvecraft_engine::io::vfs::exists(path) {
app.home.recent.retain(|p| p != path);
app.set_status(format!("{path} is no longer there"), true);
return;
}
@@ -191,10 +190,6 @@ fn open_recent(app: &mut SolveApp, path: &str) {
/// The page itself (in place of the toolbar, browser and viewport).
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.session.revision != app.home.rev {
app.home.open = false;
return;
}
poll(app, ui.ctx());
let t = Tokens::get();
let samples = solvecraft_engine::sample::samples();
@@ -261,6 +256,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
});
ui.add_space(24.0);
ui.horizontal_wrapped(|ui| {
ui.hyperlink_to("Documentation", crate::help::DOCS_URL);
ui.hyperlink_to("Report an Issue", "https://github.com/bherbruck/solvecraft/issues/new");
ui.hyperlink_to("What's new", "https://github.com/bherbruck/solvecraft/releases/latest");
if ui.link("Keyboard Shortcuts").clicked() {
app.keymap.open = true;
app.keymap.read_only = true;
}
if ui.link("Preferences").clicked() {
crate::prefs::open_at(app, 0);
}
});
ui.add_space(16.0);
section(ui, "Recent");
if recent.is_empty() {
ui.label(RichText::new("Designs you open or save appear here.").color(t.text_dim));
@@ -269,20 +277,46 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let Some(p) = recent.get(i) else { return };
let path = std::path::Path::new(p);
let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| p.clone());
let folder = path.parent().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let missing = !solvecraft_engine::io::vfs::exists(p);
let sub = if missing {
let mut sub = if missing {
"missing".to_string()
} else if p.starts_with("/opfs/") {
// The web build keeps designs in the browser's own storage.
"in this browser".to_string()
} else {
folder
p.clone()
};
if card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing)
.on_hover_text(p.as_str())
.clicked()
{
if let Some(time) = app.home.opened_at.get(p) {
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(*time);
let seconds = now.saturating_sub(*time);
let ago = if seconds < 60 {
"just now".to_string()
} else if seconds < 3600 {
format!("{} min ago", seconds / 60)
} else if seconds < 86400 {
format!("{} h ago", seconds / 3600)
} else {
format!("{} days ago", seconds / 86400)
};
sub.push_str(&format!("\nOpened {ago}"));
}
let response = card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing)
.on_hover_text(p.as_str());
crate::scenario::publish_handle(&format!("home:recent:{i}"), response.rect.center());
response.context_menu(|ui| {
if ui.button("Remove from list").clicked() {
app.home.recent.retain(|x| x != p);
app.home.opened_at.remove(p);
ui.close();
}
if !cfg!(target_arch = "wasm32") && ui.button("Show in folder").clicked() {
if let Some(show) = &app.services.show_in_folder {
show(p);
}
ui.close();
}
});
if response.clicked() {
let p = p.clone();
action = Some(Box::new(move |app: &mut SolveApp| open_recent(app, &p)));
}
@@ -340,7 +374,7 @@ enum Pic<'a> {
/// A design card: its picture (a spinner while it renders), name and a line under it.
fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui::Response {
let t = Tokens::get();
let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 62.0);
let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 80.0);
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
let hover = resp.hovered();
ui.painter().rect(
+28 -16
View File
@@ -101,9 +101,6 @@ pub struct UiState {
pub show_grid: bool,
pub show_origin: bool,
pub perspective: bool,
pub hidden_bodies: Vec<String>,
/// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction planes hidden one by one.
pub hidden_origin: Vec<String>,
pub show_sketches: bool,
pub palette_open: bool,
/// Dark theme (the default); a preference kept between runs.
@@ -118,9 +115,6 @@ pub struct UiState {
pub ground_shadow: bool,
/// The world's up axis (Z or Y; #3): the camera, view cube, home view and ground follow it.
pub up_axis: solvecraft_engine::render::UpAxis,
/// Sketches hidden one by one, and finished sketches shown although a feature uses them.
pub hidden_sketches: Vec<u64>,
pub shown_sketches: Vec<u64>,
/// Sketches whose closed profiles are not shaded.
pub hidden_profiles: Vec<u64>,
/// Finished sketches whose dimensions are shown.
@@ -140,8 +134,6 @@ impl Default for UiState {
show_grid: true,
show_origin: true,
perspective: false,
hidden_bodies: Vec::new(),
hidden_origin: Vec::new(),
show_sketches: true,
palette_open: false,
dark: true,
@@ -150,8 +142,6 @@ impl Default for UiState {
visual_style: 0,
ground_shadow: true,
up_axis: Default::default(),
hidden_sketches: Vec::new(),
shown_sketches: Vec::new(),
hidden_profiles: Vec::new(),
shown_dims: Vec::new(),
locked_bodies: Vec::new(),
@@ -165,6 +155,7 @@ impl Default for UiState {
pub struct Services {
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
pub show_in_folder: Option<Box<dyn Fn(&str)>>,
/// Where the host reads the anti-aliasing preference at start (a small JSON file).
pub graphics_path: Option<String>,
}
@@ -294,10 +285,10 @@ impl SolveApp {
}
}
/// Draw the viewport on the GPU (eframe's wgpu render state, with the app's depth buffer
/// bits and MSAA sample count). Without it the viewport renders on the CPU.
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32) {
self.viewport.gpu = Some(gpu::install(rs, depth_bits, samples));
/// Draw the viewport on the GPU (eframe's wgpu render state, with the app's depth and
/// stencil buffer bits and MSAA sample count). Without it the viewport renders on the CPU.
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) {
self.viewport.gpu = Some(gpu::install(rs, depth_bits, stencil_bits, samples));
let info = rs.adapter.get_info();
self.help.gpu = Some(format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type));
}
@@ -314,6 +305,7 @@ impl SolveApp {
"browser_collapsed": self.tree.collapsed,
"browser_expanded": self.tree.expanded,
"recent": self.home.recent,
"recent_opened_at": self.home.opened_at,
"shortcut_box": self.sbox.prefs(),
"shortcuts": self.keymap.prefs(),
"toolbar": self.toolbar_custom.prefs(),
@@ -329,6 +321,7 @@ impl SolveApp {
pub fn load_prefs(&mut self, prefs: &str) {
let Ok(v) = serde_json::from_str::<Value>(prefs) else { return };
let flag = |k: &str| v.get(k).and_then(Value::as_bool);
self.home.opened_at = v.get("recent_opened_at").and_then(|x| serde_json::from_value(x.clone()).ok()).unwrap_or_default();
if let Some(d) = flag("dark") {
self.ui.dark = d;
}
@@ -440,6 +433,12 @@ impl SolveApp {
}
self.tool = None;
self.home.open = false;
if matches!(id, "sketch.insert_dxf" | "sketch.insert_svg") {
if let Some(path) = self.services.pick_open.as_ref().and_then(|f| f()) {
let _ = self.run(id, json!({"path": path}));
}
return;
}
if matches!(id, "file.insert_step" | "file.insert_mesh") {
if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) {
self.insert_path(&p);
@@ -490,7 +489,20 @@ impl SolveApp {
{
self.last_command = Some((spec.id.to_string(), spec.label.to_string()));
}
if let Some(t) = tools::Tool::for_command(id) {
if let Some(mut t) = tools::Tool::for_command(id) {
if id == "sketch.dimension" {
t.picks = self
.session
.selection
.iter()
.filter_map(|s| match s {
solvecraft_engine::Sel::SketchCurve { id } | solvecraft_engine::Sel::SketchPoint { id } => Some(id.clone()),
_ => None,
})
.take(2)
.collect();
let _ = self.session.execute("select.clear", &json!({}));
}
self.tool = Some(t);
self.set_status(tools::hint(id), false);
return;
@@ -594,7 +606,7 @@ impl SolveApp {
pub fn origin_visible(&self) -> bool {
self.ui.show_origin
|| (self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Sketch))
&& !self.session.world_state().bodies.iter().any(|b| !self.ui.hidden_bodies.contains(&b.name)))
&& !self.session.world_state().bodies.iter().any(|b| !self.session.visibility.hidden_bodies.contains(&b.name)))
}
pub fn set_status(&mut self, s: impl Into<String>, error: bool) {
+12 -10
View File
@@ -58,7 +58,7 @@ fn face_menu_hides_and_finds_the_body() {
context_menu::run_item(&mut app, &find(&items, "Find in Timeline"), pos2(0.0, 0.0));
assert!(matches!(app.session.selection.as_slice(), [solvecraft_engine::Sel::Feature { .. }]));
context_menu::run_item(&mut app, &find(&items, "Show/Hide"), pos2(0.0, 0.0));
assert_eq!(app.ui.hidden_bodies, vec![b]);
assert_eq!(app.session.visibility.hidden_bodies, vec![b]);
}
#[test]
@@ -69,8 +69,8 @@ fn body_menu_isolates_renames_locks_and_deletes() {
let t = Target::Body { name: b.clone() };
let items = context_menu::items(&app, &t);
context_menu::run_item(&mut app, &find(&items, "Isolate"), pos2(0.0, 0.0));
assert_eq!(app.ui.hidden_bodies.len(), 1, "the other body is hidden");
assert!(!app.ui.hidden_bodies.contains(&b));
assert_eq!(app.session.visibility.hidden_bodies.len(), 1, "the other body is hidden");
assert!(!app.session.visibility.hidden_bodies.contains(&b));
// Rename through the rename box.
context_menu::run_item(&mut app, &find(&items, "Rename"), pos2(10.0, 10.0));
assert!(context_menu::finish_rename(&mut app, Some("Base"), true));
@@ -184,7 +184,7 @@ fn folder_and_group_menus_make_rename_and_drop_groups() {
assert!(context_menu::finish_rename(&mut app, Some("Parts"), true));
assert_eq!(app.session.doc.browser_groups[0].name, "Parts");
act(&mut app, &t, "Show/Hide");
assert_eq!(app.ui.hidden_bodies.len(), 2);
assert_eq!(app.session.visibility.hidden_bodies.len(), 2);
act(&mut app, &t, "Ungroup");
assert!(app.session.doc.browser_groups.is_empty());
act(&mut app, &folder, "New Group");
@@ -232,8 +232,10 @@ fn delete_key_handles_every_kind_and_asks_about_dependents() {
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc.features.len(), n);
// A face its neighbours can't close (a side of the plate): refused, nothing changes.
let b0 = app.session.model.state().bodies[0].name.clone();
app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": 0, "point": [0, 0, 0]}]})).unwrap();
let body = &app.session.model.state().bodies[0];
let face = body.body.faces(0.01).unwrap().into_iter().find(|f| f.plane_normal.is_some_and(|n| n.x < -0.99)).unwrap();
let b0 = body.name.clone();
app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": face.index, "point": face.centroid}]})).unwrap();
crate::delete::delete_selection(&mut app);
assert!(app.status.as_ref().is_some_and(|s| s.0.contains("Delete Face")), "{:?}", app.status);
assert_eq!(app.session.doc.features.len(), n);
@@ -351,7 +353,7 @@ fn menus_follow_fusion_order() {
let origin = context_menu::items(&app, &Target::Origin);
order(&origin, &["Show/Hide", "Show All", "Hide Planes", "Hide Axes"]);
context_menu::run_item(&mut app, &find(&origin, "Hide Planes"), pos2(0.0, 0.0));
assert!(["XY", "XZ", "YZ"].iter().all(|p| app.ui.hidden_origin.iter().any(|h| h == p)));
assert!(["XY", "XZ", "YZ"].iter().all(|p| app.session.visibility.hidden_origin.iter().any(|h| h == p)));
app.run("select.clear", json!({})).unwrap();
let empty = context_menu::items(&app, &Target::Viewport);
order(&empty, &["Pan", "Zoom", "Orbit", "Show All", "Unisolate", "Extrude", "Fillet"]);
@@ -367,17 +369,17 @@ fn v_toggles_what_is_selected() {
app.run("select.set", json!({"items": [{"type": "body", "name": b}, {"type": "plane", "name": "XY"}, {"type": "feature", "id": sk}]})).unwrap();
crate::browser::set_sketch_visible(&mut app, sk, true);
context_menu::toggle_visibility(&mut app);
assert!(app.ui.hidden_bodies.contains(&b) && app.ui.hidden_origin.iter().any(|h| h == "XY"));
assert!(app.session.visibility.hidden_bodies.contains(&b) && app.session.visibility.hidden_origin.iter().any(|h| h == "XY"));
assert!(!crate::browser::sketch_visible(&app, sk));
context_menu::toggle_visibility(&mut app);
assert!(app.ui.hidden_bodies.is_empty() && app.ui.hidden_origin.is_empty());
assert!(app.session.visibility.hidden_bodies.is_empty() && app.session.visibility.hidden_origin.is_empty());
assert!(crate::browser::sketch_visible(&app, sk));
// Components picked in the browser hide their bodies.
app.run("component.from_bodies", json!({"bodies": [b.clone()]})).unwrap();
app.run("select.clear", json!({})).unwrap();
app.tree.picked_components = vec![app.session.doc.components[0].id];
context_menu::toggle_visibility(&mut app);
assert_eq!(app.ui.hidden_bodies, vec![b]);
assert_eq!(app.session.visibility.hidden_bodies, vec![b]);
assert_eq!(find(&context_menu::items(&app, &Target::Body { name: body(&app) }), "Show/Hide").shortcut, "V");
}
+30 -1
View File
@@ -88,6 +88,8 @@ pub struct PrefsWindow {
pub section: usize,
/// Everything as it was when the dialog opened (or at the last Apply), for Cancel.
snapshot: Option<Snapshot>,
/// The up axis preference the view follows now (applied when it changes, and at startup).
applied_up: Option<String>,
}
/// Open the dialog at a page.
@@ -196,6 +198,10 @@ fn defaults(app: &mut SolveApp, page: usize) {
/// Settings that follow the preferences every frame: the theme when it follows the system, and
/// the interface scale.
pub fn apply(app: &mut SolveApp, ctx: &egui::Context) {
// The view's up axis follows the preference (set in the dialog, by ui.prefs or loaded).
if app.prefs_window.applied_up.as_deref() != Some(app.preferences.up_axis.as_str()) {
up_axis_changed(app);
}
if app.preferences.theme == "system"
&& let Some(t) = ctx.system_theme()
{
@@ -451,7 +457,12 @@ fn close(app: &mut SolveApp, revert: bool) {
/// The default orientation changed: the view turns to match.
fn up_axis_changed(app: &mut SolveApp) {
app.animate_view("home");
use solvecraft_engine::render::UpAxis;
app.prefs_window.applied_up = Some(app.preferences.up_axis.clone());
let up = if app.preferences.up_axis.eq_ignore_ascii_case("y") { UpAxis::Y } else { UpAxis::Z };
if app.ui.up_axis != up || app.cam.up != up {
app.set_up_axis(up);
}
}
/// One page of the dialog.
@@ -822,4 +833,22 @@ mod tests {
let s = crate::viewport::projection(&app, rect).to_screen(pivot).unwrap();
assert!(s.distance(at) < 2.0, "{s:?}");
}
/// The Y up preference turns the view: the camera's up axis follows it, startup included.
#[test]
fn up_axis_preference_turns_the_view() {
use solvecraft_engine::render::UpAxis;
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
let ctx = egui::Context::default();
apply(&mut app, &ctx);
assert_eq!(app.cam.up, UpAxis::Z);
app.preferences.up_axis = "y".into();
apply(&mut app, &ctx);
assert_eq!((app.ui.up_axis, app.cam.up), (UpAxis::Y, UpAxis::Y));
// Home looks at the model with Y up: the eye is above the XZ ground.
assert!(app.cam.back().y > 0.3, "{:?}", app.cam.back());
app.preferences.up_axis = "z".into();
apply(&mut app, &ctx);
assert_eq!(app.cam.up, UpAxis::Z);
}
}
+2 -2
View File
@@ -68,7 +68,7 @@ pub fn gpu_images(app: &SolveApp) -> Vec<GpuImage> {
.iter()
.filter(|c| c.visible && c.opacity > 0.0)
.filter_map(|c| {
let plane = doc.resolve_plane(&vals, &c.plane, 0).ok()?;
let plane = doc.canvas_plane(&vals, c).ok()?;
let (_, version, size, rgba) = pixels(c)?;
let corners = c.world_corners(&plane).map(|p| [p.x as f32, p.y as f32, p.z as f32]);
Some(GpuImage { id: c.id, version, size, rgba, corners, opacity: c.opacity.clamp(0.0, 1.0) as f32 })
@@ -105,7 +105,7 @@ pub fn show(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj)
}
let (vals, _) = doc.param_values();
for c in doc.canvases.iter().filter(|c| c.visible) {
let Ok(plane) = doc.resolve_plane(&vals, &c.plane, 0) else { continue };
let Ok(plane) = doc.canvas_plane(&vals, c) else { continue };
let Some(tex) = texture(ui.ctx(), c) else { continue };
let [bl, br, _, tl] = c.corners();
let (u, v) = (br - bl, tl - bl);
+12 -2
View File
@@ -170,6 +170,8 @@ impl Harness {
/// File pickers answer like a person would: save into the scenario's folder, open the file
/// the scenario chose.
fn install(&mut self) {
// Frames are laid out, never painted.
self.app.viewport.no_pixels = true;
let dir = self.dir.clone();
self.app.services.pick_save = Some(Box::new(move |name, _| Some(dir.join(name).to_string_lossy().into_owned())));
let next = self.next_file.clone();
@@ -243,6 +245,15 @@ impl Harness {
}
pub fn call(&mut self, method: &str, params: Value) -> Value {
if method == "ui.resize"
&& let (Some(w), Some(h)) = (params["width"].as_f64(), params["height"].as_f64())
&& w.is_finite()
&& h.is_finite()
&& w > 0.0
&& h > 0.0
{
self.size = egui::vec2(w as f32, h as f32);
}
let (req, _rx) = ControlRequest::new(method, params);
let out = match handle(&mut self.app, &self.ctx, &req) {
Outcome::Done(v) => v,
@@ -728,8 +739,7 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
}
"hidden" => {
// Bodies hidden in the view.
let h = &ui["ui"]["hidden_bodies"];
let h = if h.is_null() { &ui["ui"]["hiddenBodies"] } else { h };
let h = &ui["visibility"]["hidden_bodies"];
if h != v {
return Err(format!("hidden bodies: got {h}, want {v}"));
}
+12 -3
View File
@@ -8,6 +8,15 @@ use solvecraft_engine::geom::Vec3;
use crate::viewport::Hit;
/// Model picks retain an on-surface point, but their identity is the body and topology index.
pub(crate) fn same_item(a: &Sel, b: &Sel) -> bool {
match (a, b) {
(Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. })
| (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j,
_ => a == b,
}
}
/// What a selection input accepts (bit set).
pub type Accept = u16;
pub const PROFILES: Accept = 1;
@@ -73,11 +82,11 @@ impl SelInput {
return;
}
// A pick of something already in (or a chain fully in) takes it out.
if picked.iter().all(|p| self.items.contains(p)) {
self.items.retain(|x| !picked.contains(x));
if picked.iter().all(|p| self.items.iter().any(|x| same_item(x, p))) {
self.items.retain(|x| !picked.iter().any(|p| same_item(x, p)));
} else {
for p in picked {
if !self.items.contains(&p) {
if !self.items.iter().any(|x| same_item(x, &p)) {
self.items.push(p);
}
}
+1 -1
View File
@@ -1090,7 +1090,7 @@ fn snap_hint(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
/// Per frame, after the viewport is drawn: constraint glyphs and the text-entry box.
pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) {
SHIFT.with(|c| c.set(ui.input(|i| i.modifiers.shift)));
glyphs(app, painter, proj, ui.input(|i| i.pointer.hover_pos()));
glyphs(app, painter, proj, ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect)));
snap_hint(app, painter, proj);
combs(app, painter, proj);
if DEFERRED.with(|d| d.replace(false)) {
-3
View File
@@ -16,7 +16,6 @@ pub fn is_dark() -> bool {
pub struct Tokens {
pub app_bar: Color32,
pub toolbar: Color32,
pub tab_active: Color32,
pub panel: Color32,
pub panel_header: Color32,
pub border: Color32,
@@ -132,7 +131,6 @@ impl Tokens {
Tokens {
app_bar: Color32::from_rgb(52, 56, 64),
toolbar: Color32::from_rgb(246, 247, 249),
tab_active: Color32::from_rgb(255, 255, 255),
panel: Color32::from_rgb(250, 251, 252),
panel_header: Color32::from_rgb(236, 238, 242),
border: Color32::from_rgb(208, 212, 220),
@@ -212,7 +210,6 @@ impl Tokens {
let mut t = Tokens::light();
t.app_bar = Color32::from_rgb(28, 31, 38);
t.toolbar = Color32::from_rgb(37, 41, 49);
t.tab_active = Color32::from_rgb(46, 52, 64);
t.panel = Color32::from_rgb(39, 43, 52);
t.panel_header = Color32::from_rgb(47, 52, 62);
t.border = Color32::from_rgb(63, 69, 82);
+54 -3
View File
@@ -38,7 +38,7 @@ pub fn promoted(tab: &str, panel: &str) -> usize {
pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
egui::Panel::top("sc_appbar").exact_size(34.0).frame(egui::Frame::NONE.fill(t.app_bar)).show(ui, |ui| {
egui::Panel::top("sc_appbar").exact_size(34.0).show_separator_line(false).frame(egui::Frame::NONE.fill(t.app_bar)).show(ui, |ui| {
let r = ui.max_rect();
// The bar is the window's title bar: its empty space moves the window.
if app.custom_titlebar || app.integrated_titlebar {
@@ -54,6 +54,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
let br = Rect::from_center_size(pos2(x, r.center().y), vec2(26.0, 26.0));
x += 30.0;
let resp = ui.interact(br, ui.id().with(("ab", icon)), Sense::click());
crate::scenario::publish_handle(&format!("appbar:{icon}"), br.center());
if resp.hovered() {
ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30));
}
@@ -148,8 +149,11 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
.show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(190.0);
ui.visuals_mut().widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::NONE;
ui.visuals_mut().widgets.hovered.weak_bg_fill = Tokens::get().hover;
let item = |ui: &mut egui::Ui, label: &str, key: &str| {
ui.add(egui::Button::new(label).shortcut_text(key).frame(false).min_size(vec2(180.0, 22.0))).clicked()
ui.add(egui::Button::new(label).shortcut_text(key).min_size(vec2(180.0, 22.0))).clicked()
};
if item(ui, "New Design", "Ctrl+N") {
crate::documents::new_design(app);
@@ -161,6 +165,24 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
}
close = true;
}
ui.menu_button("Open Recent", |ui| {
for path in app.home.recent.clone() {
let missing = !solvecraft_engine::io::vfs::exists(&path);
let label = format!("{path}{}", if missing { " (missing)" } else { "" });
if ui.button(label).clicked() {
crate::home::open_recent(app, &path);
close = true;
ui.close();
}
}
ui.separator();
if ui.button("Clear Recent").clicked() {
app.home.recent.clear();
app.home.opened_at.clear();
close = true;
ui.close();
}
});
if item(ui, "Insert STEP…", "") {
app.start("file.insert_step");
close = true;
@@ -283,7 +305,6 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
let active = app.ui.tab == tab;
let resp = ui.interact(tr, ui.id().with(("tab", tab)), Sense::click());
if active {
painter.rect_filled(tr, egui::CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.tab_active);
let c = if tab == "SKETCH" { t.sketch_accent } else { t.accent };
painter.line_segment([pos2(tr.left() + 4.0, tr.bottom() - 1.0), pos2(tr.right() - 4.0, tr.bottom() - 1.0)], Stroke::new(2.0, c));
} else if resp.hovered() {
@@ -308,11 +329,16 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
let top = r.top() + 26.0;
let mut drop: Option<(&'static str, &'static str, Option<&'static str>)> = None;
let mut menu: Option<(egui::Pos2, &'static str)> = None;
let mut overflow = Vec::new();
for (panel, cmds, promote) in crate::toolbar_custom::panel_lists(&tab) {
let panel: &&str = &panel;
let shown = crate::toolbar_custom::buttons(app, &tab, panel, &cmds, promote);
let n = shown.len().max(1);
let width = (n as f32 * 40.0).max(64.0) + 8.0;
if !overflow.is_empty() || px + width > r.right() - 40.0 {
overflow.push((*panel, cmds));
continue;
}
let enabled_panel = !cmds.is_empty() || *panel == "SELECT";
// Dropped on the panel's free space: the button goes to its end.
let free = Rect::from_min_size(
@@ -433,6 +459,31 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
painter.line_segment([pos2(px, top + 4.0), pos2(px, top + 58.0)], Stroke::new(1.0, t.border));
px += 2.0;
}
if !overflow.is_empty() {
let at = Rect::from_min_size(pos2(px.min(r.right() - 40.0), top + 4.0), vec2(38.0, 52.0));
ui.scope_builder(egui::UiBuilder::new().max_rect(at), |ui| {
let response = ui.menu_button("»", |ui| {
for (panel, cmds) in &overflow {
ui.menu_button(*panel, |ui| {
if *panel == "SELECT" {
selection_filter(app, ui);
}
for c in cmds {
let info = c.info(&app.session);
let response = ui.add_enabled(info.enabled, egui::Button::new(c.label).shortcut_text(c.shortcut.unwrap_or("")));
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), response.rect.center());
if response.clicked() {
app.start(c.id);
ui.close();
}
}
});
}
});
crate::scenario::publish_handle("toolbar:overflow", response.response.rect.center());
response.response.on_hover_text("More toolbar panels");
});
}
painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border));
if let Some((id, panel, before)) = drop {
crate::toolbar_custom::reorder(app, &tab, id, panel, before);
+13 -23
View File
@@ -80,7 +80,7 @@ pub fn hint(id: &str) -> String {
"sketch.circle.center" => "Circle: click the centre, then a point on the circle".into(),
"sketch.arc.three_point" => "Arc: click start, end, then a point on the arc".into(),
"sketch.arc.center_point" => "Arc: click centre, start, then end (counter-clockwise)".into(),
"sketch.dimension" => "Dimension: pick a line, circle or arc (or two points/lines)".into(),
"sketch.dimension" => "Dimension: pick one or two entities, then click to place the value".into(),
"sketch.slot.center_to_center" | "sketch.slot.overall" => "Slot: click both ends, then the width".into(),
_ if id.starts_with("sketch.constraint.") => "Constraint: pick the sketch entities".into(),
_ => crate::sketch_tools::hint(id).unwrap_or_else(|| "Click in the sketch".into()),
@@ -142,31 +142,21 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
Kind::Dimension => {
let hits = pick(app, proj, pos);
let hit = hits.iter().find_map(|h| match h {
Hit::SketchPoint { id, .. } => Some((id.clone(), true)),
Hit::SketchCurve { id, .. } => Some((id.clone(), false)),
Hit::SketchPoint { id, .. } | Hit::SketchCurve { id, .. } => Some(id.clone()),
_ => None,
});
if let Some((id, is_point)) = hit {
tool.picks.push(id.clone());
let st = app.session.world_state();
let is_line = app
.session
.active_sketch
.and_then(|s| st.sketch(s))
.and_then(|ss| ss.sketch.curve_index(&id).and_then(|c| ss.sketch.curves.get(c).cloned()))
.map(|c| matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. }));
let ready = (tool.picks.len() == 1 && !is_point && is_line != Some(true)) || tool.picks.len() >= 2;
let single_line = tool.picks.len() == 1 && is_line == Some(true);
if ready || single_line {
// A single line gets its length right away; a second pick (line or point) replaces it.
if let Ok(v) = app.run("sketch.dimension", json!({"entities": tool.picks}))
&& let Some(p) = v["param"].as_str()
{
// The value is edited in place, selected so typing replaces it.
crate::dim_view::edit_param(app, p);
}
tool.picks.clear();
if let Some(id) = hit {
if !tool.picks.contains(&id) && tool.picks.len() < 2 {
tool.picks.push(id);
}
} else if !tool.picks.is_empty()
&& let Some((at, _)) = sketch_point_at(app, proj, pos)
&& let Ok(v) = app.run("sketch.dimension", json!({"entities": tool.picks, "text_at": [at.x, at.y]}))
{
if let Some(p) = v["param"].as_str() {
crate::dim_view::edit_param(app, p);
}
tool.picks.clear();
}
}
}
+495 -47
View File
@@ -35,6 +35,8 @@ pub struct ViewportState {
hl_slot: SceneSlot,
hl_key: u64,
cpu: Option<(u64, egui::TextureHandle)>,
/// Nothing will look at the frame (the headless scenario harness): skip the software render.
pub no_pixels: bool,
pub nav: Option<NavMode>,
/// What a click would pick now (after the active command's filter).
pub hover: Option<Hit>,
@@ -190,8 +192,9 @@ fn visible_sketches(app: &SolveApp) -> Vec<u64> {
.iter()
.filter(|s| {
app.session.active_sketch == Some(s.feature)
|| (!app.ui.hidden_sketches.contains(&s.feature)
&& (app.ui.shown_sketches.contains(&s.feature) || (app.ui.show_sketches && !sketch_consumed(&app.session, s.feature))))
|| (!app.session.visibility.hidden_sketches.contains(&s.feature)
&& (app.session.visibility.shown_sketches.contains(&s.feature)
|| (app.ui.show_sketches && !sketch_consumed(&app.session, s.feature))))
})
.map(|s| s.feature)
.collect()
@@ -208,9 +211,9 @@ fn scene_key(app: &SolveApp) -> u64 {
app.cam.up.hash(&mut h);
(app.cam.pitch > 0.0).hash(&mut h);
app.ui.show_sketches.hash(&mut h);
app.ui.hidden_bodies.hash(&mut h);
app.ui.hidden_sketches.hash(&mut h);
app.ui.shown_sketches.hash(&mut h);
app.session.visibility.hidden_bodies.hash(&mut h);
app.session.visibility.hidden_sketches.hash(&mut h);
app.session.visibility.shown_sketches.hash(&mut h);
app.ui.hidden_profiles.hash(&mut h);
app.session.active_component.hash(&mut h);
app.preview.replaced.hash(&mut h);
@@ -260,7 +263,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
}
}
for b in &st.bodies {
if app.ui.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) {
if app.session.visibility.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) {
continue;
}
// With a component active, bodies outside it are drawn faded.
@@ -324,7 +327,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
// Section: the outline of each body where the plane cuts it.
if let Some((o, n)) = section_plane(app) {
let col = c4(tk.text.gamma_multiply(0.9));
for b in st.bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(&b.name)) {
for b in st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name)) {
for (a, c) in section_segments(&b.mesh(), o, n) {
sc.line(a.to_f32(), c.to_f32(), col, 1.8, false);
}
@@ -365,7 +368,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc::ModelState) {
// The bodies' box in the camera's Z-up frame (the ground is its XY plane).
let mut b = solvecraft_engine::geom::Aabb3::EMPTY;
for x in st.bodies.iter().filter(|x| !app.ui.hidden_bodies.contains(&x.name)) {
for x in st.bodies.iter().filter(|x| !app.session.visibility.hidden_bodies.contains(&x.name)) {
let bb = x.mesh().bounds();
if bb.is_empty() {
continue;
@@ -482,7 +485,7 @@ pub fn origin_planes(app: &SolveApp) -> Vec<(&'static str, Vec3, [Vec3; 4])> {
let creating = !app.ui.show_origin && app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::Sketch));
origin_quads(origin_size(app.cam.half_height()))
.into_iter()
.filter(|(n, _, _)| creating || !app.ui.hidden_origin.iter().any(|h| h == n))
.filter(|(n, _, _)| creating || !app.session.visibility.hidden_origin.iter().any(|h| h == n))
.collect()
}
@@ -490,7 +493,10 @@ pub fn origin_axes(app: &SolveApp) -> Vec<(&'static str, Vec3)> {
if !app.origin_visible() {
return Vec::new();
}
[("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().filter(|(n, _)| !app.ui.hidden_origin.iter().any(|h| h == n)).collect()
[("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)]
.into_iter()
.filter(|(n, _)| !app.session.visibility.hidden_origin.iter().any(|h| h == n))
.collect()
}
/// Construction axes shown, as segments: across the model where the axis passes it (a little
@@ -501,7 +507,7 @@ pub fn construction_axis_segments(app: &SolveApp) -> Vec<(String, Vec3, Vec3)> {
let bb = solvecraft_engine::view::bounds(&app.session);
solvecraft_engine::view::construction_axes(&app.session)
.into_iter()
.filter(|(_, n, _, _)| !app.ui.hidden_origin.contains(n))
.filter(|(_, n, _, _)| !app.session.visibility.hidden_origin.contains(n))
.map(|(_, name, o, d)| {
let (mut lo, mut hi) = (-h, h);
if !bb.is_empty() {
@@ -525,7 +531,7 @@ pub fn construction_axis_segments(app: &SolveApp) -> Vec<(String, Vec3, Vec3)> {
pub fn construction_points(app: &SolveApp) -> Vec<(String, Vec3)> {
solvecraft_engine::view::construction_points(&app.session)
.into_iter()
.filter(|(_, n, _)| !app.ui.hidden_origin.contains(n))
.filter(|(_, n, _)| !app.session.visibility.hidden_origin.contains(n))
.map(|(_, n, p)| (n, p))
.collect()
}
@@ -535,7 +541,7 @@ pub fn construction_quads(app: &SolveApp) -> Vec<(String, Vec3, [Vec3; 4])> {
let h = origin_size(app.cam.half_height()) * 1.2;
solvecraft_engine::view::construction_planes(&app.session)
.into_iter()
.filter(|(_, n, _)| !app.ui.hidden_origin.contains(n))
.filter(|(_, n, _)| !app.session.visibility.hidden_origin.contains(n))
.map(|(_, name, pl)| {
let (o, x, y) = (pl.origin, pl.x * h, pl.y * h);
(name, pl.normal(), [o - x - y, o + x - y, o + x + y, o - x + y])
@@ -623,7 +629,7 @@ fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
(std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h);
app.session.active_sketch.hash(&mut h);
app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).hash(&mut h);
app.ui.hidden_bodies.hash(&mut h);
app.session.visibility.hidden_bodies.hash(&mut h);
app.ui.perspective.hash(&mut h);
(proj.rect.width().to_bits(), proj.rect.height().to_bits()).hash(&mut h);
let key = h.finish();
@@ -699,7 +705,7 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
// Nearest face along the ray (hides what is behind it).
let mut bestf: Option<(f64, Hit)> = None;
for b in &st.bodies {
if app.ui.hidden_bodies.contains(&b.name) {
if app.session.visibility.hidden_bodies.contains(&b.name) {
continue;
}
// Bodies whose box the ray misses (or enters behind the best hit so far) are skipped.
@@ -725,7 +731,7 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
let mut bestv: Option<(f32, Hit)> = None;
let mut beste: Option<(f32, Hit)> = None;
for b in &st.bodies {
if app.ui.hidden_bodies.contains(&b.name) {
if app.session.visibility.hidden_bodies.contains(&b.name) {
continue;
}
// Only bodies whose outline on screen is near the cursor can have an edge under it.
@@ -1073,8 +1079,8 @@ fn highlight_key(app: &SolveApp) -> u64 {
crate::theme::is_dark().hash(&mut h);
app.preview.replaced.hash(&mut h);
app.origin_visible().hash(&mut h);
app.ui.hidden_origin.hash(&mut h);
app.ui.hidden_bodies.hash(&mut h);
app.session.visibility.hidden_origin.hash(&mut h);
app.session.visibility.hidden_bodies.hash(&mut h);
app.cam.half_height().to_bits().hash(&mut h);
app.cam.back().x.to_bits().hash(&mut h);
h.finish()
@@ -1085,7 +1091,14 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
let t = Tokens::get();
let mut sc = GpuScene::default();
let st = app.session.world_state();
let sel = app.highlighted();
let mut sel = Vec::new();
for item in app.highlighted() {
for piece in grouped_selection(&app.session, &item) {
if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) {
sel.push(piece);
}
}
}
let hover = app.viewport.hover.as_ref();
let size = origin_size(app.cam.half_height());
// Origin planes and construction planes.
@@ -1157,7 +1170,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
}
Sel::Body { name } => {
if let Some(b) = st.body(name)
&& !app.ui.hidden_bodies.contains(name)
&& !app.session.visibility.hidden_bodies.contains(name)
{
let m = b.mesh();
for f in 0..b.body.face_count() {
@@ -1203,7 +1216,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
let k = t.hover_face_lift;
let lit =
|b: &solvecraft_engine::doc::ModelBody| Some(b.feature) == app.viewport.hover_feature || app.viewport.hover_bodies.contains(&b.name);
for b in st.bodies.iter().filter(|b| lit(b) && !app.ui.hidden_bodies.contains(&b.name)) {
for b in st.bodies.iter().filter(|b| lit(b) && !app.session.visibility.hidden_bodies.contains(&b.name)) {
let c = body_rgb(app, &b.name);
let m = b.mesh();
for f in 0..b.body.face_count() {
@@ -1236,9 +1249,12 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
if let Some(b) = st.body(body) {
let k = t.hover_face_lift;
let c = body_rgb(app, body);
face_tris(&mut sc, &b.mesh(), *index, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
let m = b.mesh();
for e in m.face_edges(u32::try_from(*index).unwrap_or(u32::MAX)) {
let patch = m.surface_patch(*index);
for &f in &patch {
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
}
for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) {
if let Some(p) = m.edges.get(e)
&& !m.seams.get(e).copied().unwrap_or(false)
{
@@ -1248,12 +1264,13 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
}
}
Some(Hit::Edge { body, index, mid }) => {
if let Some(b) = st.body(body)
&& let Some(e) = edge_of(&b.mesh(), *index, *mid)
{
let selected = sel.iter().any(|x| matches!(x, Sel::Edge { point, .. } if point.dist(*mid) < 1e-9));
if !selected {
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 2.0);
if let Some(b) = st.body(body) {
let m = b.mesh();
for i in m.circular_rim(*index) {
let selected = sel.iter().any(|x| matches!(x, Sel::Edge { body: b, index: j, .. } if b == body && *j == i));
if !selected && let Some(e) = if i == *index { edge_of(&m, i, *mid) } else { m.edges.get(i) } {
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 2.0);
}
}
}
}
@@ -1280,7 +1297,7 @@ fn body_rgb(app: &SolveApp, name: &str) -> (u8, u8, u8) {
(c.r(), c.g(), c.b())
}
fn polyline_mid(pts: &[Vec3]) -> Vec3 {
pub(crate) fn polyline_mid(pts: &[Vec3]) -> Vec3 {
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
let mut acc = 0.0;
for w in pts.windows(2) {
@@ -1354,7 +1371,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let secondary_down = secondary_down && app.viewport.context_menu.is_none();
// The view cube handles its own clicks; the model underneath must not see them.
let cube = Rect::from_center_size(pos2(rect.right() - 80.0, rect.top() + 80.0), vec2(150.0, 150.0));
let inside = hover.is_some_and(|p| rect.contains(p) && !cube.contains(p));
// Floating windows and menus own input over the model, including wheel navigation.
let inside = resp.contains_pointer() && hover.is_some_and(|p| !cube.contains(p));
app.viewport.mouse = hover.filter(|_| inside);
// Ctrl or Alt held: drawing and dragging don't snap.
crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command);
@@ -1427,6 +1445,14 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
let proj = projection(app, rect);
// Suppress hover for the whole navigation gesture, including frames between pointer
// events. Testing delta made the highlight reappear whenever a held drag paused.
// Resolve it before rendering so the GPU scene and screen overlays use the same hit.
let navigating =
scroll != 0.0 || middle || secondary_down || (app.viewport.nav.is_some() && primary_down) || app.viewport.spin || app.cam_anim.is_some();
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default();
let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) };
app.viewport.hover = cand.as_ref().map(|c| c.0.clone());
let painter = ui.painter_at(rect);
// Background gradient.
let (top, bot) = (t.viewport_top, t.viewport_bottom);
@@ -1476,6 +1502,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
analysis: analysis_uniform(app),
access: crate::access::map(app),
outline: matches!(app.ui.visual_style, 0 | 3),
eye: {
let e = proj.cam.eye().to_f32();
[e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }]
},
};
painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb));
} else {
@@ -1483,11 +1513,6 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
// ---- interaction ----
// No picking while the view moves (orbit, pan, zoom): nothing can be hovered meanwhile.
let navigating = scroll != 0.0 || ((middle || secondary_down) && delta != egui::Vec2::ZERO) || (app.viewport.nav.is_some() && primary_down);
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default();
let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) };
app.viewport.hover = cand.as_ref().map(|c| c.0.clone());
let add = mods.shift || mods.command || mods.ctrl;
if let Some(p) = hover.filter(|_| inside) {
if app.tool.is_some() {
@@ -1672,6 +1697,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
app.viewport.boxsel = None;
}
crate::ref_images::show(app, ui, &painter, &proj);
crate::dialogs_assembly::origins(app, &painter, &proj);
overlays(app, &painter, &proj);
crate::dim_view::show(app, ui, &painter, &proj);
crate::sketch3d::show(app, &painter, &proj);
@@ -1719,12 +1745,13 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
match sel {
Some(x) => {
let item = serde_json::to_value(&x).unwrap_or_default();
if add && app.session.selection.contains(&x) {
let rest: Vec<Sel> = app.session.selection.iter().filter(|y| **y != x).cloned().collect();
let items = grouped_selection(&app.session, &x);
if add && items.iter().all(|x| app.session.selection.iter().any(|y| crate::selection::same_item(x, y))) {
let rest: Vec<Sel> =
app.session.selection.iter().filter(|y| !items.iter().any(|x| crate::selection::same_item(x, y))).cloned().collect();
let _ = app.run("select.set", json!({ "items": rest }));
} else {
let _ = app.run("select.set", json!({"items": [item], "add": add}));
let _ = app.run("select.set", json!({"items": items, "add": add}));
}
}
None if !add => {
@@ -1734,6 +1761,75 @@ fn select(app: &mut SolveApp, sel: Option<Sel>, add: bool) {
}
}
/// Expand a visible rim or surface into the kernel pieces a command needs. The original
/// face pick keeps its point (used by tools such as Hole and construction geometry).
pub(crate) fn grouped_selection(s: &solvecraft_engine::Session, sel: &Sel) -> Vec<Sel> {
let st = s.world_state();
match sel {
Sel::Edge { body, index, .. } => {
if let Some(b) = st.body(body) {
let m = b.mesh();
return m
.circular_rim(*index)
.into_iter()
.filter_map(|i| m.edges.get(i).map(|e| Sel::Edge { body: body.clone(), index: i, point: polyline_mid(e) }))
.collect();
}
}
Sel::Face { body, index, .. } => {
if let Some(b) = st.body(body) {
let m = b.mesh();
let mut patch = m.surface_patch(*index);
patch.sort_by_key(|i| usize::from(i != index));
return patch
.into_iter()
.filter_map(|i| {
if i == *index {
return Some(sel.clone());
}
m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| {
if *f as usize != i {
return None;
}
let [a, b, c] = m.tri(t)?;
Some(Sel::Face { body: body.clone(), index: i, point: (a + b + c) / 3.0 })
})
})
.collect();
}
}
_ => {}
}
vec![sel.clone()]
}
/// Measure keeps one entry per visible item, rather than using its two slots for kernel halves.
pub(crate) fn measurement_selection(s: &solvecraft_engine::Session, sel: &Sel) -> Sel {
let pieces = grouped_selection(s, sel);
let mut representative = sel.clone();
match &mut representative {
Sel::Edge { index, .. } => {
if let Some(Sel::Edge { index: i, .. }) = pieces.first() {
*index = *i;
}
}
Sel::Face { index, .. } => {
if let Some(i) = pieces
.iter()
.filter_map(|p| match p {
Sel::Face { index, .. } => Some(*index),
_ => None,
})
.min()
{
*index = i;
}
}
_ => {}
}
representative
}
/// What a box selects: sketch entities while sketching, otherwise what the open dialog's
/// active input takes (edges, faces or bodies), otherwise bodies.
fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
@@ -1777,13 +1873,15 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
} else {
let accept = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).unwrap_or(BODIES);
for b in &st.bodies {
if app.ui.hidden_bodies.contains(&b.name) {
if app.session.visibility.hidden_bodies.contains(&b.name) {
continue;
}
let m = b.mesh();
if accept & EDGES != 0 {
for (ei, e) in m.edges.iter().enumerate() {
if !m.seams.get(ei).copied().unwrap_or(false) && bx.takes(&to2(e)) {
let rim = m.circular_rim(ei);
let takes = if bx.crossing { bx.takes(&to2(e)) } else { rim.iter().all(|&i| m.edges.get(i).is_some_and(|p| bx.takes(&to2(p)))) };
if !m.seams.get(ei).copied().unwrap_or(false) && takes {
out.push(Sel::Edge { body: b.name.clone(), index: ei, point: polyline_mid(e) });
}
}
@@ -1800,8 +1898,15 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
.find(|(_, tf)| **tf as usize == f)
.and_then(|(t, _)| m.tri(t))
.map(|[a, bb, c]| (a + bb + c) / 3.0);
let takes = bx.takes(&to2(&pts))
&& (bx.crossing
|| m.surface_patch(f).iter().all(|&face| {
m.face_edges(u32::try_from(face).unwrap_or(u32::MAX))
.iter()
.all(|&e| m.edges.get(e).is_some_and(|p| bx.takes(&to2(p))))
}));
if let Some(point) = point
&& bx.takes(&to2(&pts))
&& takes
{
out.push(Sel::Face { body: b.name.clone(), index: f, point });
}
@@ -1826,8 +1931,17 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
}
}
}
let mut grouped = Vec::new();
for item in out {
for piece in grouped_selection(&app.session, &item) {
if !grouped.iter().any(|x| crate::selection::same_item(x, &piece)) {
grouped.push(piece);
}
}
}
let out = grouped;
if let Some(mut d) = app.dialog.take() {
d.take_box(out, add);
d.take_box(&app.session, out, add);
app.dialog = Some(d);
return;
}
@@ -1839,7 +1953,7 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
fn points_2d(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
let t = Tokens::get();
if app.origin_visible()
&& !app.ui.hidden_origin.iter().any(|h| h == "O")
&& !app.session.visibility.hidden_origin.iter().any(|h| h == "O")
&& let Some(o) = proj.to_screen(Vec3::ZERO)
{
painter.circle(o, 4.5, t.origin_point, Stroke::new(1.0, Color32::from_gray(110)));
@@ -1927,6 +2041,9 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
}
fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, rect: Rect, proj: &Proj) {
if app.viewport.no_pixels {
return;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
scene_key(app).hash(&mut h);
for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] {
@@ -1973,7 +2090,7 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let (r, u, b) = cube_basis(&app.cam);
let (wr, wu, _) = app.cam.basis();
let painter = ui.painter_at(rect);
let hover = ui.input(|i| i.pointer.hover_pos());
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect));
let targets = cube_targets();
let shapes = cube_projected(&targets, (r, u, b), c);
let hovered = hover.and_then(|h| cube_hit(&shapes, h));
@@ -2290,6 +2407,337 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
mod tests {
use super::*;
fn hover_frame(app: &mut SolveApp, ctx: &egui::Context, events: Vec<egui::Event>) {
let selected = app.highlighted();
let _ = ctx.run_ui(
egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() },
|ui| {
show(app, ui);
},
);
// Click selection is applied after rendering; hover itself must already be current.
if app.viewport.gpu.is_some() && selected == app.highlighted() {
assert_eq!(app.viewport.hl_key, highlight_key(app), "GPU highlight must use this frame's hover");
}
}
fn hover_app(gpu: bool) -> SolveApp {
let mut s = solvecraft_engine::Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.point", &json!({"point": [0, 0]})).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
app.viewport.no_pixels = true;
if gpu {
// The callback is queued by egui; no device is needed to inspect scene updates.
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1 });
}
app
}
#[test]
fn hover_stays_off_between_navigation_events_and_returns_on_release() {
for gpu in [false, true] {
for button in [egui::PointerButton::Middle, egui::PointerButton::Secondary, egui::PointerButton::Primary] {
let ctx = egui::Context::default();
let mut app = hover_app(gpu);
if button == egui::PointerButton::Primary {
app.viewport.nav = Some(NavMode::Orbit);
}
hover_frame(&mut app, &ctx, vec![]);
let p = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
assert!(app.viewport.hover.is_some());
hover_frame(&mut app, &ctx, vec![egui::Event::PointerButton { pos: p, button, pressed: true, modifiers: Default::default() }]);
assert!(app.viewport.hover.is_none(), "hover on navigation press");
for offset in [1.0, 2.0, 3.0] {
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(offset, 0.0))]);
assert!(app.viewport.hover.is_none(), "hover while moving");
for _ in 0..3 {
hover_frame(&mut app, &ctx, vec![]);
assert!(app.viewport.hover.is_none(), "hover between movement events");
}
}
hover_frame(&mut app, &ctx, vec![egui::Event::PointerButton { pos: p, button, pressed: false, modifiers: Default::default() }]);
assert!(app.viewport.hover.is_some(), "hover must return on release");
}
}
}
#[test]
fn hover_stays_off_during_spin_and_animated_views() {
let ctx = egui::Context::default();
let mut app = hover_app(true);
hover_frame(&mut app, &ctx, vec![]);
let p = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
assert!(app.viewport.hover.is_some());
app.viewport.spin = true;
for _ in 0..3 {
hover_frame(&mut app, &ctx, vec![]);
assert!(app.viewport.hover.is_none());
}
app.viewport.spin = false;
hover_frame(&mut app, &ctx, vec![]);
assert!(app.viewport.hover.is_some());
app.cam_anim = Some(solvecraft_engine::render::CameraAnim::new(app.cam, app.cam, 0.0));
for _ in 0..3 {
hover_frame(&mut app, &ctx, vec![]);
assert!(app.viewport.hover.is_none());
}
app.cam_anim = None;
hover_frame(&mut app, &ctx, vec![]);
assert!(app.viewport.hover.is_some());
}
fn cylinder_app() -> SolveApp {
let mut s = solvecraft_engine::Session::default();
s.execute("solid.cylinder", &json!({"radius": 10, "height": 20})).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
app.ui.show_origin = false;
app
}
#[test]
fn circular_items_measure_as_whole_rims_and_surfaces() {
let mut app = cylinder_app();
let st = app.session.world_state();
let b = &st.bodies[0];
let m = b.mesh();
let edges: Vec<Sel> = m
.edges
.iter()
.enumerate()
.filter(|(_, p)| p.len() > 3 && p.iter().all(|q| (q.z - 20.0).abs() < 1e-6))
.map(|(index, p)| Sel::Edge { body: b.name.clone(), index, point: polyline_mid(p) })
.collect();
let result = app.session.measure_items(&edges);
assert_eq!(result["items"].as_array().unwrap().len(), 1);
assert!((result["items"][0]["length_mm"].as_f64().unwrap() - 20.0 * std::f64::consts::PI).abs() < 0.2, "{result}");
app.session.selection = edges;
let d = crate::dialogs::Dialog::for_command(&app, "inspect.measure").unwrap();
assert_eq!(d.items().len(), 1);
let faces = m.edge_faces.iter().zip(&m.seams).find(|(_, seam)| **seam).unwrap().0;
let wall = Sel::Face { body: b.name.clone(), index: faces[0] as usize, point: Vec3::new(10.0, 0.0, 10.0) };
let wall_pieces = grouped_selection(&app.session, &wall);
let result = app.session.measure_items(&wall_pieces);
assert_eq!(result["items"].as_array().unwrap().len(), 1);
assert!((result["items"][0]["area_mm2"].as_f64().unwrap() - 400.0 * std::f64::consts::PI).abs() < 10.0, "{result}");
let mut d = crate::dialogs::Dialog::for_command(&app, "inspect.measure").unwrap();
d.pick(&app.session, wall.clone());
assert_eq!(d.items().len(), 2, "a rim and a wall are two measurement targets");
d.pick(&app.session, Sel::Face { body: b.name.clone(), index: faces[1] as usize, point: Vec3::new(-10.0, 0.0, 10.0) });
assert_eq!(d.items().len(), 1, "the other half toggles the same wall");
}
#[test]
fn circular_rim_hover_click_and_dialog_pick_include_both_halves() {
let mut app = cylinder_app();
let st = app.session.world_state();
let b = &st.bodies[0];
let m = b.mesh();
let top = m.edges.iter().position(|e| e.len() > 3 && e.iter().all(|p| (p.z - 20.0).abs() < 1e-6)).unwrap();
let rim = m.circular_rim(top);
assert_eq!(rim.len(), 2);
let line_count: usize = rim.iter().map(|&i| m.edges[i].len() - 1).sum();
for &i in &rim {
let mid = polyline_mid(&m.edges[i]);
app.viewport.hover = Some(Hit::Edge { body: b.name.clone(), index: i, mid });
assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap();
select(&mut app, Some(item.clone()), false);
assert_eq!(app.session.selection.len(), 2);
// Selecting both pieces must not draw each highlight twice.
assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE);
// Ctrl/Shift clicking either half deselects the entire rim.
select(&mut app, Some(item.clone()), true);
assert!(app.session.selection.is_empty());
let mut d = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap();
if let crate::dialogs::Kind::Fillet { chain, .. } = &mut d.kind {
*chain = false;
}
d.pick(&app.session, item.clone());
assert_eq!(d.items().len(), 2);
d.pick(&app.session, item);
assert!(d.items().is_empty());
}
// Screen-space picking works from either side of the rim as the model turns.
for &i in &rim {
let mid = polyline_mid(&m.edges[i]);
app.cam = app.cam.looking_from(Vec3::new(mid.x, mid.y, 20.0));
app.cam.target = Vec3::new(0.0, 0.0, 10.0);
for perspective in [false, true] {
app.ui.perspective = perspective;
let proj = projection(&app, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)));
let hits = pick(&app, &proj, proj.to_screen(mid).unwrap());
let (_, sel) = candidate(&app, &hits).unwrap();
assert!(matches!(sel, Sel::Edge { .. }), "{hits:?}");
assert_eq!(grouped_selection(&app.session, &sel).len(), 2);
}
}
}
#[test]
fn curved_face_hover_selection_and_dialogs_cross_the_seam() {
let mut app = cylinder_app();
let st = app.session.world_state();
let b = &st.bodies[0];
let m = b.mesh();
let faces = m.edge_faces.iter().zip(&m.seams).find(|(_, seam)| **seam).unwrap().0;
assert_eq!(faces.len(), 2);
let triangle_count = m.tri_face.iter().filter(|f| faces.contains(f)).count();
for &f in faces {
let index = f as usize;
let t = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf == f).unwrap().0;
let [a, c, d] = m.tri(t).unwrap();
let point = (a + c + d) / 3.0;
app.viewport.hover = Some(Hit::Face { body: b.name.clone(), index, point });
assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE);
let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap();
select(&mut app, Some(item.clone()), false);
assert_eq!(app.session.selection.len(), 2);
assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE);
assert_eq!(app.session.selection.first(), Some(&item), "keep the original tool placement point first");
let mut dialog = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap();
assert_eq!(dialog.items().len(), 2);
dialog.pick(&app.session, item.clone());
assert!(dialog.items().is_empty());
dialog.pick(&app.session, item);
assert_eq!(dialog.items().len(), 2);
// A point elsewhere on the opposite half still toggles the whole surface off.
let other = faces.iter().find(|&&x| x != f).unwrap();
select(&mut app, Some(Sel::Face { body: b.name.clone(), index: *other as usize, point: m.positions[0] }), true);
assert!(app.session.selection.is_empty());
}
// Planar caps and box corners stay separate.
let cap = (0..b.body.face_count()).find(|&i| m.surface_patch(i).len() == 1).unwrap();
assert_eq!(m.surface_patch(cap), vec![cap]);
app.session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
let st = app.session.world_state();
let box_body = st.bodies.last().unwrap();
for i in 0..box_body.body.face_count() {
assert_eq!(box_body.mesh().surface_patch(i), vec![i]);
}
}
#[test]
fn shortcuts_window_wheel_does_not_zoom_the_viewport() {
let ctx = egui::Context::default();
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
app.viewport.no_pixels = true;
crate::help::run(&mut app, &ctx, "shortcuts");
let mut time = 0.0;
let mut frame = |app: &mut SolveApp, pointer: Option<Pos2>, wheel: f32| {
time += 1.0 / 60.0;
let mut events = Vec::new();
if let Some(p) = pointer {
events.push(egui::Event::PointerMoved(p));
}
if wheel != 0.0 {
events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, wheel),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
}
let _ = ctx.run_ui(
egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))),
time: Some(time),
events,
..Default::default()
},
|ui| {
egui::CentralPanel::default().show(ui, |ui| show(app, ui));
crate::prefs::show(app, ui.ctx());
},
);
};
// Let egui establish the floating window's size and hit-test widgets.
for _ in 0..4 {
frame(&mut app, None, 0.0);
}
let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
let list = window.center() + vec2(140.0, 0.0);
let height = app.cam.half_height();
let target = app.cam.target;
frame(&mut app, Some(list), 0.0);
for n in 0..12 {
frame(&mut app, Some(list), if n == 0 { -120.0 } else { 0.0 });
assert_eq!(app.cam.half_height(), height, "wheel input over shortcuts must stay in the popup");
assert_eq!(app.cam.target, target);
}
// The window's non-scrolling sidebar must also shield the model.
frame(&mut app, Some(window.left_center() + vec2(30.0, 0.0)), -120.0);
assert_eq!(app.cam.half_height(), height);
// An exposed part of the viewport still accepts wheel navigation with the popup open.
let exposed = pos2(40.0, 700.0);
assert!(!window.contains(exposed));
frame(&mut app, Some(exposed), 0.0);
frame(&mut app, Some(exposed), -120.0);
assert_ne!(app.cam.half_height(), height);
// Closing the popup returns its former area to the viewport.
app.prefs_window.open = false;
for _ in 0..12 {
frame(&mut app, Some(list), 0.0);
}
let height = app.cam.half_height();
frame(&mut app, Some(list), -120.0);
assert_ne!(app.cam.half_height(), height);
}
#[test]
fn floating_dialog_blocks_viewport_hover_and_clicks() {
let mut h = crate::scenario::Harness::new();
h.call("engine.execute", json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}));
h.call("ui.view", json!({"view": "fit"}));
h.app.ui.pick_bodies = true;
let p = h.app.viewport.rect.unwrap().center();
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert!(h.app.viewport.hover.is_some(), "the test point must hover over the model");
h.call("ui.click", json!({"x": p.x, "y": p.y}));
assert!(!h.app.session.selection.is_empty(), "the uncovered model must be selectable");
h.app.session.selection.clear();
h.call("ui.prefs", json!({"open": "Navigation"}));
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
assert!(window.contains(p), "the dialog must cover the model at the test point");
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert!(h.app.viewport.hover.is_none(), "the dialog must block model highlights");
assert!(h.app.viewport.mouse.is_none());
h.call("ui.click", json!({"x": p.x, "y": p.y}));
assert!(h.app.session.selection.is_empty(), "clicks in the dialog must not select the model");
h.call("ui.prefs", json!({"button": "cancel"}));
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert!(h.app.viewport.hover.is_some(), "closing the dialog must restore model highlights");
h.call("ui.click", json!({"x": p.x, "y": p.y}));
assert!(!h.app.session.selection.is_empty());
}
#[test]
fn floating_dialog_blocks_view_cube_highlights_and_clicks() {
let mut h = crate::scenario::Harness::new();
h.call("ui.resize", json!({"width": 800, "height": 700}));
let p = crate::scenario::handle_at("cube:top").unwrap();
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0));
h.call("ui.prefs", json!({"open": "Navigation"}));
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
assert!(window.contains(p), "the floating dialog must cover the cube target");
let camera = serde_json::to_value(h.app.cam).unwrap();
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(0.0));
h.call("ui.click", json!({"x": p.x, "y": p.y}));
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the view cube");
h.call("ui.prefs", json!({"button": "cancel"}));
h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0));
h.call("ui.click", json!({"x": p.x, "y": p.y}));
assert_ne!(serde_json::to_value(h.app.cam).unwrap(), camera);
}
#[test]
fn section_outlines_follow_the_cut() {
let mut s = solvecraft_engine::Session::default();
@@ -2447,7 +2895,7 @@ mod tests {
// It is an axis for dialogs (pattern directions, revolve axes), and hiding it hides it.
let (o, dir) = crate::dialogs::axis_of(&app, &Sel::Axis { name: name.clone() }).unwrap();
assert!(o.dist(Vec3::new(0.0, 0.0, 20.0)) < 1e-6 && dir.dist(d) < 1e-6);
app.ui.hidden_origin.push(name);
app.session.visibility.hidden_origin.push(name);
assert!(construction_axis_segments(&app).is_empty());
}
}
+95
View File
@@ -31,3 +31,98 @@ fn adhoc() {
panic!("{path}: {e}");
}
}
#[test]
fn angle_dimension_both_interactive_workflows() {
use serde_json::json;
for preselect in [false, true] {
let mut h = Harness::new();
h.run(&json!([
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,0],[40,0]]}}},
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,0],[30,30]]}}},
{"call":"ui.view","params":{"view":"top"}}
]))
.unwrap();
let picks = json!([
{"click":{"sketch":[20,0]}},
{"click":{"sketch":[15,15]},"shift":true}
]);
if preselect {
h.run(&picks).unwrap();
h.run(&json!([{ "key":"D" }])).unwrap();
} else {
h.run(&json!([{ "key":"D" }])).unwrap();
h.run(&picks).unwrap();
}
h.run(&json!([
{"click":{"sketch":[20,8]}},
{"text":"45"}, {"key":"Enter"},
{"expect":{"errors":0,"param":{"d1":std::f64::consts::FRAC_PI_4}}}
]))
.unwrap();
let sk = &h.app.session.model.state().sketches[0].sketch;
assert!(sk.constraints.iter().any(|c| matches!(c.kind, solvecraft_engine::sketch::ConstraintKind::Angle { .. })));
}
}
#[test]
fn home_tab_and_file_recent_open_designs() {
use serde_json::json;
let mut h = Harness::new();
let path = h.dir.join("recent.solvecraft").to_string_lossy().into_owned();
h.run(&json!([
{"call":"engine.execute","params":{"command":"solid.box","params":{"length":10,"width":10,"height":10}}},
{"call":"engine.execute","params":{"command":"file.save_as","params":{"path":path}}},
{"click":{"handle":"tab:home"}},
{"expect":{"home":true,"documents":1}},
{"click":{"handle":"tab:design:0"}},
{"expect":{"home":false,"bodies":1}},
{"click":{"handle":"tab:home"}},
{"click":{"handle":"home:recent:0"}},
{"expect":{"home":false,"bodies":1,"documents":2}},
{"click":{"handle":"appbar:folder"}},
{"widget":"Open Recent"}
]))
.unwrap();
h.run(&json!([
{"widget":path},
{"expect":{"home":false,"bodies":1,"documents":3}}
]))
.unwrap();
}
#[test]
fn sketch_toolbar_imports_use_file_picker() {
use serde_json::json;
let mut h = Harness::new();
let path = h.dir.join("drawing.svg");
std::fs::write(
&path,
r#"<svg xmlns="http://www.w3.org/2000/svg" width="40mm" height="20mm" viewBox="0 0 40 20"><path d="M 0 0 L 40 0 L 40 20 L 0 20 Z"/></svg>"#,
)
.unwrap();
h.run(&json!([
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"choose_file":path},
{"start":"sketch.insert_svg"},
{"expect":{"errors":0,"sketch_curves":{"own":4}}}
]))
.unwrap();
}
#[test]
fn narrow_toolbar_reaches_collapsed_commands() {
use serde_json::json;
let mut h = Harness::new();
h.run(&json!([
{"call":"ui.resize","params":{"width":640,"height":800}},
{"expect":{"shown_handles":["toolbar:overflow"]}},
{"click":{"handle":"toolbar:overflow"}},
{"widget":"CONSTRUCT"},
{"expect":{"shown_handles":["toolbar:construct.plane.offset"]}},
{"click":{"handle":"toolbar:construct.plane.offset"}},
{"expect":{"dialog":"OffsetPlane"}}
]))
.unwrap();
}
@@ -1,7 +1,4 @@
[
{
"pending": "#40: offsets of projected geometry get new ids on recompute, the lid's lip fails"
},
{
"note": "Acceptance (#27): the board grows 60 \u2192 70 mm; the lid's lip (offsets of the case's projected rim) follows."
},
@@ -87,6 +87,70 @@
}
}
},
{
"call": "engine.execute",
"params": {
"command": "parameters.change",
"params": {
"name": "wall",
"expression": "2.4 mm"
}
}
},
{
"expect": {
"errors": 0,
"bodies": 2
}
},
{
"call": "engine.execute",
"params": {
"command": "parameters.change",
"params": {
"name": "height",
"expression": "30 mm"
}
}
},
{
"expect": {
"errors": 0,
"bodies": 2
}
},
{
"call": "engine.execute",
"params": {
"command": "parameters.change",
"params": {
"name": "height",
"expression": "25 mm"
}
}
},
{
"expect": {
"errors": 0,
"bodies": 2
}
},
{
"call": "engine.execute",
"params": {
"command": "parameters.change",
"params": {
"name": "wall",
"expression": "2 mm"
}
}
},
{
"expect": {
"errors": 0,
"bodies": 2
}
},
{
"call": "engine.execute",
"params": {
@@ -1,7 +1,4 @@
[
{
"pending": "#41: a snap fit stays at its old point when its wall moves (a floating body)"
},
{
"note": "Acceptance (#27): the board grows 40 \u2192 50 mm deep; the snap fit stays on the moved front wall."
},
@@ -50,6 +50,7 @@
]
}
},
{"click": {"sketch": [20, -8]}},
{
"text": "50"
},
@@ -40,6 +40,7 @@
]
}
},
{"click": {"sketch": [20, -8]}},
{
"text": "40"
},
@@ -76,6 +76,14 @@
{
"key": "Enter"
},
{
"call": "ui.set",
"params": {
"hiddenBodies": [
"Body1"
]
}
},
{
"start": "sketch.create"
},
@@ -84,6 +92,12 @@
"plane": "XZ"
}
},
{
"call": "ui.set",
"params": {
"hiddenBodies": []
}
},
{
"start": "sketch.rectangle.two_point"
},
@@ -99,7 +113,7 @@
"click": {
"sketch": [
50,
10
-10
]
}
},
@@ -116,16 +130,13 @@
}
},
{
"start": "solid.extrude"
"call": "ui.view",
"params": {
"view": "fit"
}
},
{
"click": {
"world": [
40,
0,
5
]
}
"start": "solid.extrude"
},
{
"text": "20"
@@ -199,7 +210,7 @@
"max": [
null,
null,
20
30
]
}
}
@@ -231,4 +242,4 @@
{
"shot": "s07_driven"
}
]
]
@@ -40,6 +40,7 @@
]
}
},
{"click": {"sketch": [20, -8]}},
{
"text": "40"
},
@@ -95,6 +95,14 @@
]
}
},
{
"click": {
"sketch": [
20,
-8
]
}
},
{
"text": "40"
},
@@ -109,6 +117,14 @@
]
}
},
{
"click": {
"sketch": [
48,
12
]
}
},
{
"text": "25"
},
@@ -123,6 +139,14 @@
]
}
},
{
"click": {
"sketch": [
30,
20
]
}
},
{
"text": "10"
},
@@ -219,4 +243,4 @@
{
"shot": "s11_done"
}
]
]
@@ -76,6 +76,14 @@
{
"key": "Enter"
},
{
"call": "ui.set",
"params": {
"hiddenBodies": [
"Body1"
]
}
},
{
"start": "sketch.create"
},
@@ -84,6 +92,12 @@
"plane": "XZ"
}
},
{
"call": "ui.set",
"params": {
"hiddenBodies": []
}
},
{
"start": "sketch.rectangle.two_point"
},
@@ -99,7 +113,7 @@
"click": {
"sketch": [
50,
10
-10
]
}
},
@@ -116,16 +130,13 @@
}
},
{
"start": "solid.extrude"
"call": "ui.view",
"params": {
"view": "fit"
}
},
{
"click": {
"world": [
40,
0,
5
]
}
"start": "solid.extrude"
},
{
"text": "20"
@@ -236,4 +247,4 @@
{
"shot": "s20_section_driven"
}
]
]
@@ -43,6 +43,7 @@
]
}
},
{"click": {"sketch": [20, -8]}},
{
"text": "40"
},

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